pymdkit 1.4.2__tar.gz → 1.4.4__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.2/src/pymdkit.egg-info → pymdkit-1.4.4}/PKG-INFO +9 -3
- {pymdkit-1.4.2 → pymdkit-1.4.4}/README.md +10 -4
- {pymdkit-1.4.2 → pymdkit-1.4.4}/pyproject.toml +1 -1
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/__init__.py +1 -1
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_gpumd.py +168 -71
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/compute_msd_all_groups.py +33 -17
- pymdkit-1.4.4/src/pymdkit/commands/gpumd_thermo.py +260 -0
- pymdkit-1.4.4/src/pymdkit/commands/trj_sparse.py +79 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4/src/pymdkit.egg-info}/PKG-INFO +9 -3
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/SOURCES.txt +1 -0
- pymdkit-1.4.2/src/pymdkit/commands/gpumd_thermo.py +0 -131
- {pymdkit-1.4.2 → pymdkit-1.4.4}/LICENSE +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/setup.cfg +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/__init__.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_extxyz.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_fileio.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_geometry.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_reduction.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_vasp_output.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_vaspset.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/add_config_type.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/chemsys_entry.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/compute_ehull.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/compute_rmsd.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/convert.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/ewald.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/gather_contcar.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/gpumd_group.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/nep_rmse.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/pca.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/perturb.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/submit_vasp.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/substitute.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/supercell.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/symmetrize.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/trj_extract.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/umap.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp2xyz.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_fe.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_relax.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_static.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/pymdkit_main.py +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/dependency_links.txt +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/entry_points.txt +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/requires.txt +0 -0
- {pymdkit-1.4.2 → pymdkit-1.4.4}/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.4
|
|
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
|
|
@@ -73,6 +73,7 @@ directory for job sub-folders automatically.
|
|
|
73
73
|
| `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
|
|
74
74
|
| `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
|
|
75
75
|
| `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
|
|
76
|
+
| `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
|
|
76
77
|
| `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
|
|
77
78
|
| `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
|
|
78
79
|
| `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
|
|
@@ -137,6 +138,7 @@ pmk gpumd-thermo -t thermo.out -in run.in
|
|
|
137
138
|
pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
|
|
138
139
|
pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
|
|
139
140
|
pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
|
|
141
|
+
pmk trj-sparse -it example.xyz -r 0.5 -o sparse-example.xyz
|
|
140
142
|
|
|
141
143
|
pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
|
|
142
144
|
pmk rmsd vasp-opted/ # all pairs in a folder -> rmsd.txt
|
|
@@ -175,9 +177,13 @@ When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every
|
|
|
175
177
|
|
|
176
178
|
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`.
|
|
177
179
|
|
|
178
|
-
`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. Potential energy is the primary equilibrium signal
|
|
180
|
+
`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. Potential energy is the primary equilibrium signal. Detection requires a persistent change with a meaningful magnitude relative to the robust natural energy fluctuations, so ordinary correlated thermal noise and isolated spikes are not classified as abnormal. When multiple lattice or volume measures independently show a transition, a more sensitive noise-relative potential-energy test is enabled; this avoids a fixed eV or percentage-of-total-energy threshold that would depend on system size. Lattice parameters, volume, temperature, pressure, and angles remain supporting diagnostics. Thermo plots use Arial when available, opaque legends, correct Å units, and shaded abnormal regions on potential energy, lattice parameters, and volume. Potential energy and volume are divided by 1000 and labeled ×10³ only when their absolute plotted values reach 1000. The analysis uses the last `run` segment, or the last 80% when `run.in` has no `run` record; inspect the plot before making a final scientific judgment.
|
|
179
181
|
|
|
180
|
-
|
|
182
|
+
The thermo parser follows the [official GPUMD thermo.out format](https://gpumd.org/gpumd/output_files/thermo_out.html): 18 columns are required, `pressure.txt` exports `Pxx Pyy Pzz Pyz Pxz Pxy`, box vectors are interpreted as a full 3×3 matrix, `time_step` propagates between runs, and `dump_thermo` does not.
|
|
183
|
+
|
|
184
|
+
`gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Stable trajectories are merged in deterministic folder order into `train.xyz` (or `-o`). For an abnormal job, frames from the structurally corroborated potential-energy region are written to `abnormal.xyz` (or `-ao`); if no energy region can be resolved, the dominant structural transition is used as a fallback so an abnormal job cannot produce an empty abnormal output. Frame times are obtained from `time_step` and `dump_xyz` in that job's `run.in`. Missing, unreadable, or unmappable jobs are skipped. When `<job>/model.xyz` contains `Config_type`, that value is applied to every exported frame while all other extended-XYZ bytes remain unchanged.
|
|
185
|
+
|
|
186
|
+
`trj-sparse` keeps `max(1, floor(frame_count × ratio))` frames without rewriting their extended-XYZ content. Selection is deterministic and starts with frame zero; for 100 frames and `-r 0.5`, it writes source indices `0, 2, ..., 98`.
|
|
181
187
|
|
|
182
188
|
Each command's full flag list is in `pmk <command> -help`.
|
|
183
189
|
|
|
@@ -46,7 +46,8 @@ directory for job sub-folders automatically.
|
|
|
46
46
|
| `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
|
|
47
47
|
| `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
|
|
48
48
|
| `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
|
|
49
|
-
| `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
|
|
49
|
+
| `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
|
|
50
|
+
| `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
|
|
50
51
|
| `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
|
|
51
52
|
| `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
|
|
52
53
|
| `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
|
|
@@ -110,7 +111,8 @@ pmk gpumd-thermo # current job, or recursively scan
|
|
|
110
111
|
pmk gpumd-thermo -t thermo.out -in run.in
|
|
111
112
|
pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
|
|
112
113
|
pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
|
|
113
|
-
pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
|
|
114
|
+
pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
|
|
115
|
+
pmk trj-sparse -it example.xyz -r 0.5 -o sparse-example.xyz
|
|
114
116
|
|
|
115
117
|
pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
|
|
116
118
|
pmk rmsd vasp-opted/ # all pairs in a folder -> rmsd.txt
|
|
@@ -149,10 +151,14 @@ When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every
|
|
|
149
151
|
|
|
150
152
|
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`.
|
|
151
153
|
|
|
152
|
-
`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. Potential energy is the primary equilibrium signal
|
|
154
|
+
`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. Potential energy is the primary equilibrium signal. Detection requires a persistent change with a meaningful magnitude relative to the robust natural energy fluctuations, so ordinary correlated thermal noise and isolated spikes are not classified as abnormal. When multiple lattice or volume measures independently show a transition, a more sensitive noise-relative potential-energy test is enabled; this avoids a fixed eV or percentage-of-total-energy threshold that would depend on system size. Lattice parameters, volume, temperature, pressure, and angles remain supporting diagnostics. Thermo plots use Arial when available, opaque legends, correct Å units, and shaded abnormal regions on potential energy, lattice parameters, and volume. Potential energy and volume are divided by 1000 and labeled ×10³ only when their absolute plotted values reach 1000. The analysis uses the last `run` segment, or the last 80% when `run.in` has no `run` record; inspect the plot before making a final scientific judgment.
|
|
153
155
|
|
|
154
|
-
|
|
156
|
+
The thermo parser follows the [official GPUMD thermo.out format](https://gpumd.org/gpumd/output_files/thermo_out.html): 18 columns are required, `pressure.txt` exports `Pxx Pyy Pzz Pyz Pxz Pxy`, box vectors are interpreted as a full 3×3 matrix, `time_step` propagates between runs, and `dump_thermo` does not.
|
|
157
|
+
|
|
158
|
+
`gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Stable trajectories are merged in deterministic folder order into `train.xyz` (or `-o`). For an abnormal job, frames from the structurally corroborated potential-energy region are written to `abnormal.xyz` (or `-ao`); if no energy region can be resolved, the dominant structural transition is used as a fallback so an abnormal job cannot produce an empty abnormal output. Frame times are obtained from `time_step` and `dump_xyz` in that job's `run.in`. Missing, unreadable, or unmappable jobs are skipped. When `<job>/model.xyz` contains `Config_type`, that value is applied to every exported frame while all other extended-XYZ bytes remain unchanged.
|
|
155
159
|
|
|
160
|
+
`trj-sparse` keeps `max(1, floor(frame_count × ratio))` frames without rewriting their extended-XYZ content. Selection is deterministic and starts with frame zero; for 100 frames and `-r 0.5`, it writes source indices `0, 2, ..., 98`.
|
|
161
|
+
|
|
156
162
|
Each command's full flag list is in `pmk <command> -help`.
|
|
157
163
|
|
|
158
164
|
For long VASP batch submission, run `submit-vasp` with `nohup` and `&` if you
|
|
@@ -24,7 +24,8 @@ class ThermoData:
|
|
|
24
24
|
|
|
25
25
|
def parse_run_input(path):
|
|
26
26
|
time_step = 1.0
|
|
27
|
-
|
|
27
|
+
active_dump = None
|
|
28
|
+
last_dump = None
|
|
28
29
|
runs = []
|
|
29
30
|
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
|
30
31
|
tokens = raw.split("#", 1)[0].split()
|
|
@@ -34,13 +35,16 @@ def parse_run_input(path):
|
|
|
34
35
|
if tokens[0] == "time_step" and len(tokens) >= 2:
|
|
35
36
|
time_step = float(tokens[1])
|
|
36
37
|
elif tokens[0] == "dump_thermo" and len(tokens) >= 2:
|
|
37
|
-
|
|
38
|
+
active_dump = int(tokens[1])
|
|
39
|
+
if active_dump <= 0:
|
|
40
|
+
raise ValueError("dump_thermo interval must be positive")
|
|
41
|
+
last_dump = active_dump
|
|
38
42
|
elif tokens[0] == "run" and len(tokens) >= 2:
|
|
39
|
-
runs.append((int(tokens[1]),
|
|
43
|
+
runs.append((int(tokens[1]), active_dump, time_step))
|
|
44
|
+
active_dump = None
|
|
40
45
|
except ValueError as exc:
|
|
41
46
|
raise ValueError(f"invalid {tokens[0]} setting in {path}: {raw}") from exc
|
|
42
|
-
return time_step,
|
|
43
|
-
|
|
47
|
+
return time_step, last_dump, runs
|
|
44
48
|
|
|
45
49
|
def trajectory_interval(run_input, trajectory):
|
|
46
50
|
"""Return the GPUMD XYZ dump timing as (time_step, steps, ps)."""
|
|
@@ -77,35 +81,38 @@ def trajectory_interval(run_input, trajectory):
|
|
|
77
81
|
|
|
78
82
|
|
|
79
83
|
def time_axis(length, time_step, dump_interval, runs):
|
|
80
|
-
expected = sum(
|
|
84
|
+
expected = sum(
|
|
85
|
+
steps // interval
|
|
86
|
+
for steps, interval, _ in runs
|
|
87
|
+
if interval is not None
|
|
88
|
+
)
|
|
81
89
|
if runs and expected == length:
|
|
82
90
|
values = []
|
|
83
91
|
elapsed = 0.0
|
|
84
92
|
for steps, interval, step_fs in runs:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
if interval is not None:
|
|
94
|
+
count = steps // interval
|
|
95
|
+
increment = step_fs * interval / 1000.0
|
|
96
|
+
values.extend(elapsed + np.arange(count) * increment)
|
|
88
97
|
elapsed += steps * step_fs / 1000.0
|
|
89
98
|
return np.asarray(values, dtype=float)
|
|
99
|
+
if dump_interval is None:
|
|
100
|
+
raise ValueError("no dump_thermo setting found in run.in")
|
|
90
101
|
increment = time_step * dump_interval / 1000.0
|
|
91
102
|
return np.arange(length, dtype=float) * increment
|
|
92
103
|
|
|
93
104
|
|
|
94
105
|
def equilibrium_start(length, runs):
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
for steps, interval, _ in reversed(runs):
|
|
107
|
+
if interval is not None:
|
|
108
|
+
points = steps // interval
|
|
109
|
+
return max(0, length - points)
|
|
110
|
+
return int(length * 0.2)
|
|
100
111
|
|
|
101
112
|
def cell_quantities(data):
|
|
102
|
-
if data.shape[1] == 12:
|
|
103
|
-
lengths = data[:, 9:12]
|
|
104
|
-
angles = np.full((len(data), 3), 90.0)
|
|
105
|
-
return lengths, angles, np.prod(lengths, axis=1)
|
|
106
113
|
if data.shape[1] != 18:
|
|
107
114
|
raise ValueError(
|
|
108
|
-
f"thermo.out has {data.shape[1]} columns; expected
|
|
115
|
+
f"thermo.out has {data.shape[1]} columns; expected the official 18-column format"
|
|
109
116
|
)
|
|
110
117
|
|
|
111
118
|
vectors = data[:, 9:18].reshape(-1, 3, 3)
|
|
@@ -194,8 +201,48 @@ def _clean_mask(mask):
|
|
|
194
201
|
return mask
|
|
195
202
|
|
|
196
203
|
|
|
197
|
-
def
|
|
198
|
-
|
|
204
|
+
def _bridge_mask(mask, max_gap):
|
|
205
|
+
mask = np.asarray(mask, dtype=bool).copy()
|
|
206
|
+
index = 0
|
|
207
|
+
while index < len(mask):
|
|
208
|
+
if mask[index]:
|
|
209
|
+
index += 1
|
|
210
|
+
continue
|
|
211
|
+
end = index
|
|
212
|
+
while end < len(mask) and not mask[end]:
|
|
213
|
+
end += 1
|
|
214
|
+
if index > 0 and end < len(mask) and end - index <= max_gap:
|
|
215
|
+
mask[index:end] = True
|
|
216
|
+
index = end
|
|
217
|
+
return mask
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _keep_major_regions(mask):
|
|
221
|
+
mask = np.asarray(mask, dtype=bool)
|
|
222
|
+
regions = []
|
|
223
|
+
index = 0
|
|
224
|
+
while index < len(mask):
|
|
225
|
+
if not mask[index]:
|
|
226
|
+
index += 1
|
|
227
|
+
continue
|
|
228
|
+
end = index
|
|
229
|
+
while end < len(mask) and mask[end]:
|
|
230
|
+
end += 1
|
|
231
|
+
regions.append((index, end))
|
|
232
|
+
index = end
|
|
233
|
+
if not regions:
|
|
234
|
+
return np.zeros(len(mask), dtype=bool)
|
|
235
|
+
|
|
236
|
+
longest = max(end - begin for begin, end in regions)
|
|
237
|
+
minimum = max(3, len(mask) // 50, longest // 4)
|
|
238
|
+
result = np.zeros(len(mask), dtype=bool)
|
|
239
|
+
for begin, end in regions:
|
|
240
|
+
if end - begin >= minimum:
|
|
241
|
+
result[begin:end] = True
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _persistent_change_mask(values, corroborated=False, bridge=True):
|
|
199
246
|
values = np.asarray(values, dtype=float)
|
|
200
247
|
size = len(values)
|
|
201
248
|
if size < 20 or not np.all(np.isfinite(values)):
|
|
@@ -204,55 +251,69 @@ def potential_abnormal_mask(values):
|
|
|
204
251
|
edge_count = max(5, size // 10)
|
|
205
252
|
left_center, left_spread = _robust_spread(values[:edge_count])
|
|
206
253
|
right_center, right_spread = _robust_spread(values[-edge_count:])
|
|
254
|
+
noise = max(left_spread, right_spread, 1e-12)
|
|
207
255
|
difference = abs(right_center - left_center)
|
|
208
256
|
|
|
209
|
-
window = max(5, size //
|
|
257
|
+
window = max(5, size // 100)
|
|
210
258
|
if window % 2 == 0:
|
|
211
259
|
window += 1
|
|
212
260
|
radius = window // 2
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
])
|
|
261
|
+
padded = np.pad(values, (radius, radius), mode="edge")
|
|
262
|
+
cumulative = np.cumsum(np.insert(padded, 0, 0.0))
|
|
263
|
+
smooth = (cumulative[window:] - cumulative[:-window]) / window
|
|
217
264
|
|
|
218
|
-
|
|
219
|
-
|
|
265
|
+
effect_multiplier = 0.5 if corroborated else 1.5
|
|
266
|
+
practical_shift = max(
|
|
267
|
+
effect_multiplier * noise,
|
|
220
268
|
1e-6 * max(abs(left_center), abs(right_center), 1.0),
|
|
221
269
|
)
|
|
222
|
-
if difference >
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
4.0 * max(left_spread, right_spread) / np.sqrt(window),
|
|
227
|
-
0.02 * difference,
|
|
228
|
-
1e-12,
|
|
229
|
-
)
|
|
230
|
-
distance = np.minimum(
|
|
270
|
+
if difference > practical_shift:
|
|
271
|
+
tolerance_multiplier = 0.25 if corroborated else 0.75
|
|
272
|
+
tolerance = max(tolerance_multiplier * noise, 0.05 * difference)
|
|
273
|
+
smooth_distance = np.minimum(
|
|
231
274
|
np.abs(smooth - left_center), np.abs(smooth - right_center)
|
|
232
275
|
)
|
|
233
|
-
|
|
234
|
-
if
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
# An abrupt jump may contain no intermediate values. In that case,
|
|
238
|
-
# retain a narrow window around the change rather than one plateau.
|
|
239
|
-
score = np.zeros(size)
|
|
240
|
-
for index in range(window, size - window):
|
|
241
|
-
score[index] = abs(
|
|
242
|
-
np.mean(smooth[index:index + window])
|
|
243
|
-
- np.mean(smooth[index - window:index])
|
|
276
|
+
candidate = smooth_distance > tolerance
|
|
277
|
+
if not corroborated:
|
|
278
|
+
raw_distance = np.minimum(
|
|
279
|
+
np.abs(values - left_center), np.abs(values - right_center)
|
|
244
280
|
)
|
|
281
|
+
candidate &= raw_distance > tolerance
|
|
282
|
+
else:
|
|
283
|
+
baseline = np.concatenate((values[:edge_count], values[-edge_count:]))
|
|
284
|
+
center, spread = _robust_spread(baseline)
|
|
285
|
+
multiplier = 1.0 if corroborated else 2.0
|
|
245
286
|
threshold = max(
|
|
246
|
-
|
|
247
|
-
4.0 * max(left_spread, right_spread) / np.sqrt(window),
|
|
287
|
+
multiplier * spread, 1e-6 * max(abs(center), 1.0), 1e-12
|
|
248
288
|
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
289
|
+
candidate = np.abs(smooth - center) > threshold
|
|
290
|
+
if not corroborated:
|
|
291
|
+
candidate &= np.abs(values - center) > threshold
|
|
292
|
+
|
|
293
|
+
if corroborated and bridge:
|
|
294
|
+
candidate = _bridge_mask(candidate, max(1, size // 20))
|
|
295
|
+
return _clean_mask(candidate)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def potential_abnormal_mask(values, corroborated=False):
|
|
299
|
+
"""Locate persistent potential-energy transitions or abnormal plateaus."""
|
|
300
|
+
return _persistent_change_mask(values, corroborated=corroborated)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def structural_abnormal_mask(lengths, volume):
|
|
304
|
+
"""Find dominant transition regions supported by multiple cell measures."""
|
|
305
|
+
components = [np.asarray(lengths)[:, index] for index in range(3)]
|
|
306
|
+
components.append(np.asarray(volume))
|
|
307
|
+
masks = [
|
|
308
|
+
_persistent_change_mask(values, corroborated=True, bridge=False)
|
|
309
|
+
for values in components
|
|
310
|
+
]
|
|
311
|
+
votes = np.sum(masks, axis=0)
|
|
312
|
+
candidate = votes >= 2
|
|
313
|
+
if not candidate.any():
|
|
314
|
+
candidate = votes >= 1
|
|
315
|
+
candidate = _bridge_mask(candidate, max(1, len(candidate) // 50))
|
|
316
|
+
return _keep_major_regions(_clean_mask(candidate))
|
|
256
317
|
|
|
257
318
|
def mask_intervals(time, mask):
|
|
258
319
|
"""Return inclusive physical-time intervals for contiguous true mask regions."""
|
|
@@ -278,7 +339,7 @@ def _format_intervals(intervals):
|
|
|
278
339
|
|
|
279
340
|
def find_abnormal(series, start):
|
|
280
341
|
component_names = {
|
|
281
|
-
"pressure": ("
|
|
342
|
+
"pressure": ("xx", "yy", "zz", "yz", "xz", "xy"),
|
|
282
343
|
"lattice-parameter": ("a", "b", "c"),
|
|
283
344
|
"lattice-angle": ("alpha", "beta", "gamma"),
|
|
284
345
|
}
|
|
@@ -301,24 +362,59 @@ def find_abnormal(series, start):
|
|
|
301
362
|
def load_thermo(thermo_path, run_path):
|
|
302
363
|
thermo_path = Path(thermo_path)
|
|
303
364
|
data = np.loadtxt(thermo_path, ndmin=2)
|
|
304
|
-
if data.shape[1]
|
|
305
|
-
raise ValueError(f"{thermo_path} must
|
|
365
|
+
if data.shape[1] != 18:
|
|
366
|
+
raise ValueError(f"{thermo_path} must use the official 18-column GPUMD format")
|
|
306
367
|
|
|
307
368
|
time_step, dump_interval, runs = parse_run_input(run_path)
|
|
308
369
|
time = time_axis(len(data), time_step, dump_interval, runs)
|
|
309
370
|
temperature = data[:, 0]
|
|
310
371
|
potential = data[:, 2]
|
|
311
|
-
pressure = data[:, 3:
|
|
372
|
+
pressure = data[:, 3:9]
|
|
312
373
|
lengths, angles, volume = cell_quantities(data)
|
|
313
374
|
start = equilibrium_start(len(data), runs)
|
|
314
375
|
|
|
376
|
+
structural_warnings = find_abnormal({
|
|
377
|
+
"lattice-parameter": lengths,
|
|
378
|
+
"volume": volume,
|
|
379
|
+
"lattice-angle": angles,
|
|
380
|
+
}, start)
|
|
381
|
+
other_warnings = find_abnormal({
|
|
382
|
+
"temperature": temperature,
|
|
383
|
+
"pressure": pressure,
|
|
384
|
+
}, start)
|
|
385
|
+
|
|
315
386
|
abnormal_mask = np.zeros(len(data), dtype=bool)
|
|
316
|
-
|
|
317
|
-
|
|
387
|
+
segment_mask = potential_abnormal_mask(potential[start:])
|
|
388
|
+
abnormal_mask[start:] = segment_mask
|
|
318
389
|
warnings = []
|
|
390
|
+
|
|
391
|
+
if not segment_mask.any() and structural_warnings:
|
|
392
|
+
structural_mask = structural_abnormal_mask(
|
|
393
|
+
lengths[start:], volume[start:]
|
|
394
|
+
)
|
|
395
|
+
relaxed_energy = potential_abnormal_mask(
|
|
396
|
+
potential[start:], corroborated=True
|
|
397
|
+
)
|
|
398
|
+
overlap = relaxed_energy & structural_mask
|
|
399
|
+
if overlap.any():
|
|
400
|
+
segment_mask = _keep_major_regions(
|
|
401
|
+
_bridge_mask(overlap, max(1, len(overlap) // 20))
|
|
402
|
+
)
|
|
403
|
+
warning_prefix = "potential-energy: structurally corroborated"
|
|
404
|
+
elif structural_mask.any():
|
|
405
|
+
segment_mask = structural_mask
|
|
406
|
+
warning_prefix = "structure: mapped fallback"
|
|
407
|
+
else:
|
|
408
|
+
segment_mask = np.ones(len(potential) - start, dtype=bool)
|
|
409
|
+
warning_prefix = "structure: full equilibrium fallback"
|
|
410
|
+
abnormal_mask[start:] = segment_mask
|
|
411
|
+
else:
|
|
412
|
+
warning_prefix = "potential-energy: localized"
|
|
413
|
+
|
|
414
|
+
intervals = mask_intervals(time, abnormal_mask)
|
|
319
415
|
if intervals:
|
|
320
416
|
warnings.append(
|
|
321
|
-
"
|
|
417
|
+
f"{warning_prefix} abnormal region(s) at "
|
|
322
418
|
+ _format_intervals(intervals)
|
|
323
419
|
)
|
|
324
420
|
else:
|
|
@@ -327,13 +423,14 @@ def load_thermo(thermo_path, run_path):
|
|
|
327
423
|
warnings.append(f"potential-energy: {potential_reason}")
|
|
328
424
|
abnormal_mask[start:] = True
|
|
329
425
|
|
|
330
|
-
warnings.extend(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
426
|
+
warnings.extend(structural_warnings)
|
|
427
|
+
warnings.extend(other_warnings)
|
|
428
|
+
if warnings and not abnormal_mask.any():
|
|
429
|
+
abnormal_mask[start:] = True
|
|
430
|
+
warnings.insert(
|
|
431
|
+
0, "region mapping: full equilibrium segment used as fallback"
|
|
432
|
+
)
|
|
433
|
+
|
|
337
434
|
return ThermoData(
|
|
338
435
|
time=time,
|
|
339
436
|
temperature=temperature,
|
|
@@ -59,9 +59,11 @@ class MDAnalyzer:
|
|
|
59
59
|
if not path.exists():
|
|
60
60
|
return []
|
|
61
61
|
runs = []
|
|
62
|
-
dump_interval
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
dump_interval = None
|
|
63
|
+
dump_msd = None
|
|
64
|
+
time_step = 1.0
|
|
65
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
66
|
+
parts = raw.split("#", 1)[0].split()
|
|
65
67
|
if not parts:
|
|
66
68
|
continue
|
|
67
69
|
key = parts[0]
|
|
@@ -75,11 +77,12 @@ class MDAnalyzer:
|
|
|
75
77
|
runs.append({
|
|
76
78
|
"steps": int(parts[1]),
|
|
77
79
|
"dump_interval": dump_interval,
|
|
78
|
-
"dump_msd": dump_msd
|
|
80
|
+
"dump_msd": dump_msd,
|
|
79
81
|
"time_step": time_step,
|
|
80
82
|
})
|
|
83
|
+
dump_interval = None
|
|
84
|
+
dump_msd = None
|
|
81
85
|
return runs
|
|
82
|
-
|
|
83
86
|
@staticmethod
|
|
84
87
|
def _detect_all_groups(filepath) -> bool:
|
|
85
88
|
"""True if a `compute_msd ... all_groups` directive is present."""
|
|
@@ -100,19 +103,26 @@ class MDAnalyzer:
|
|
|
100
103
|
|
|
101
104
|
@staticmethod
|
|
102
105
|
def _calculate_volume(data: np.ndarray) -> np.ndarray:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
np.cross(box[:, 1], box[:, 2])))
|
|
110
|
-
raise ValueError(f"Unsupported thermo.out format with {n_cols} columns")
|
|
106
|
+
if data.shape[1] != 18:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"thermo.out must use the official 18-column GPUMD format"
|
|
109
|
+
)
|
|
110
|
+
box = data[:, 9:18].reshape(-1, 3, 3)
|
|
111
|
+
return np.abs(np.linalg.det(box))
|
|
111
112
|
|
|
112
113
|
def load_thermo_data(self, filepath) -> Tuple[float, float]:
|
|
113
114
|
data = np.atleast_2d(np.loadtxt(Path(filepath)))
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
thermo_runs = [
|
|
116
|
+
run for run in self.runs_info
|
|
117
|
+
if run["dump_interval"] is not None
|
|
118
|
+
]
|
|
119
|
+
if thermo_runs:
|
|
120
|
+
run = thermo_runs[-1]
|
|
121
|
+
sl = self._last_run_slice(
|
|
122
|
+
len(data), run["dump_interval"]
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
sl = slice(0, len(data))
|
|
116
126
|
self.temperature = float(np.mean(data[sl, 0]))
|
|
117
127
|
self.volume = float(np.mean(self._calculate_volume(data)[sl]))
|
|
118
128
|
return self.temperature, self.volume
|
|
@@ -143,8 +153,14 @@ class MDAnalyzer:
|
|
|
143
153
|
group's worth of columns it is mapped to the diffuse-ion group.
|
|
144
154
|
"""
|
|
145
155
|
data = np.loadtxt(Path(filepath))
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
msd_runs = [
|
|
157
|
+
run for run in self.runs_info
|
|
158
|
+
if run["dump_msd"] is not None
|
|
159
|
+
]
|
|
160
|
+
if msd_runs:
|
|
161
|
+
data = data[self._last_run_slice(
|
|
162
|
+
len(data), msd_runs[-1]["dump_msd"]
|
|
163
|
+
)]
|
|
148
164
|
times = data[:, 0] - data[0, 0]
|
|
149
165
|
|
|
150
166
|
num_groups = (data.shape[1] - 1) // 6
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Export, plot, and check GPUMD thermo data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from ._gpumd import load_thermo, mask_intervals
|
|
12
|
+
except ImportError:
|
|
13
|
+
from _gpumd import load_thermo, mask_intervals
|
|
14
|
+
|
|
15
|
+
COMMAND = "gpumd-thermo"
|
|
16
|
+
HELP = "Export and plot GPUMD thermo.out data and warn about instability."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_table(path, time, values, header):
|
|
20
|
+
values = np.asarray(values)
|
|
21
|
+
if values.ndim == 1:
|
|
22
|
+
values = values[:, None]
|
|
23
|
+
np.savetxt(path, np.column_stack([time, values]), fmt="%.10g", header=header)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scaled_plot_values(values, quantity, unit):
|
|
27
|
+
values = np.asarray(values, dtype=float)
|
|
28
|
+
finite = np.abs(values[np.isfinite(values)])
|
|
29
|
+
use_thousands = finite.size > 0 and float(np.max(finite)) >= 1000.0
|
|
30
|
+
if use_thousands:
|
|
31
|
+
return values / 1000.0, f"{quantity} (×10³ {unit})"
|
|
32
|
+
return values, f"{quantity} ({unit})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mark_abnormal_regions(axis, intervals):
|
|
36
|
+
for begin, end in intervals:
|
|
37
|
+
axis.axvspan(
|
|
38
|
+
begin, end, color="#d9e8f5", alpha=0.85, linewidth=0, zorder=0
|
|
39
|
+
)
|
|
40
|
+
axis.text(
|
|
41
|
+
(begin + end) / 2.0,
|
|
42
|
+
0.97,
|
|
43
|
+
"abnormal region",
|
|
44
|
+
transform=axis.get_xaxis_transform(),
|
|
45
|
+
ha="center",
|
|
46
|
+
va="top",
|
|
47
|
+
fontsize=7,
|
|
48
|
+
color="#315a7d",
|
|
49
|
+
bbox={
|
|
50
|
+
"facecolor": "white",
|
|
51
|
+
"edgecolor": "none",
|
|
52
|
+
"alpha": 0.8,
|
|
53
|
+
"pad": 1.0,
|
|
54
|
+
},
|
|
55
|
+
clip_on=True,
|
|
56
|
+
zorder=4,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def plot_thermo(path, thermo):
|
|
61
|
+
import matplotlib
|
|
62
|
+
matplotlib.use("Agg")
|
|
63
|
+
matplotlib.rcParams.update({
|
|
64
|
+
"font.family": "sans-serif",
|
|
65
|
+
"font.sans-serif": ["Arial", "Liberation Sans", "DejaVu Sans"],
|
|
66
|
+
})
|
|
67
|
+
import matplotlib.pyplot as plt
|
|
68
|
+
|
|
69
|
+
colors = ("#d22027", "#015699", "#fac00f")
|
|
70
|
+
legend_style = {
|
|
71
|
+
"loc": "best",
|
|
72
|
+
"frameon": True,
|
|
73
|
+
"facecolor": "white",
|
|
74
|
+
"edgecolor": "none",
|
|
75
|
+
"framealpha": 1.0,
|
|
76
|
+
}
|
|
77
|
+
intervals = mask_intervals(thermo.time, thermo.abnormal_mask)
|
|
78
|
+
potential, potential_label = scaled_plot_values(
|
|
79
|
+
thermo.potential, "Potential energy", "eV"
|
|
80
|
+
)
|
|
81
|
+
volume, volume_label = scaled_plot_values(
|
|
82
|
+
thermo.volume, "Volume", "ų"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
fig, axes = plt.subplots(2, 3, figsize=(12, 6), dpi=100)
|
|
86
|
+
|
|
87
|
+
axes[0, 0].plot(
|
|
88
|
+
thermo.time, thermo.temperature, color=colors[1], zorder=2
|
|
89
|
+
)
|
|
90
|
+
axes[0, 0].set_ylabel("Temperature (K)")
|
|
91
|
+
|
|
92
|
+
axes[0, 1].plot(
|
|
93
|
+
thermo.time, potential, color=colors[0], zorder=2
|
|
94
|
+
)
|
|
95
|
+
axes[0, 1].set_ylabel(potential_label)
|
|
96
|
+
mark_abnormal_regions(axes[0, 1], intervals)
|
|
97
|
+
|
|
98
|
+
for index, label in enumerate(("Pxx", "Pyy", "Pzz")):
|
|
99
|
+
axes[0, 2].plot(
|
|
100
|
+
thermo.time,
|
|
101
|
+
thermo.pressure[:, index],
|
|
102
|
+
label=label,
|
|
103
|
+
color=colors[index],
|
|
104
|
+
zorder=2,
|
|
105
|
+
)
|
|
106
|
+
axes[0, 2].set_ylabel("Pressure (GPa)")
|
|
107
|
+
axes[0, 2].legend(**legend_style).set_zorder(4)
|
|
108
|
+
|
|
109
|
+
for index, label in enumerate(("a", "b", "c")):
|
|
110
|
+
axes[1, 0].plot(
|
|
111
|
+
thermo.time,
|
|
112
|
+
thermo.lengths[:, index],
|
|
113
|
+
label=label,
|
|
114
|
+
color=colors[index],
|
|
115
|
+
zorder=2,
|
|
116
|
+
)
|
|
117
|
+
axes[1, 0].set_ylabel("Lattice parameters (Å)")
|
|
118
|
+
mark_abnormal_regions(axes[1, 0], intervals)
|
|
119
|
+
axes[1, 0].legend(**legend_style).set_zorder(4)
|
|
120
|
+
|
|
121
|
+
axes[1, 1].plot(
|
|
122
|
+
thermo.time, volume, color=colors[1], zorder=2
|
|
123
|
+
)
|
|
124
|
+
axes[1, 1].set_ylabel(volume_label)
|
|
125
|
+
mark_abnormal_regions(axes[1, 1], intervals)
|
|
126
|
+
|
|
127
|
+
for index, label in enumerate(("alpha", "beta", "gamma")):
|
|
128
|
+
axes[1, 2].plot(
|
|
129
|
+
thermo.time,
|
|
130
|
+
thermo.angles[:, index],
|
|
131
|
+
label=label,
|
|
132
|
+
color=colors[index],
|
|
133
|
+
zorder=2,
|
|
134
|
+
)
|
|
135
|
+
axes[1, 2].set_ylabel("Lattice angles (degree)")
|
|
136
|
+
axes[1, 2].legend(**legend_style).set_zorder(4)
|
|
137
|
+
|
|
138
|
+
for index, axis in enumerate(axes.flat):
|
|
139
|
+
axis.set_xlabel("Time (ps)")
|
|
140
|
+
axis.text(
|
|
141
|
+
-0.14,
|
|
142
|
+
1.03,
|
|
143
|
+
f"({'abcdef'[index]})",
|
|
144
|
+
transform=axis.transAxes,
|
|
145
|
+
fontsize=12,
|
|
146
|
+
va="top",
|
|
147
|
+
ha="right",
|
|
148
|
+
)
|
|
149
|
+
fig.tight_layout()
|
|
150
|
+
fig.savefig(path, dpi=300)
|
|
151
|
+
plt.close(fig)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def process_job(folder, thermo_name, run_name):
|
|
155
|
+
thermo = load_thermo(folder / thermo_name, folder / run_name)
|
|
156
|
+
save_table(
|
|
157
|
+
folder / "temperature.txt",
|
|
158
|
+
thermo.time,
|
|
159
|
+
thermo.temperature,
|
|
160
|
+
"time(ps) temperature(K)",
|
|
161
|
+
)
|
|
162
|
+
save_table(
|
|
163
|
+
folder / "potential-energy.txt",
|
|
164
|
+
thermo.time,
|
|
165
|
+
thermo.potential,
|
|
166
|
+
"time(ps) potential_energy(eV)",
|
|
167
|
+
)
|
|
168
|
+
save_table(
|
|
169
|
+
folder / "pressure.txt",
|
|
170
|
+
thermo.time,
|
|
171
|
+
thermo.pressure,
|
|
172
|
+
"time(ps) Pxx(GPa) Pyy(GPa) Pzz(GPa) Pyz(GPa) Pxz(GPa) Pxy(GPa)",
|
|
173
|
+
)
|
|
174
|
+
save_table(
|
|
175
|
+
folder / "lattice-parameters.txt",
|
|
176
|
+
thermo.time,
|
|
177
|
+
thermo.lengths,
|
|
178
|
+
"time(ps) a(Å) b(Å) c(Å)",
|
|
179
|
+
)
|
|
180
|
+
save_table(
|
|
181
|
+
folder / "volume.txt",
|
|
182
|
+
thermo.time,
|
|
183
|
+
thermo.volume,
|
|
184
|
+
"time(ps) volume(Å^3)",
|
|
185
|
+
)
|
|
186
|
+
save_table(
|
|
187
|
+
folder / "lattice-angles.txt",
|
|
188
|
+
thermo.time,
|
|
189
|
+
thermo.angles,
|
|
190
|
+
"time(ps) alpha(deg) beta(deg) gamma(deg)",
|
|
191
|
+
)
|
|
192
|
+
plot_thermo(folder / "thermo.png", thermo)
|
|
193
|
+
|
|
194
|
+
print(
|
|
195
|
+
f"{folder}: {len(thermo.time)} thermo record(s), "
|
|
196
|
+
f"{thermo.time[-1]:.6g} ps"
|
|
197
|
+
)
|
|
198
|
+
if thermo.warnings:
|
|
199
|
+
print("WARNING: possible abnormal fluctuation in the equilibrium segment:")
|
|
200
|
+
for warning in thermo.warnings:
|
|
201
|
+
print(f" {warning}")
|
|
202
|
+
else:
|
|
203
|
+
print("No sustained equilibrium drift or level shift detected.")
|
|
204
|
+
print("Wrote thermo text tables and thermo.png")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def discover_jobs(thermo_arg, run_arg):
|
|
208
|
+
thermo = Path(thermo_arg)
|
|
209
|
+
run_input = Path(run_arg)
|
|
210
|
+
if thermo.is_file() and run_input.is_file():
|
|
211
|
+
return [(thermo.parent.resolve(), thermo.name, run_input.name)]
|
|
212
|
+
|
|
213
|
+
root = Path(".").resolve()
|
|
214
|
+
jobs = sorted(
|
|
215
|
+
{
|
|
216
|
+
path.parent.resolve()
|
|
217
|
+
for path in root.rglob(thermo.name)
|
|
218
|
+
if (path.parent / run_input.name).is_file()
|
|
219
|
+
},
|
|
220
|
+
key=lambda path: (len(path.relative_to(root).parts), str(path)),
|
|
221
|
+
)
|
|
222
|
+
return [(folder, thermo.name, run_input.name) for folder in jobs]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"-t",
|
|
228
|
+
"-thermo",
|
|
229
|
+
dest="thermo",
|
|
230
|
+
default="thermo.out",
|
|
231
|
+
help="GPUMD thermo output (default: thermo.out).",
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"-in",
|
|
235
|
+
dest="run_input",
|
|
236
|
+
default="run.in",
|
|
237
|
+
help="GPUMD run input (default: run.in).",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run(args) -> int:
|
|
242
|
+
jobs = discover_jobs(args.thermo, args.run_input)
|
|
243
|
+
if not jobs:
|
|
244
|
+
raise SystemExit(
|
|
245
|
+
"Error: no folder containing both thermo.out and run.in was found."
|
|
246
|
+
)
|
|
247
|
+
failures = 0
|
|
248
|
+
for folder, thermo_name, run_name in jobs:
|
|
249
|
+
try:
|
|
250
|
+
process_job(folder, thermo_name, run_name)
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
failures += 1
|
|
253
|
+
print(f"{folder}: error ({exc})")
|
|
254
|
+
return 1 if failures == len(jobs) else 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
_parser = argparse.ArgumentParser(description=__doc__)
|
|
259
|
+
add_arguments(_parser)
|
|
260
|
+
raise SystemExit(run(_parser.parse_args()))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Deterministically sparse an XYZ trajectory by a retained-frame ratio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from ._extxyz import read_blocks, write_blocks
|
|
10
|
+
except ImportError:
|
|
11
|
+
from _extxyz import read_blocks, write_blocks
|
|
12
|
+
|
|
13
|
+
COMMAND = "trj-sparse"
|
|
14
|
+
HELP = "Retain a deterministic fraction of frames from an XYZ trajectory."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"-it",
|
|
20
|
+
"-input-trajectory",
|
|
21
|
+
dest="input_trajectory",
|
|
22
|
+
required=True,
|
|
23
|
+
help="Input XYZ trajectory.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-r",
|
|
27
|
+
"-ratio",
|
|
28
|
+
dest="ratio",
|
|
29
|
+
required=True,
|
|
30
|
+
type=float,
|
|
31
|
+
help="Fraction of frames to retain, in the interval (0, 1].",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"-o",
|
|
35
|
+
"-output",
|
|
36
|
+
dest="output",
|
|
37
|
+
help="Output trajectory (default: sparse-<input filename>).",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sparse_indices(frame_count, ratio):
|
|
42
|
+
if not 0.0 < ratio <= 1.0:
|
|
43
|
+
raise ValueError("-r must be greater than 0 and no greater than 1")
|
|
44
|
+
if frame_count <= 0:
|
|
45
|
+
return []
|
|
46
|
+
output_count = max(1, int(frame_count * ratio))
|
|
47
|
+
return [int(index / ratio) for index in range(output_count)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run(args) -> int:
|
|
51
|
+
input_path = Path(args.input_trajectory)
|
|
52
|
+
blocks, _ = read_blocks(input_path)
|
|
53
|
+
if not blocks:
|
|
54
|
+
raise SystemExit(f"Error: {input_path} contains no trajectory frames.")
|
|
55
|
+
try:
|
|
56
|
+
indices = sparse_indices(len(blocks), args.ratio)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise SystemExit(f"Error: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
output = (
|
|
61
|
+
Path(args.output)
|
|
62
|
+
if args.output
|
|
63
|
+
else input_path.with_name(f"sparse-{input_path.name}")
|
|
64
|
+
)
|
|
65
|
+
if output.resolve() == input_path.resolve():
|
|
66
|
+
raise SystemExit("Error: input and output trajectories must be different.")
|
|
67
|
+
|
|
68
|
+
write_blocks(output, blocks, indices)
|
|
69
|
+
print(
|
|
70
|
+
f"Wrote {len(indices)} of {len(blocks)} frame(s) "
|
|
71
|
+
f"({len(indices) / len(blocks):.6g}) to {output}"
|
|
72
|
+
)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
_parser = argparse.ArgumentParser(description=__doc__)
|
|
78
|
+
add_arguments(_parser)
|
|
79
|
+
raise SystemExit(run(_parser.parse_args()))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymdkit
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
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
|
|
@@ -73,6 +73,7 @@ directory for job sub-folders automatically.
|
|
|
73
73
|
| `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
|
|
74
74
|
| `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
|
|
75
75
|
| `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
|
|
76
|
+
| `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
|
|
76
77
|
| `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
|
|
77
78
|
| `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
|
|
78
79
|
| `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
|
|
@@ -137,6 +138,7 @@ pmk gpumd-thermo -t thermo.out -in run.in
|
|
|
137
138
|
pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
|
|
138
139
|
pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
|
|
139
140
|
pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
|
|
141
|
+
pmk trj-sparse -it example.xyz -r 0.5 -o sparse-example.xyz
|
|
140
142
|
|
|
141
143
|
pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
|
|
142
144
|
pmk rmsd vasp-opted/ # all pairs in a folder -> rmsd.txt
|
|
@@ -175,9 +177,13 @@ When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every
|
|
|
175
177
|
|
|
176
178
|
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`.
|
|
177
179
|
|
|
178
|
-
`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. Potential energy is the primary equilibrium signal
|
|
180
|
+
`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. Potential energy is the primary equilibrium signal. Detection requires a persistent change with a meaningful magnitude relative to the robust natural energy fluctuations, so ordinary correlated thermal noise and isolated spikes are not classified as abnormal. When multiple lattice or volume measures independently show a transition, a more sensitive noise-relative potential-energy test is enabled; this avoids a fixed eV or percentage-of-total-energy threshold that would depend on system size. Lattice parameters, volume, temperature, pressure, and angles remain supporting diagnostics. Thermo plots use Arial when available, opaque legends, correct Å units, and shaded abnormal regions on potential energy, lattice parameters, and volume. Potential energy and volume are divided by 1000 and labeled ×10³ only when their absolute plotted values reach 1000. The analysis uses the last `run` segment, or the last 80% when `run.in` has no `run` record; inspect the plot before making a final scientific judgment.
|
|
179
181
|
|
|
180
|
-
|
|
182
|
+
The thermo parser follows the [official GPUMD thermo.out format](https://gpumd.org/gpumd/output_files/thermo_out.html): 18 columns are required, `pressure.txt` exports `Pxx Pyy Pzz Pyz Pxz Pxy`, box vectors are interpreted as a full 3×3 matrix, `time_step` propagates between runs, and `dump_thermo` does not.
|
|
183
|
+
|
|
184
|
+
`gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Stable trajectories are merged in deterministic folder order into `train.xyz` (or `-o`). For an abnormal job, frames from the structurally corroborated potential-energy region are written to `abnormal.xyz` (or `-ao`); if no energy region can be resolved, the dominant structural transition is used as a fallback so an abnormal job cannot produce an empty abnormal output. Frame times are obtained from `time_step` and `dump_xyz` in that job's `run.in`. Missing, unreadable, or unmappable jobs are skipped. When `<job>/model.xyz` contains `Config_type`, that value is applied to every exported frame while all other extended-XYZ bytes remain unchanged.
|
|
185
|
+
|
|
186
|
+
`trj-sparse` keeps `max(1, floor(frame_count × ratio))` frames without rewriting their extended-XYZ content. Selection is deterministic and starts with frame zero; for 100 frames and `-r 0.5`, it writes source indices `0, 2, ..., 98`.
|
|
181
187
|
|
|
182
188
|
Each command's full flag list is in `pmk <command> -help`.
|
|
183
189
|
|
|
@@ -36,6 +36,7 @@ src/pymdkit/commands/substitute.py
|
|
|
36
36
|
src/pymdkit/commands/supercell.py
|
|
37
37
|
src/pymdkit/commands/symmetrize.py
|
|
38
38
|
src/pymdkit/commands/trj_extract.py
|
|
39
|
+
src/pymdkit/commands/trj_sparse.py
|
|
39
40
|
src/pymdkit/commands/umap.py
|
|
40
41
|
src/pymdkit/commands/vasp2xyz.py
|
|
41
42
|
src/pymdkit/commands/vasp_fe.py
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
"""Export, plot, and check GPUMD thermo data."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import numpy as np
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
from ._gpumd import load_thermo
|
|
12
|
-
except ImportError:
|
|
13
|
-
from _gpumd import load_thermo
|
|
14
|
-
|
|
15
|
-
COMMAND = "gpumd-thermo"
|
|
16
|
-
HELP = "Export and plot GPUMD thermo.out data and warn about instability."
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def save_table(path, time, values, header):
|
|
20
|
-
values = np.asarray(values)
|
|
21
|
-
if values.ndim == 1:
|
|
22
|
-
values = values[:, None]
|
|
23
|
-
np.savetxt(path, np.column_stack([time, values]), fmt="%.10g", header=header)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def plot_thermo(path, thermo):
|
|
27
|
-
import matplotlib
|
|
28
|
-
matplotlib.use("Agg")
|
|
29
|
-
import matplotlib.pyplot as plt
|
|
30
|
-
|
|
31
|
-
colors = ("#d22027", "#015699", "#fac00f")
|
|
32
|
-
fig, axes = plt.subplots(2, 3, figsize=(12, 6), dpi=100)
|
|
33
|
-
|
|
34
|
-
axes[0, 0].plot(thermo.time, thermo.temperature, color=colors[1])
|
|
35
|
-
axes[0, 0].set_ylabel("Temperature (K)")
|
|
36
|
-
axes[0, 1].plot(thermo.time, thermo.potential / 1000.0, color=colors[0])
|
|
37
|
-
axes[0, 1].set_ylabel("Potential energy (x10^3 eV)")
|
|
38
|
-
for index, label in enumerate(("Px", "Py", "Pz")):
|
|
39
|
-
axes[0, 2].plot(thermo.time, thermo.pressure[:, index],
|
|
40
|
-
label=label, color=colors[index])
|
|
41
|
-
axes[0, 2].set_ylabel("Pressure (GPa)")
|
|
42
|
-
axes[0, 2].legend(frameon=False)
|
|
43
|
-
|
|
44
|
-
for index, label in enumerate(("a", "b", "c")):
|
|
45
|
-
axes[1, 0].plot(thermo.time, thermo.lengths[:, index],
|
|
46
|
-
label=label, color=colors[index])
|
|
47
|
-
axes[1, 0].set_ylabel("Lattice parameters (A)")
|
|
48
|
-
axes[1, 0].legend(frameon=False)
|
|
49
|
-
axes[1, 1].plot(thermo.time, thermo.volume / 1000.0, color=colors[1])
|
|
50
|
-
axes[1, 1].set_ylabel("Volume (x10^3 A^3)")
|
|
51
|
-
for index, label in enumerate(("alpha", "beta", "gamma")):
|
|
52
|
-
axes[1, 2].plot(thermo.time, thermo.angles[:, index],
|
|
53
|
-
label=label, color=colors[index])
|
|
54
|
-
axes[1, 2].set_ylabel("Lattice angles (degree)")
|
|
55
|
-
axes[1, 2].legend(frameon=False)
|
|
56
|
-
|
|
57
|
-
for index, axis in enumerate(axes.flat):
|
|
58
|
-
axis.set_xlabel("Time (ps)")
|
|
59
|
-
axis.text(-0.14, 1.03, f"({'abcdef'[index]})",
|
|
60
|
-
transform=axis.transAxes, fontsize=12, va="top", ha="right")
|
|
61
|
-
fig.tight_layout()
|
|
62
|
-
fig.savefig(path, dpi=300)
|
|
63
|
-
plt.close(fig)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def process_job(folder, thermo_name, run_name):
|
|
67
|
-
thermo = load_thermo(folder / thermo_name, folder / run_name)
|
|
68
|
-
save_table(folder / "temperature.txt", thermo.time, thermo.temperature,
|
|
69
|
-
"time(ps) temperature(K)")
|
|
70
|
-
save_table(folder / "potential-energy.txt", thermo.time, thermo.potential,
|
|
71
|
-
"time(ps) potential_energy(eV)")
|
|
72
|
-
save_table(folder / "pressure.txt", thermo.time, thermo.pressure,
|
|
73
|
-
"time(ps) pressure_x(GPa) pressure_y(GPa) pressure_z(GPa)")
|
|
74
|
-
save_table(folder / "lattice-parameters.txt", thermo.time, thermo.lengths,
|
|
75
|
-
"time(ps) a(A) b(A) c(A)")
|
|
76
|
-
save_table(folder / "volume.txt", thermo.time, thermo.volume,
|
|
77
|
-
"time(ps) volume(A^3)")
|
|
78
|
-
save_table(folder / "lattice-angles.txt", thermo.time, thermo.angles,
|
|
79
|
-
"time(ps) alpha(deg) beta(deg) gamma(deg)")
|
|
80
|
-
plot_thermo(folder / "thermo.png", thermo)
|
|
81
|
-
|
|
82
|
-
print(f"{folder}: {len(thermo.time)} thermo record(s), {thermo.time[-1]:.6g} ps")
|
|
83
|
-
if thermo.warnings:
|
|
84
|
-
print("WARNING: possible abnormal fluctuation in the equilibrium segment:")
|
|
85
|
-
for warning in thermo.warnings:
|
|
86
|
-
print(f" {warning}")
|
|
87
|
-
else:
|
|
88
|
-
print("No sustained equilibrium drift or level shift detected.")
|
|
89
|
-
print("Wrote thermo text tables and thermo.png")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def discover_jobs(thermo_arg, run_arg):
|
|
93
|
-
thermo = Path(thermo_arg)
|
|
94
|
-
run_input = Path(run_arg)
|
|
95
|
-
if thermo.is_file() and run_input.is_file():
|
|
96
|
-
return [(thermo.parent.resolve(), thermo.name, run_input.name)]
|
|
97
|
-
|
|
98
|
-
root = Path(".").resolve()
|
|
99
|
-
jobs = sorted(
|
|
100
|
-
{path.parent.resolve() for path in root.rglob(thermo.name)
|
|
101
|
-
if (path.parent / run_input.name).is_file()},
|
|
102
|
-
key=lambda path: (len(path.relative_to(root).parts), str(path)),
|
|
103
|
-
)
|
|
104
|
-
return [(folder, thermo.name, run_input.name) for folder in jobs]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
108
|
-
parser.add_argument("-t", "-thermo", dest="thermo", default="thermo.out",
|
|
109
|
-
help="GPUMD thermo output (default: thermo.out).")
|
|
110
|
-
parser.add_argument("-in", dest="run_input", default="run.in",
|
|
111
|
-
help="GPUMD run input (default: run.in).")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def run(args) -> int:
|
|
115
|
-
jobs = discover_jobs(args.thermo, args.run_input)
|
|
116
|
-
if not jobs:
|
|
117
|
-
raise SystemExit("Error: no folder containing both thermo.out and run.in was found.")
|
|
118
|
-
failures = 0
|
|
119
|
-
for folder, thermo_name, run_name in jobs:
|
|
120
|
-
try:
|
|
121
|
-
process_job(folder, thermo_name, run_name)
|
|
122
|
-
except Exception as exc:
|
|
123
|
-
failures += 1
|
|
124
|
-
print(f"{folder}: error ({exc})")
|
|
125
|
-
return 1 if failures == len(jobs) else 0
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if __name__ == "__main__":
|
|
129
|
-
_parser = argparse.ArgumentParser(description=__doc__)
|
|
130
|
-
add_arguments(_parser)
|
|
131
|
-
raise SystemExit(run(_parser.parse_args()))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|