pymdkit 1.4.6__tar.gz → 1.4.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {pymdkit-1.4.6 → pymdkit-1.4.8}/PKG-INFO +35 -7
  2. {pymdkit-1.4.6 → pymdkit-1.4.8}/README.md +36 -8
  3. {pymdkit-1.4.6 → pymdkit-1.4.8}/pyproject.toml +1 -1
  4. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/__init__.py +1 -1
  5. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_gpumd.py +6 -7
  6. pymdkit-1.4.8/src/pymdkit/commands/gather_fs.py +196 -0
  7. pymdkit-1.4.8/src/pymdkit/commands/gpumd_relax.py +127 -0
  8. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/gpumd_trjcat.py +92 -46
  9. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit.egg-info/PKG-INFO +35 -7
  10. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit.egg-info/SOURCES.txt +2 -1
  11. pymdkit-1.4.6/src/pymdkit/commands/gather_contcar.py +0 -106
  12. {pymdkit-1.4.6 → pymdkit-1.4.8}/LICENSE +0 -0
  13. {pymdkit-1.4.6 → pymdkit-1.4.8}/setup.cfg +0 -0
  14. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/__init__.py +0 -0
  15. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_extxyz.py +0 -0
  16. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_fileio.py +0 -0
  17. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_geometry.py +0 -0
  18. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_reduction.py +0 -0
  19. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_vasp_output.py +0 -0
  20. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/_vaspset.py +0 -0
  21. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/add_config_type.py +0 -0
  22. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/chemsys_entry.py +0 -0
  23. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/compute_ehull.py +0 -0
  24. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/compute_msd_all_groups.py +0 -0
  25. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/compute_rmsd.py +0 -0
  26. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/convert.py +0 -0
  27. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/ewald.py +0 -0
  28. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/gpumd_group.py +0 -0
  29. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/gpumd_thermo.py +0 -0
  30. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/nep_rmse.py +0 -0
  31. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/pca.py +0 -0
  32. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/perturb.py +0 -0
  33. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/submit_vasp.py +0 -0
  34. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/substitute.py +0 -0
  35. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/supercell.py +0 -0
  36. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/symmetrize.py +0 -0
  37. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/trj_extract.py +0 -0
  38. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/trj_sparse.py +0 -0
  39. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/umap.py +0 -0
  40. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/vasp2xyz.py +0 -0
  41. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/vasp_fe.py +0 -0
  42. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/vasp_relax.py +0 -0
  43. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/commands/vasp_static.py +0 -0
  44. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit/pymdkit_main.py +0 -0
  45. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  46. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit.egg-info/entry_points.txt +0 -0
  47. {pymdkit-1.4.6 → pymdkit-1.4.8}/src/pymdkit.egg-info/requires.txt +0 -0
  48. {pymdkit-1.4.6 → pymdkit-1.4.8}/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.6
3
+ Version: 1.4.8
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
@@ -65,12 +65,13 @@ directory for job sub-folders automatically.
65
65
  |---|---|
66
66
  | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
67
67
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
68
+ | `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
68
69
  | `ewald` | Compute CIF electrostatic energy with pymatgen EwaldSummation |
69
70
  | `ehull` | Auto-detect VASP job folders and compute E_hull vs Materials Project |
70
71
  | `vasp-fe` | List VASP job final energies from lowest to highest |
71
- | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
72
+ | `gather-fs` | Recursively gather final structures from converged VASP or GPUMD jobs |
72
73
  | `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
73
- | `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
74
+ | `gpumd-trjcat` | Merge normal or all GPUMD trajectories and extract the first abnormal region |
74
75
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
75
76
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
76
77
  | `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
@@ -106,6 +107,8 @@ pmk supercell -if extxyz-opted -max-abc 24 -individual -temp 500 600 -md-if inpu
106
107
  # GPUMD: ./<name>/model.xyz (grouped) + ./<name>/<T>/ jobs
107
108
  pmk vasp-relax -i opted.vasp # relax inputs in current dir
108
109
  pmk vasp-relax -if optimal_occupancy # one ./<name>/ job folder per structure
110
+ pmk gpumd-relax -if example -nep nep89_20250409.txt # model.xyz + run.in + NEP per job folder
111
+ pmk gpumd-relax -it train.xyz -nep nep.txt -in run.in # one ./frame_N/ GPUMD job per frame
109
112
  pmk vasp-static -if cifs/ -custom-setting my_incar.txt # static inputs, custom INCAR
110
113
  pmk vasp-static -it traj.xyz # one ./frame_N/ job per trajectory frame
111
114
  pmk msd # scans <structure>/<temp>/ -> per-job msd/ + msd_summary.txt
@@ -114,8 +117,9 @@ pmk chemsys-entry -s Li La Ta Cl # MP stable entries -> Li-La-Ta
114
117
  pmk ehull -mp-api-key $MP_API_KEY # scans ./ for VASP jobs -> ehull.txt
115
118
  pmk ehull -local Li-La-Ta-Cl-stable-entries-opted
116
119
  pmk vasp-fe # scans ./ for VASP jobs -> final-energy.txt with convergence status
117
- pmk gather-contcar -of vasp-opted # CONTCARs -> vasp-opted/<folder>.vasp
118
- pmk gather-contcar -of vasp-opted -ehull 0.028 # only structures with E_hull < 0.028 eV/atom
120
+ pmk gather-fs -job vasp -fs-name CONTCAR -of vasp-opted
121
+ pmk gather-fs -job vasp -fs-name CONTCAR -of vasp-opted -ehull 0.028
122
+ pmk gather-fs -job gpumd -fs-name relaxed.xyz -of gpumd-opted
119
123
  pmk vasp2xyz # scans ./ for VASP output folders -> scf-converged.xyz
120
124
  pmk vasp2xyz -position-only # write positions only, without energy/forces/stress
121
125
  pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
@@ -137,6 +141,7 @@ pmk gpumd-thermo # current job, or recursively scan
137
141
  pmk gpumd-thermo -t thermo.out -in run.in
138
142
  pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
139
143
  pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
144
+ pmk gpumd-trjcat -all # merge every readable trajectory into train.xyz
140
145
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
141
146
  pmk trj-sparse -it example.xyz -r 0.5 -o sparse-example.xyz
142
147
 
@@ -168,6 +173,29 @@ multi-structure trajectory and writes one job sub-folder per frame
168
173
  (`frame_1/`, `frame_2/`, ..., prefix configurable via `-frame-prefix`). Each
169
174
  folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
170
175
  `vasp2xyz`.
176
+ `gpumd-relax` accepts the same `-i`, `-if`, and `-it` structure modes. `-nep`
177
+ is required and the selected potential is copied into every job folder together
178
+ with `model.xyz` and `run.in`. `-in FILE` supplies a custom GPUMD input; its
179
+ first `potential` filename is synchronized to the copied NEP. Without `-in`,
180
+ the generated job-local input is:
181
+
182
+ ```text
183
+ potential <NEP filename>
184
+
185
+ minimize fire 2.0e-2 1000000
186
+
187
+ ensemble nve
188
+ dump_xyz -1 0 1 relaxed.xyz force
189
+ run 1
190
+ ```
191
+
192
+ `gather-fs` recursively scans all job folders below the current directory. For
193
+ VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
194
+ requires full convergence before copying the selected `-fs-name` as `.vasp`.
195
+ For GPUMD it requires `gpumd.out` to report a force tolerance and a final
196
+ `f_max` no greater than that tolerance before copying the final structure.
197
+ `-fs-name` defaults to `CONTCAR` for VASP and `relaxed.xyz` for GPUMD. The
198
+ `-ehull` and `-ehull-file` filters are available only with `-job vasp`.
171
199
 
172
200
  `add-config-type` updates its input atomically in place. With `-i`, the file must contain exactly one XYZ structure; use `-it` for a trajectory. Existing `Config_type` values are replaced, missing values are added, and all other extended-XYZ bytes remain unchanged.
173
201
 
@@ -177,11 +205,11 @@ When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every
177
205
 
178
206
  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`.
179
207
 
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. For an abnormal run it also writes `abnormal-time.txt`, with one `BEGIN ps to END ps` interval per line; a normal rerun removes a stale file. 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 at least two independent lattice-parameter or volume measures show the same mapped 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 and volume can corroborate an energy transition. Temperature and all six pressure components remain diagnostics only and cannot classify an otherwise stationary run as abnormal by themselves. A lone cell-axis drift, an isolated spike, or an empty anomaly mask never triggers a whole-run fallback. Thermo plots use Arial when available and Matplotlib's built-in qualitative `Dark2` color cycle. They plot all six pressure components, use frameless upper-right legends with added vertical headroom, preserve correct Å units, and show abnormal regions as shaded legend entries 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.
208
+ `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. For an abnormal run it also writes `abnormal-time.txt`, with one `BEGIN ps to END ps` interval per line; a normal rerun removes a stale file. 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. An abnormal classification always requires a mapped potential-energy change. When at least two lattice-parameter or volume signals show the same mapped transition, they may enable a more sensitive noise-relative energy test; this avoids a fixed eV or percentage-of-total-energy threshold that would depend on system size. Cell signals can corroborate and refine the energy interval, but can never classify a run by themselves. Temperature and all six pressure components remain diagnostics only and cannot classify an otherwise stationary run as abnormal by themselves. A lone cell-axis drift, an isolated spike, or an empty anomaly mask never triggers a whole-run fallback. Thermo plots use Arial when available and Matplotlib's built-in qualitative `Dark2` color cycle. They plot all six pressure components, use frameless upper-right legends with added vertical headroom, preserve correct Å units, and show abnormal regions as shaded legend entries 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.
181
209
 
182
210
  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
211
 
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.
212
+ `gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. By default, normal trajectories are merged in deterministic folder order into `train.xyz` (or `-o`), while only frames from the earliest mapped abnormal interval of each abnormal job are written to `abnormal.xyz` (or `-ao`). With `-all`, every readable full trajectory is merged into `train.xyz` regardless of normal, abnormal, missing-thermo, or failed-thermo status; an analyzable abnormal job still contributes its earliest abnormal interval to `abnormal.xyz`. If no mapped abnormal frames are found, `abnormal.xyz` is not created and a stale file at the selected `-ao` path is removed. Frame times are obtained from `time_step` and `dump_xyz` in that job's `run.in`. In default mode, 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
213
 
186
214
  `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`.
187
215
 
@@ -39,12 +39,13 @@ directory for job sub-folders automatically.
39
39
  |---|---|
40
40
  | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
41
41
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
42
+ | `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
42
43
  | `ewald` | Compute CIF electrostatic energy with pymatgen EwaldSummation |
43
44
  | `ehull` | Auto-detect VASP job folders and compute E_hull vs Materials Project |
44
45
  | `vasp-fe` | List VASP job final energies from lowest to highest |
45
- | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
46
+ | `gather-fs` | Recursively gather final structures from converged VASP or GPUMD jobs |
46
47
  | `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
47
- | `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
48
+ | `gpumd-trjcat` | Merge normal or all GPUMD trajectories and extract the first abnormal region |
48
49
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
49
50
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
50
51
  | `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
@@ -80,6 +81,8 @@ pmk supercell -if extxyz-opted -max-abc 24 -individual -temp 500 600 -md-if inpu
80
81
  # GPUMD: ./<name>/model.xyz (grouped) + ./<name>/<T>/ jobs
81
82
  pmk vasp-relax -i opted.vasp # relax inputs in current dir
82
83
  pmk vasp-relax -if optimal_occupancy # one ./<name>/ job folder per structure
84
+ pmk gpumd-relax -if example -nep nep89_20250409.txt # model.xyz + run.in + NEP per job folder
85
+ pmk gpumd-relax -it train.xyz -nep nep.txt -in run.in # one ./frame_N/ GPUMD job per frame
83
86
  pmk vasp-static -if cifs/ -custom-setting my_incar.txt # static inputs, custom INCAR
84
87
  pmk vasp-static -it traj.xyz # one ./frame_N/ job per trajectory frame
85
88
  pmk msd # scans <structure>/<temp>/ -> per-job msd/ + msd_summary.txt
@@ -88,8 +91,9 @@ pmk chemsys-entry -s Li La Ta Cl # MP stable entries -> Li-La-Ta
88
91
  pmk ehull -mp-api-key $MP_API_KEY # scans ./ for VASP jobs -> ehull.txt
89
92
  pmk ehull -local Li-La-Ta-Cl-stable-entries-opted
90
93
  pmk vasp-fe # scans ./ for VASP jobs -> final-energy.txt with convergence status
91
- pmk gather-contcar -of vasp-opted # CONTCARs -> vasp-opted/<folder>.vasp
92
- pmk gather-contcar -of vasp-opted -ehull 0.028 # only structures with E_hull < 0.028 eV/atom
94
+ pmk gather-fs -job vasp -fs-name CONTCAR -of vasp-opted
95
+ pmk gather-fs -job vasp -fs-name CONTCAR -of vasp-opted -ehull 0.028
96
+ pmk gather-fs -job gpumd -fs-name relaxed.xyz -of gpumd-opted
93
97
  pmk vasp2xyz # scans ./ for VASP output folders -> scf-converged.xyz
94
98
  pmk vasp2xyz -position-only # write positions only, without energy/forces/stress
95
99
  pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
@@ -110,7 +114,8 @@ pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
110
114
  pmk gpumd-thermo # current job, or recursively scan job folders
111
115
  pmk gpumd-thermo -t thermo.out -in run.in
112
116
  pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
113
- pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
117
+ pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
118
+ pmk gpumd-trjcat -all # merge every readable trajectory into train.xyz
114
119
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
115
120
  pmk trj-sparse -it example.xyz -r 0.5 -o sparse-example.xyz
116
121
 
@@ -141,7 +146,30 @@ custom_settings = {
141
146
  multi-structure trajectory and writes one job sub-folder per frame
142
147
  (`frame_1/`, `frame_2/`, ..., prefix configurable via `-frame-prefix`). Each
143
148
  folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
144
- `vasp2xyz`.
149
+ `vasp2xyz`.
150
+ `gpumd-relax` accepts the same `-i`, `-if`, and `-it` structure modes. `-nep`
151
+ is required and the selected potential is copied into every job folder together
152
+ with `model.xyz` and `run.in`. `-in FILE` supplies a custom GPUMD input; its
153
+ first `potential` filename is synchronized to the copied NEP. Without `-in`,
154
+ the generated job-local input is:
155
+
156
+ ```text
157
+ potential <NEP filename>
158
+
159
+ minimize fire 2.0e-2 1000000
160
+
161
+ ensemble nve
162
+ dump_xyz -1 0 1 relaxed.xyz force
163
+ run 1
164
+ ```
165
+
166
+ `gather-fs` recursively scans all job folders below the current directory. For
167
+ VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
168
+ requires full convergence before copying the selected `-fs-name` as `.vasp`.
169
+ For GPUMD it requires `gpumd.out` to report a force tolerance and a final
170
+ `f_max` no greater than that tolerance before copying the final structure.
171
+ `-fs-name` defaults to `CONTCAR` for VASP and `relaxed.xyz` for GPUMD. The
172
+ `-ehull` and `-ehull-file` filters are available only with `-job vasp`.
145
173
 
146
174
  `add-config-type` updates its input atomically in place. With `-i`, the file must contain exactly one XYZ structure; use `-it` for a trajectory. Existing `Config_type` values are replaced, missing values are added, and all other extended-XYZ bytes remain unchanged.
147
175
 
@@ -151,11 +179,11 @@ When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every
151
179
 
152
180
  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`.
153
181
 
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. For an abnormal run it also writes `abnormal-time.txt`, with one `BEGIN ps to END ps` interval per line; a normal rerun removes a stale file. 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 at least two independent lattice-parameter or volume measures show the same mapped 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 and volume can corroborate an energy transition. Temperature and all six pressure components remain diagnostics only and cannot classify an otherwise stationary run as abnormal by themselves. A lone cell-axis drift, an isolated spike, or an empty anomaly mask never triggers a whole-run fallback. Thermo plots use Arial when available and Matplotlib's built-in qualitative `Dark2` color cycle. They plot all six pressure components, use frameless upper-right legends with added vertical headroom, preserve correct Å units, and show abnormal regions as shaded legend entries 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.
182
+ `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. For an abnormal run it also writes `abnormal-time.txt`, with one `BEGIN ps to END ps` interval per line; a normal rerun removes a stale file. 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. An abnormal classification always requires a mapped potential-energy change. When at least two lattice-parameter or volume signals show the same mapped transition, they may enable a more sensitive noise-relative energy test; this avoids a fixed eV or percentage-of-total-energy threshold that would depend on system size. Cell signals can corroborate and refine the energy interval, but can never classify a run by themselves. Temperature and all six pressure components remain diagnostics only and cannot classify an otherwise stationary run as abnormal by themselves. A lone cell-axis drift, an isolated spike, or an empty anomaly mask never triggers a whole-run fallback. Thermo plots use Arial when available and Matplotlib's built-in qualitative `Dark2` color cycle. They plot all six pressure components, use frameless upper-right legends with added vertical headroom, preserve correct Å units, and show abnormal regions as shaded legend entries 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.
155
183
 
156
184
  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
185
 
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.
186
+ `gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. By default, normal trajectories are merged in deterministic folder order into `train.xyz` (or `-o`), while only frames from the earliest mapped abnormal interval of each abnormal job are written to `abnormal.xyz` (or `-ao`). With `-all`, every readable full trajectory is merged into `train.xyz` regardless of normal, abnormal, missing-thermo, or failed-thermo status; an analyzable abnormal job still contributes its earliest abnormal interval to `abnormal.xyz`. If no mapped abnormal frames are found, `abnormal.xyz` is not created and a stale file at the selected `-ao` path is removed. Frame times are obtained from `time_step` and `dump_xyz` in that job's `run.in`. In default mode, 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.
159
187
 
160
188
  `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
189
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.4.6"
7
+ version = "1.4.8"
8
8
  description = "A unified command-line toolkit for atomistic / MD structure workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """pymdkit -- a unified CLI for atomistic / MD structure workflows."""
2
2
 
3
- __version__ = "1.4.6"
3
+ __version__ = "1.4.8"
@@ -389,8 +389,8 @@ def load_thermo(thermo_path, run_path):
389
389
  segment_mask = potential_abnormal_mask(potential[start:])
390
390
  warning_prefix = "potential-energy: localized"
391
391
 
392
- # Structural evidence can lower the energy threshold only when at least
393
- # two independent cell measures agree on a mapped transition.
392
+ # Potential energy is mandatory evidence. Coherent cell changes may lower
393
+ # its threshold and refine the interval, but cannot classify a run alone.
394
394
  if not segment_mask.any() and len(cell_warnings) >= 2:
395
395
  structural_mask = structural_abnormal_mask(
396
396
  lengths[start:], volume[start:]
@@ -401,13 +401,12 @@ def load_thermo(thermo_path, run_path):
401
401
  )
402
402
  overlap = relaxed_energy & structural_mask
403
403
  if overlap.any():
404
- segment_mask = _keep_major_regions(
404
+ corroborated = _keep_major_regions(
405
405
  _bridge_mask(overlap, max(1, segment_length // 20))
406
406
  )
407
- warning_prefix = "potential-energy: structurally corroborated"
408
- else:
409
- segment_mask = structural_mask
410
- warning_prefix = "structure: coherently mapped"
407
+ if corroborated.any():
408
+ segment_mask = corroborated
409
+ warning_prefix = "potential-energy: structurally corroborated"
411
410
 
412
411
  abnormal_mask = np.zeros(len(data), dtype=bool)
413
412
  abnormal_mask[start:] = segment_mask
@@ -0,0 +1,196 @@
1
+ """Gather converged final structures from recursive VASP or GPUMD jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import shutil
8
+ from collections import Counter
9
+ from pathlib import Path
10
+
11
+ try:
12
+ from . import _fileio, _vasp_output
13
+ except ImportError:
14
+ import _fileio
15
+ import _vasp_output
16
+
17
+ COMMAND = "gather-fs"
18
+ HELP = "Gather final structures from converged VASP or GPUMD jobs."
19
+
20
+ _NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?"
21
+ _TOLERANCE_RE = re.compile(
22
+ rf"force\s+tolerance\s+of\s+({_NUMBER})\s*eV\s*/\s*(?:A|Angstrom)",
23
+ re.IGNORECASE,
24
+ )
25
+ _FMAX_RE = re.compile(
26
+ rf"f_max\s*=\s*({_NUMBER})\s*eV\s*/\s*(?:A|Angstrom)",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+
31
+ def parse_ehull_file(path):
32
+ result = {}
33
+ for line in Path(path).read_text(encoding="utf-8").splitlines():
34
+ parts = line.split()
35
+ if len(parts) < 2:
36
+ continue
37
+ try:
38
+ result[parts[0]] = float(parts[-1])
39
+ except ValueError:
40
+ continue
41
+ return result
42
+
43
+
44
+ def gpumd_convergence(path):
45
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
46
+ tolerances = [float(value) for value in _TOLERANCE_RE.findall(text)]
47
+ maxima = [float(value) for value in _FMAX_RE.findall(text)]
48
+ if not tolerances or not maxima:
49
+ return False, "force tolerance or final f_max not found"
50
+ tolerance = tolerances[-1]
51
+ f_max = maxima[-1]
52
+ converged = f_max <= tolerance
53
+ detail = f"f_max={f_max:.10g} eV/A, tolerance={tolerance:.10g} eV/A"
54
+ return converged, detail
55
+
56
+
57
+ def vasp_convergence(folder):
58
+ output = _vasp_output.read_vasp_output(folder, require=())
59
+ if output is None:
60
+ return False, "no readable vaspout.h5, vasprun.xml, or OUTCAR"
61
+ return output.converged, f"source={output.source}"
62
+
63
+
64
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
65
+ parser.add_argument("-job", required=True, choices=("vasp", "gpumd"),
66
+ help="Job type used for convergence checking.")
67
+ parser.add_argument("-fs-name", dest="final_structure_name",
68
+ help="Final-structure filename (defaults: CONTCAR or relaxed.xyz).")
69
+ parser.add_argument("-of", "-output-folder", dest="output_folder",
70
+ required=True, metavar="DIR",
71
+ help="Folder receiving the gathered final structures.")
72
+ parser.add_argument("-ehull", type=float, default=None, metavar="EV",
73
+ help="VASP only: require E_hull below this eV/atom value.")
74
+ parser.add_argument("-ehull-file", default="ehull.txt",
75
+ help="VASP E_hull report used with -ehull (default: ehull.txt).")
76
+
77
+
78
+ def discover_final_structures(root, filename, output_folder):
79
+ root = Path(root).resolve()
80
+ output_folder = Path(output_folder).resolve()
81
+ paths = []
82
+ for path in root.rglob(filename):
83
+ if not path.is_file() or path.parent.resolve() == root:
84
+ continue
85
+ resolved = path.resolve()
86
+ if resolved == output_folder or output_folder in resolved.parents:
87
+ continue
88
+ paths.append(path)
89
+ return sorted(
90
+ paths,
91
+ key=lambda path: _fileio.folder_sort_key(
92
+ str(path.parent.relative_to(root))
93
+ ),
94
+ )
95
+
96
+
97
+ def ehull_value(ehull_map, folder, root):
98
+ relative = str(folder.relative_to(root)).replace("\\", "/")
99
+ for key in (relative, folder.name):
100
+ if key in ehull_map:
101
+ return ehull_map[key]
102
+ return None
103
+
104
+
105
+ def destination_name(path, root, duplicate_names, job):
106
+ relative_folder = path.parent.relative_to(root)
107
+ if duplicate_names[path.parent.name] > 1:
108
+ stem = "__".join(relative_folder.parts)
109
+ else:
110
+ stem = path.parent.name
111
+ suffix = ".vasp" if job == "vasp" else (path.suffix or ".xyz")
112
+ return stem + suffix
113
+
114
+
115
+ def run(args) -> int:
116
+ if args.job == "gpumd" and args.ehull is not None:
117
+ raise SystemExit("Error: -ehull is only available with -job vasp.")
118
+
119
+ final_name = args.final_structure_name or (
120
+ "CONTCAR" if args.job == "vasp" else "relaxed.xyz"
121
+ )
122
+ if Path(final_name).name != final_name:
123
+ raise SystemExit("Error: -fs-name must be a filename, not a path.")
124
+
125
+ root = Path(".").resolve()
126
+ output_folder = Path(args.output_folder).resolve()
127
+ candidates = discover_final_structures(root, final_name, output_folder)
128
+ duplicate_names = Counter(path.parent.name for path in candidates)
129
+
130
+ ehull_map = None
131
+ if args.ehull is not None:
132
+ report = Path(args.ehull_file)
133
+ if not report.is_file():
134
+ raise SystemExit(f"Error: {report} not found (needed for -ehull).")
135
+ ehull_map = parse_ehull_file(report)
136
+
137
+ output_folder.mkdir(parents=True, exist_ok=True)
138
+ gathered = 0
139
+ unconverged = 0
140
+ filtered = 0
141
+ missing_ehull = 0
142
+ for final_structure in candidates:
143
+ folder = final_structure.parent.resolve()
144
+ if args.job == "vasp":
145
+ converged, detail = vasp_convergence(folder)
146
+ else:
147
+ gpumd_output = folder / "gpumd.out"
148
+ if not gpumd_output.is_file():
149
+ converged, detail = False, "gpumd.out not found"
150
+ else:
151
+ converged, detail = gpumd_convergence(gpumd_output)
152
+
153
+ relative = folder.relative_to(root)
154
+ if not converged:
155
+ unconverged += 1
156
+ print(f"{relative}: skipped - not converged ({detail})")
157
+ continue
158
+
159
+ tag = ""
160
+ if ehull_map is not None:
161
+ value = ehull_value(ehull_map, folder, root)
162
+ if value is None:
163
+ missing_ehull += 1
164
+ print(f"{relative}: skipped - no E_hull entry")
165
+ continue
166
+ if not value < args.ehull:
167
+ filtered += 1
168
+ print(f"{relative}: skipped - E_hull={value:.6g} eV/atom")
169
+ continue
170
+ tag = f" (E_hull={value:.6g} eV/atom)"
171
+
172
+ destination = output_folder / destination_name(
173
+ final_structure, root, duplicate_names, args.job
174
+ )
175
+ shutil.copy2(final_structure, destination)
176
+ print(f"{final_structure} -> {destination}{tag}")
177
+ gathered += 1
178
+
179
+ print(
180
+ f"Done: gathered {gathered} converged {args.job.upper()} final "
181
+ f"structure(s) into {output_folder}"
182
+ )
183
+ if unconverged:
184
+ print(f"Skipped {unconverged} unconverged or unreadable job(s).")
185
+ if missing_ehull or filtered:
186
+ print(
187
+ f"E_hull filtering skipped {missing_ehull} missing and "
188
+ f"{filtered} above-threshold job(s)."
189
+ )
190
+ return 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ _parser = argparse.ArgumentParser(description=__doc__)
195
+ add_arguments(_parser)
196
+ raise SystemExit(run(_parser.parse_args()))
@@ -0,0 +1,127 @@
1
+ """Write GPUMD energy-minimization jobs from structures or trajectories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ try:
11
+ from . import _fileio, convert
12
+ except ImportError:
13
+ import _fileio
14
+ import convert
15
+
16
+ COMMAND = "gpumd-relax"
17
+ HELP = "Write GPUMD energy-minimization jobs for a file, folder, or trajectory."
18
+
19
+ DEFAULT_RUN_INPUT = """potential {nep_name}
20
+
21
+ minimize fire 2.0e-2 1000000
22
+
23
+ ensemble nve
24
+ dump_xyz -1 0 1 relaxed.xyz force
25
+ run 1
26
+ """
27
+
28
+
29
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
30
+ parser.add_argument("-i", "-input", dest="input",
31
+ help="Single input structure file.")
32
+ parser.add_argument("-o", "-output", dest="output", default=".",
33
+ help="Output job folder for -i (default: current directory).")
34
+ parser.add_argument("-if", "-input-folder", dest="input_folder",
35
+ help="Folder of structures; one ./<name>/ job per file.")
36
+ parser.add_argument("-it", "-input-trajectory", dest="input_trajectory",
37
+ help="Trajectory; one ./frame_N/ job per frame.")
38
+ parser.add_argument("-frame-prefix", dest="frame_prefix", default="frame",
39
+ help="Trajectory job-folder prefix (default: frame).")
40
+ parser.add_argument("-in", dest="run_input",
41
+ help="Custom GPUMD run input (default: built-in relaxation run.in).")
42
+ parser.add_argument("-nep", required=True,
43
+ help="NEP potential file copied into every job folder.")
44
+
45
+
46
+ def prepare_run_input(path, nep_name):
47
+ if path is None:
48
+ return DEFAULT_RUN_INPUT.format(nep_name=nep_name)
49
+
50
+ source = Path(path)
51
+ if not source.is_file():
52
+ raise SystemExit(f"Error: run input not found: {source}")
53
+ text = source.read_text(encoding="utf-8")
54
+ pattern = re.compile(r"^(\s*potential\s+)(\S+)(.*)$", re.MULTILINE)
55
+ if pattern.search(text):
56
+ text = pattern.sub(
57
+ lambda match: match.group(1) + nep_name + match.group(3),
58
+ text,
59
+ count=1,
60
+ )
61
+ else:
62
+ text = f"potential {nep_name}\n\n{text}"
63
+ return text.rstrip() + "\n"
64
+
65
+
66
+ def write_job(atoms, folder, nep_source, run_text):
67
+ folder = Path(folder)
68
+ folder.mkdir(parents=True, exist_ok=True)
69
+ convert._write(folder / "model.xyz", atoms)
70
+
71
+ nep_destination = folder / nep_source.name
72
+ if nep_source.resolve() != nep_destination.resolve():
73
+ shutil.copy2(nep_source, nep_destination)
74
+ with (folder / "run.in").open("w", encoding="utf-8", newline="\n") as handle:
75
+ handle.write(run_text)
76
+
77
+
78
+ def run(args) -> int:
79
+ modes = [bool(args.input), bool(args.input_folder), bool(args.input_trajectory)]
80
+ if sum(modes) != 1:
81
+ raise SystemExit("Error: provide exactly one of -i, -if, or -it.")
82
+
83
+ nep_source = Path(args.nep)
84
+ if not nep_source.is_file():
85
+ raise SystemExit(f"Error: NEP potential not found: {nep_source}")
86
+ run_text = prepare_run_input(args.run_input, nep_source.name)
87
+
88
+ jobs = []
89
+ if args.input:
90
+ source = Path(args.input)
91
+ if not source.is_file():
92
+ raise SystemExit(f"Error: input structure not found: {source}")
93
+ jobs.append((convert._read(source), Path(args.output), str(source)))
94
+ elif args.input_folder:
95
+ for source in _fileio.structure_files(args.input_folder):
96
+ jobs.append((convert._read(source), Path(source.stem), str(source)))
97
+ else:
98
+ trajectory = Path(args.input_trajectory)
99
+ if not trajectory.is_file():
100
+ raise SystemExit(f"Error: input trajectory not found: {trajectory}")
101
+ frames = convert._read(trajectory, all_frames=True)
102
+ if not isinstance(frames, list):
103
+ frames = [frames]
104
+ jobs.extend(
105
+ (atoms, Path(f"{args.frame_prefix}_{index}"),
106
+ f"{trajectory}[{index}]")
107
+ for index, atoms in enumerate(frames, start=1)
108
+ )
109
+
110
+ written = 0
111
+ for atoms, folder, label in jobs:
112
+ try:
113
+ write_job(atoms, folder, nep_source, run_text)
114
+ except Exception as exc:
115
+ print(f"{label}: cannot prepare job ({exc}) -> skipping")
116
+ continue
117
+ print(f"{label} -> {folder}/")
118
+ written += 1
119
+
120
+ print(f"Done: wrote {written} GPUMD relaxation job folder(s).")
121
+ return 0 if written else 1
122
+
123
+
124
+ if __name__ == "__main__":
125
+ _parser = argparse.ArgumentParser(description=__doc__)
126
+ add_arguments(_parser)
127
+ raise SystemExit(run(_parser.parse_args()))
@@ -1,4 +1,4 @@
1
- """Merge stable GPUMD trajectories and extract abnormal trajectory regions."""
1
+ """Merge GPUMD trajectories and extract each job's first abnormal region."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -16,7 +16,7 @@ except ImportError:
16
16
  from _gpumd import load_thermo, mask_intervals, trajectory_interval
17
17
 
18
18
  COMMAND = "gpumd-trjcat"
19
- HELP = "Merge stable GPUMD trajectories and extract abnormal regions."
19
+ HELP = "Merge normal or all GPUMD trajectories; extract first abnormal regions."
20
20
 
21
21
 
22
22
  @dataclass(frozen=True)
@@ -44,6 +44,11 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
44
44
  help="Structure filename supplying Config_type.")
45
45
 
46
46
 
47
+ parser.add_argument(
48
+ "-all", dest="all", action="store_true",
49
+ help="Merge every readable trajectory, regardless of thermo status.",
50
+ )
51
+
47
52
  def find_trajectories(root, input_name, outputs):
48
53
  root = Path(root).resolve()
49
54
  excluded = {Path(path).resolve() for path in outputs}
@@ -60,18 +65,6 @@ def find_trajectories(root, input_name, outputs):
60
65
 
61
66
  def inspect_job(trajectory, args):
62
67
  folder = trajectory.parent
63
- thermo_path = folder / Path(args.thermo).name
64
- run_path = folder / Path(args.run_input).name
65
- missing = [path.name for path in (thermo_path, run_path) if not path.is_file()]
66
- if missing:
67
- return None, None, f"missing {', '.join(missing)}"
68
-
69
- try:
70
- thermo = load_thermo(thermo_path, run_path)
71
- _, _, interval_ps = trajectory_interval(run_path, trajectory)
72
- except Exception as exc:
73
- return None, None, f"thermo check failed ({exc})"
74
-
75
68
  try:
76
69
  blocks, _ = read_blocks(trajectory)
77
70
  except Exception as exc:
@@ -88,21 +81,31 @@ def inspect_job(trajectory, args):
88
81
  print(f"{folder}: warning: could not read Config_type ({exc})")
89
82
 
90
83
  job = TrajectoryJob(trajectory, len(blocks), config_type)
91
- return job, (thermo, interval_ps), None
84
+ thermo_path = folder / Path(args.thermo).name
85
+ run_path = folder / Path(args.run_input).name
86
+ missing = [path.name for path in (thermo_path, run_path) if not path.is_file()]
87
+ if missing:
88
+ return job, None, f"missing {', '.join(missing)}"
92
89
 
90
+ try:
91
+ thermo = load_thermo(thermo_path, run_path)
92
+ _, _, interval_ps = trajectory_interval(run_path, trajectory)
93
+ except Exception as exc:
94
+ return job, None, f"thermo check failed ({exc})"
95
+ return job, (thermo, interval_ps), None
93
96
 
94
97
  def abnormal_frame_indices(thermo, frame_count, interval_ps):
95
98
  intervals = mask_intervals(thermo.time, thermo.abnormal_mask)
99
+ if not intervals:
100
+ return ()
101
+
102
+ begin, end = intervals[0]
96
103
  tolerance = max(1e-12, interval_ps * 1e-9)
97
104
  return tuple(
98
105
  index for index in range(frame_count)
99
- if any(
100
- begin - tolerance <= index * interval_ps <= end + tolerance
101
- for begin, end in intervals
102
- )
106
+ if begin - tolerance <= index * interval_ps <= end + tolerance
103
107
  )
104
108
 
105
-
106
109
  def write_merged(output, jobs):
107
110
  output = Path(output)
108
111
  output.parent.mkdir(parents=True, exist_ok=True)
@@ -138,60 +141,103 @@ def run(args) -> int:
138
141
  f"Error: no {Path(args.input).name} files found in subfolders of {root}."
139
142
  )
140
143
 
141
- stable_jobs = []
144
+ merged_jobs = []
142
145
  abnormal_jobs = []
143
146
  skipped = 0
144
147
  print(f"Found {len(trajectories)} trajectory file(s).")
145
148
  for trajectory in trajectories:
146
149
  job, analysis, reason = inspect_job(trajectory, args)
147
150
  relative_folder = trajectory.parent.relative_to(root)
148
- if reason:
151
+ if job is None:
149
152
  skipped += 1
150
153
  print(f"{relative_folder}: skipped - {reason}")
151
154
  continue
152
155
 
153
- thermo, interval_ps = analysis
154
156
  config = (
155
157
  f", Config_type={job.config_type}" if job.config_type is not None else ""
156
158
  )
159
+ if analysis is None:
160
+ if args.all:
161
+ merged_jobs.append(job)
162
+ print(
163
+ f"{relative_folder}: included by -all - {job.frame_count} "
164
+ f"frame(s){config}; thermo not checked ({reason})"
165
+ )
166
+ else:
167
+ skipped += 1
168
+ print(f"{relative_folder}: skipped - {reason}")
169
+ continue
170
+
171
+ thermo, interval_ps = analysis
157
172
  if not thermo.warnings:
158
- stable_jobs.append(job)
159
- print(f"{relative_folder}: stable - {job.frame_count} frame(s){config}")
173
+ merged_jobs.append(job)
174
+ status = "included by -all (normal)" if args.all else "stable"
175
+ print(
176
+ f"{relative_folder}: {status} - {job.frame_count} frame(s){config}"
177
+ )
160
178
  continue
161
179
 
180
+ if args.all:
181
+ merged_jobs.append(job)
162
182
  indices = abnormal_frame_indices(thermo, job.frame_count, interval_ps)
163
183
  details = "; ".join(thermo.warnings)
164
184
  if not indices:
165
- skipped += 1
166
- print(
167
- f"{relative_folder}: abnormal but no potential-energy region "
168
- f"mapped to trajectory frames - {details}"
169
- )
185
+ if args.all:
186
+ print(
187
+ f"{relative_folder}: included by -all - {job.frame_count} "
188
+ f"frame(s){config}; abnormal interval could not be mapped - {details}"
189
+ )
190
+ else:
191
+ skipped += 1
192
+ print(
193
+ f"{relative_folder}: abnormal but no potential-energy region "
194
+ f"mapped to trajectory frames - {details}"
195
+ )
170
196
  continue
197
+
171
198
  abnormal_jobs.append(TrajectoryJob(
172
199
  job.trajectory, job.frame_count, job.config_type, indices
173
200
  ))
174
- print(
175
- f"{relative_folder}: abnormal - extracted {len(indices)} of "
176
- f"{job.frame_count} frame(s){config} - {details}"
177
- )
201
+ if args.all:
202
+ print(
203
+ f"{relative_folder}: included by -all (abnormal) - all "
204
+ f"{job.frame_count} frame(s){config}; extracted {len(indices)} "
205
+ f"frame(s) from the first abnormal region - {details}"
206
+ )
207
+ else:
208
+ print(
209
+ f"{relative_folder}: abnormal - extracted {len(indices)} of "
210
+ f"{job.frame_count} frame(s) from the first abnormal region"
211
+ f"{config} - {details}"
212
+ )
178
213
 
179
- write_merged(output, stable_jobs)
180
- write_merged(abnormal_output, abnormal_jobs)
181
- stable_frames = sum(job.frame_count for job in stable_jobs)
214
+ write_merged(output, merged_jobs)
215
+ if abnormal_jobs:
216
+ write_merged(abnormal_output, abnormal_jobs)
217
+ else:
218
+ abnormal_output.unlink(missing_ok=True)
219
+ merged_frames = sum(job.frame_count for job in merged_jobs)
182
220
  abnormal_frames = sum(len(job.indices) for job in abnormal_jobs)
183
- print(
184
- f"Wrote {stable_frames} frame(s) from {len(stable_jobs)} stable job(s) "
185
- f"to {args.output}"
186
- )
187
- print(
188
- f"Wrote {abnormal_frames} frame(s) from {len(abnormal_jobs)} abnormal "
189
- f"job(s) to {args.abnormal_output}"
190
- )
221
+ if args.all:
222
+ print(
223
+ f"Wrote {merged_frames} frame(s) from {len(merged_jobs)} readable "
224
+ f"job(s) to {args.output}"
225
+ )
226
+ else:
227
+ print(
228
+ f"Wrote {merged_frames} frame(s) from {len(merged_jobs)} stable "
229
+ f"job(s) to {args.output}"
230
+ )
231
+ if abnormal_jobs:
232
+ print(
233
+ f"Wrote {abnormal_frames} frame(s) from {len(abnormal_jobs)} "
234
+ f"abnormal job(s) to {args.abnormal_output}"
235
+ )
236
+ else:
237
+ print(f"No abnormal frames found; did not write {args.abnormal_output}")
191
238
  print(f"Skipped {skipped} unreadable or unmappable job(s).")
192
239
  return 0
193
240
 
194
-
195
241
  if __name__ == "__main__":
196
242
  _parser = argparse.ArgumentParser(description=__doc__)
197
243
  add_arguments(_parser)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.4.6
3
+ Version: 1.4.8
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
@@ -65,12 +65,13 @@ directory for job sub-folders automatically.
65
65
  |---|---|
66
66
  | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
67
67
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
68
+ | `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
68
69
  | `ewald` | Compute CIF electrostatic energy with pymatgen EwaldSummation |
69
70
  | `ehull` | Auto-detect VASP job folders and compute E_hull vs Materials Project |
70
71
  | `vasp-fe` | List VASP job final energies from lowest to highest |
71
- | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
72
+ | `gather-fs` | Recursively gather final structures from converged VASP or GPUMD jobs |
72
73
  | `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
73
- | `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
74
+ | `gpumd-trjcat` | Merge normal or all GPUMD trajectories and extract the first abnormal region |
74
75
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
75
76
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
76
77
  | `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
@@ -106,6 +107,8 @@ pmk supercell -if extxyz-opted -max-abc 24 -individual -temp 500 600 -md-if inpu
106
107
  # GPUMD: ./<name>/model.xyz (grouped) + ./<name>/<T>/ jobs
107
108
  pmk vasp-relax -i opted.vasp # relax inputs in current dir
108
109
  pmk vasp-relax -if optimal_occupancy # one ./<name>/ job folder per structure
110
+ pmk gpumd-relax -if example -nep nep89_20250409.txt # model.xyz + run.in + NEP per job folder
111
+ pmk gpumd-relax -it train.xyz -nep nep.txt -in run.in # one ./frame_N/ GPUMD job per frame
109
112
  pmk vasp-static -if cifs/ -custom-setting my_incar.txt # static inputs, custom INCAR
110
113
  pmk vasp-static -it traj.xyz # one ./frame_N/ job per trajectory frame
111
114
  pmk msd # scans <structure>/<temp>/ -> per-job msd/ + msd_summary.txt
@@ -114,8 +117,9 @@ pmk chemsys-entry -s Li La Ta Cl # MP stable entries -> Li-La-Ta
114
117
  pmk ehull -mp-api-key $MP_API_KEY # scans ./ for VASP jobs -> ehull.txt
115
118
  pmk ehull -local Li-La-Ta-Cl-stable-entries-opted
116
119
  pmk vasp-fe # scans ./ for VASP jobs -> final-energy.txt with convergence status
117
- pmk gather-contcar -of vasp-opted # CONTCARs -> vasp-opted/<folder>.vasp
118
- pmk gather-contcar -of vasp-opted -ehull 0.028 # only structures with E_hull < 0.028 eV/atom
120
+ pmk gather-fs -job vasp -fs-name CONTCAR -of vasp-opted
121
+ pmk gather-fs -job vasp -fs-name CONTCAR -of vasp-opted -ehull 0.028
122
+ pmk gather-fs -job gpumd -fs-name relaxed.xyz -of gpumd-opted
119
123
  pmk vasp2xyz # scans ./ for VASP output folders -> scf-converged.xyz
120
124
  pmk vasp2xyz -position-only # write positions only, without energy/forces/stress
121
125
  pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
@@ -137,6 +141,7 @@ pmk gpumd-thermo # current job, or recursively scan
137
141
  pmk gpumd-thermo -t thermo.out -in run.in
138
142
  pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
139
143
  pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
144
+ pmk gpumd-trjcat -all # merge every readable trajectory into train.xyz
140
145
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
141
146
  pmk trj-sparse -it example.xyz -r 0.5 -o sparse-example.xyz
142
147
 
@@ -168,6 +173,29 @@ multi-structure trajectory and writes one job sub-folder per frame
168
173
  (`frame_1/`, `frame_2/`, ..., prefix configurable via `-frame-prefix`). Each
169
174
  folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
170
175
  `vasp2xyz`.
176
+ `gpumd-relax` accepts the same `-i`, `-if`, and `-it` structure modes. `-nep`
177
+ is required and the selected potential is copied into every job folder together
178
+ with `model.xyz` and `run.in`. `-in FILE` supplies a custom GPUMD input; its
179
+ first `potential` filename is synchronized to the copied NEP. Without `-in`,
180
+ the generated job-local input is:
181
+
182
+ ```text
183
+ potential <NEP filename>
184
+
185
+ minimize fire 2.0e-2 1000000
186
+
187
+ ensemble nve
188
+ dump_xyz -1 0 1 relaxed.xyz force
189
+ run 1
190
+ ```
191
+
192
+ `gather-fs` recursively scans all job folders below the current directory. For
193
+ VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
194
+ requires full convergence before copying the selected `-fs-name` as `.vasp`.
195
+ For GPUMD it requires `gpumd.out` to report a force tolerance and a final
196
+ `f_max` no greater than that tolerance before copying the final structure.
197
+ `-fs-name` defaults to `CONTCAR` for VASP and `relaxed.xyz` for GPUMD. The
198
+ `-ehull` and `-ehull-file` filters are available only with `-job vasp`.
171
199
 
172
200
  `add-config-type` updates its input atomically in place. With `-i`, the file must contain exactly one XYZ structure; use `-it` for a trajectory. Existing `Config_type` values are replaced, missing values are added, and all other extended-XYZ bytes remain unchanged.
173
201
 
@@ -177,11 +205,11 @@ When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every
177
205
 
178
206
  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`.
179
207
 
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. For an abnormal run it also writes `abnormal-time.txt`, with one `BEGIN ps to END ps` interval per line; a normal rerun removes a stale file. 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 at least two independent lattice-parameter or volume measures show the same mapped 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 and volume can corroborate an energy transition. Temperature and all six pressure components remain diagnostics only and cannot classify an otherwise stationary run as abnormal by themselves. A lone cell-axis drift, an isolated spike, or an empty anomaly mask never triggers a whole-run fallback. Thermo plots use Arial when available and Matplotlib's built-in qualitative `Dark2` color cycle. They plot all six pressure components, use frameless upper-right legends with added vertical headroom, preserve correct Å units, and show abnormal regions as shaded legend entries 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.
208
+ `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. For an abnormal run it also writes `abnormal-time.txt`, with one `BEGIN ps to END ps` interval per line; a normal rerun removes a stale file. 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. An abnormal classification always requires a mapped potential-energy change. When at least two lattice-parameter or volume signals show the same mapped transition, they may enable a more sensitive noise-relative energy test; this avoids a fixed eV or percentage-of-total-energy threshold that would depend on system size. Cell signals can corroborate and refine the energy interval, but can never classify a run by themselves. Temperature and all six pressure components remain diagnostics only and cannot classify an otherwise stationary run as abnormal by themselves. A lone cell-axis drift, an isolated spike, or an empty anomaly mask never triggers a whole-run fallback. Thermo plots use Arial when available and Matplotlib's built-in qualitative `Dark2` color cycle. They plot all six pressure components, use frameless upper-right legends with added vertical headroom, preserve correct Å units, and show abnormal regions as shaded legend entries 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.
181
209
 
182
210
  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
211
 
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.
212
+ `gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. By default, normal trajectories are merged in deterministic folder order into `train.xyz` (or `-o`), while only frames from the earliest mapped abnormal interval of each abnormal job are written to `abnormal.xyz` (or `-ao`). With `-all`, every readable full trajectory is merged into `train.xyz` regardless of normal, abnormal, missing-thermo, or failed-thermo status; an analyzable abnormal job still contributes its earliest abnormal interval to `abnormal.xyz`. If no mapped abnormal frames are found, `abnormal.xyz` is not created and a stale file at the selected `-ao` path is removed. Frame times are obtained from `time_step` and `dump_xyz` in that job's `run.in`. In default mode, 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
213
 
186
214
  `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`.
187
215
 
@@ -24,8 +24,9 @@ src/pymdkit/commands/compute_msd_all_groups.py
24
24
  src/pymdkit/commands/compute_rmsd.py
25
25
  src/pymdkit/commands/convert.py
26
26
  src/pymdkit/commands/ewald.py
27
- src/pymdkit/commands/gather_contcar.py
27
+ src/pymdkit/commands/gather_fs.py
28
28
  src/pymdkit/commands/gpumd_group.py
29
+ src/pymdkit/commands/gpumd_relax.py
29
30
  src/pymdkit/commands/gpumd_thermo.py
30
31
  src/pymdkit/commands/gpumd_trjcat.py
31
32
  src/pymdkit/commands/nep_rmse.py
@@ -1,106 +0,0 @@
1
- """
2
- Gather CONTCAR files from VASP job folders into one folder.
3
-
4
- Scans the sub-folders of the current directory (like `ehull`) and copies each
5
- folder's CONTCAR into an output folder, renamed ``<folder-name>.vasp``. For
6
- example the CONTCAR in ``1-Li3YCl6-164/`` becomes ``1-Li3YCl6-164.vasp``.
7
-
8
- gather-contcar -of vasp-opted
9
- gather-contcar -of vasp-opted -ehull 0.028 # only E_hull < 0.028 eV/atom
10
-
11
- With -ehull, the ehull.txt report in the current directory (produced by
12
- `pmk ehull`) is read and only structures whose E_hull is below the given
13
- threshold (eV/atom) are gathered.
14
- """
15
-
16
- import argparse
17
- import shutil
18
- from pathlib import Path
19
-
20
- try:
21
- from . import _fileio
22
- except ImportError: # running as a standalone script
23
- import _fileio
24
-
25
- COMMAND = "gather-contcar"
26
- HELP = "Gather CONTCAR files from VASP job folders into one folder (renamed .vasp)."
27
-
28
-
29
- def parse_ehull_file(path):
30
- """Return {folder_name: e_hull} from an ehull.txt report.
31
-
32
- Data rows are ``<folder> <formula> <system> <FinalE> <E_form> <E_hull>``;
33
- the folder is the first token and E_hull the last. Header / separator lines
34
- (whose last token is not a number) are ignored.
35
- """
36
- result = {}
37
- for line in Path(path).read_text().splitlines():
38
- parts = line.split()
39
- if len(parts) < 2:
40
- continue
41
- try:
42
- result[parts[0]] = float(parts[-1])
43
- except ValueError:
44
- continue
45
- return result
46
-
47
-
48
- def add_arguments(parser):
49
- parser.add_argument("-of", "-output-folder", dest="output_folder",
50
- required=True, metavar="DIR",
51
- help="Folder to create and fill with <name>.vasp files.")
52
- parser.add_argument("-ehull", dest="ehull", type=float,
53
- default=None, metavar="EV",
54
- help="Only gather folders whose E_hull (from the report) "
55
- "is below this value in eV/atom.")
56
- parser.add_argument("-entry-path", default=".",
57
- help="Directory to scan for VASP job folders (default: .).")
58
- parser.add_argument("-ehull-file", default="ehull.txt",
59
- help="E_hull report read with -ehull (default: ehull.txt).")
60
-
61
-
62
- def run(args):
63
- root = Path(args.entry_path)
64
- out_dir = Path(args.output_folder)
65
-
66
- ehull_map = None
67
- if args.ehull is not None:
68
- report = Path(args.ehull_file)
69
- if not report.is_file():
70
- raise SystemExit(f"Error: {report} not found (needed for -ehull).")
71
- ehull_map = parse_ehull_file(report)
72
-
73
- out_dir.mkdir(parents=True, exist_ok=True)
74
- gathered = 0
75
- no_ehull = []
76
- for sub in _fileio.subfolders(root):
77
- contcar = sub / "CONTCAR"
78
- if not contcar.is_file():
79
- continue
80
- name = sub.name
81
-
82
- if ehull_map is not None:
83
- val = ehull_map.get(name)
84
- if val is None:
85
- no_ehull.append(name)
86
- continue
87
- if not (val < args.ehull):
88
- continue
89
-
90
- dest = out_dir / f"{name}.vasp"
91
- shutil.copy2(contcar, dest)
92
- tag = f" (E_hull={ehull_map[name]:.3f})" if ehull_map is not None else ""
93
- print(f"{contcar} -> {dest}{tag}")
94
- gathered += 1
95
-
96
- if no_ehull:
97
- print(f"Note: {len(no_ehull)} folder(s) had a CONTCAR but no E_hull entry "
98
- f"in {args.ehull_file}; skipped.")
99
- print(f"Done: gathered {gathered} CONTCAR file(s) into {out_dir}/")
100
- return 0
101
-
102
-
103
- if __name__ == "__main__":
104
- _p = argparse.ArgumentParser(description=__doc__)
105
- add_arguments(_p)
106
- raise SystemExit(run(_p.parse_args()))
File without changes
File without changes