pymdkit 1.4.3__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.
Files changed (47) hide show
  1. {pymdkit-1.4.3/src/pymdkit.egg-info → pymdkit-1.4.4}/PKG-INFO +9 -3
  2. {pymdkit-1.4.3 → pymdkit-1.4.4}/README.md +10 -4
  3. {pymdkit-1.4.3 → pymdkit-1.4.4}/pyproject.toml +1 -1
  4. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/__init__.py +1 -1
  5. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_gpumd.py +161 -55
  6. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/compute_msd_all_groups.py +33 -17
  7. pymdkit-1.4.4/src/pymdkit/commands/gpumd_thermo.py +260 -0
  8. pymdkit-1.4.4/src/pymdkit/commands/trj_sparse.py +79 -0
  9. {pymdkit-1.4.3 → pymdkit-1.4.4/src/pymdkit.egg-info}/PKG-INFO +9 -3
  10. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit.egg-info/SOURCES.txt +1 -0
  11. pymdkit-1.4.3/src/pymdkit/commands/gpumd_thermo.py +0 -131
  12. {pymdkit-1.4.3 → pymdkit-1.4.4}/LICENSE +0 -0
  13. {pymdkit-1.4.3 → pymdkit-1.4.4}/setup.cfg +0 -0
  14. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/__init__.py +0 -0
  15. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_extxyz.py +0 -0
  16. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_fileio.py +0 -0
  17. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_geometry.py +0 -0
  18. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_reduction.py +0 -0
  19. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_vasp_output.py +0 -0
  20. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/_vaspset.py +0 -0
  21. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/add_config_type.py +0 -0
  22. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/chemsys_entry.py +0 -0
  23. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/compute_ehull.py +0 -0
  24. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/compute_rmsd.py +0 -0
  25. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/convert.py +0 -0
  26. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/ewald.py +0 -0
  27. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/gather_contcar.py +0 -0
  28. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/gpumd_group.py +0 -0
  29. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
  30. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/nep_rmse.py +0 -0
  31. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/pca.py +0 -0
  32. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/perturb.py +0 -0
  33. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/submit_vasp.py +0 -0
  34. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/substitute.py +0 -0
  35. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/supercell.py +0 -0
  36. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/symmetrize.py +0 -0
  37. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/trj_extract.py +0 -0
  38. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/umap.py +0 -0
  39. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/vasp2xyz.py +0 -0
  40. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_fe.py +0 -0
  41. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_relax.py +0 -0
  42. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_static.py +0 -0
  43. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit/pymdkit_main.py +0 -0
  44. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  45. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit.egg-info/entry_points.txt +0 -0
  46. {pymdkit-1.4.3 → pymdkit-1.4.4}/src/pymdkit.egg-info/requires.txt +0 -0
  47. {pymdkit-1.4.3 → 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
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. 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. Lattice parameters, volume, temperature, pressure, and angles remain supporting diagnostics. 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.
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
- `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, only frames whose times fall in potential-energy abnormal regions are written to `abnormal.xyz` (or `-ao`); 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.
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. 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. Lattice parameters, volume, temperature, pressure, and angles remain supporting diagnostics. 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.
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
- `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, only frames whose times fall in potential-energy abnormal regions are written to `abnormal.xyz` (or `-ao`); 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.
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.4.3"
7
+ version = "1.4.4"
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.3"
3
+ __version__ = "1.4.4"
@@ -24,7 +24,8 @@ class ThermoData:
24
24
 
25
25
  def parse_run_input(path):
26
26
  time_step = 1.0
27
- dump_interval = 10
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
- dump_interval = int(tokens[1])
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]), dump_interval, time_step))
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, dump_interval, runs
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(steps // interval for steps, interval, _ in runs)
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
- count = steps // interval
86
- increment = step_fs * interval / 1000.0
87
- values.extend(elapsed + np.arange(count) * increment)
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
- if not runs:
96
- return int(length * 0.2)
97
- points = runs[-1][0] // runs[-1][1]
98
- return max(0, length - points)
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 12 or 18"
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 potential_abnormal_mask(values):
198
- """Locate persistent potential-energy transitions or abnormal plateaus."""
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)):
@@ -207,8 +254,6 @@ def potential_abnormal_mask(values):
207
254
  noise = max(left_spread, right_spread, 1e-12)
208
255
  difference = abs(right_center - left_center)
209
256
 
210
- # Average over 1% of the segment so isolated thermal excursions cannot
211
- # trigger a warning. Raw values are used again to refine the boundaries.
212
257
  window = max(5, size // 100)
213
258
  if window % 2 == 0:
214
259
  window += 1
@@ -217,33 +262,58 @@ def potential_abnormal_mask(values):
217
262
  cumulative = np.cumsum(np.insert(padded, 0, 0.0))
218
263
  smooth = (cumulative[window:] - cumulative[:-window]) / window
219
264
 
265
+ effect_multiplier = 0.5 if corroborated else 1.5
220
266
  practical_shift = max(
221
- 1.5 * noise,
267
+ effect_multiplier * noise,
222
268
  1e-6 * max(abs(left_center), abs(right_center), 1.0),
223
269
  )
224
270
  if difference > practical_shift:
225
- # The ends are two meaningfully different stable plateaus. Keep only
226
- # the persistent transition between them.
227
- tolerance = max(0.75 * noise, 0.05 * difference)
271
+ tolerance_multiplier = 0.25 if corroborated else 0.75
272
+ tolerance = max(tolerance_multiplier * noise, 0.05 * difference)
228
273
  smooth_distance = np.minimum(
229
274
  np.abs(smooth - left_center), np.abs(smooth - right_center)
230
275
  )
231
- raw_distance = np.minimum(
232
- np.abs(values - left_center), np.abs(values - right_center)
233
- )
234
- return _clean_mask(
235
- (smooth_distance > tolerance) & (raw_distance > tolerance)
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)
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
286
+ threshold = max(
287
+ multiplier * spread, 1e-6 * max(abs(center), 1.0), 1e-12
236
288
  )
289
+ candidate = np.abs(smooth - center) > threshold
290
+ if not corroborated:
291
+ candidate &= np.abs(values - center) > threshold
237
292
 
238
- # The ends share one baseline. Detect only persistent excursions whose
239
- # magnitude is at least twice the natural robust fluctuation scale.
240
- baseline = np.concatenate((values[:edge_count], values[-edge_count:]))
241
- center, spread = _robust_spread(baseline)
242
- threshold = max(2.0 * spread, 1e-6 * max(abs(center), 1.0), 1e-12)
243
- return _clean_mask(
244
- (np.abs(smooth - center) > threshold)
245
- & (np.abs(values - center) > threshold)
246
- )
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))
247
317
 
248
318
  def mask_intervals(time, mask):
249
319
  """Return inclusive physical-time intervals for contiguous true mask regions."""
@@ -269,7 +339,7 @@ def _format_intervals(intervals):
269
339
 
270
340
  def find_abnormal(series, start):
271
341
  component_names = {
272
- "pressure": ("x", "y", "z"),
342
+ "pressure": ("xx", "yy", "zz", "yz", "xz", "xy"),
273
343
  "lattice-parameter": ("a", "b", "c"),
274
344
  "lattice-angle": ("alpha", "beta", "gamma"),
275
345
  }
@@ -292,24 +362,59 @@ def find_abnormal(series, start):
292
362
  def load_thermo(thermo_path, run_path):
293
363
  thermo_path = Path(thermo_path)
294
364
  data = np.loadtxt(thermo_path, ndmin=2)
295
- if data.shape[1] not in (12, 18):
296
- raise ValueError(f"{thermo_path} must contain 12 or 18 columns")
365
+ if data.shape[1] != 18:
366
+ raise ValueError(f"{thermo_path} must use the official 18-column GPUMD format")
297
367
 
298
368
  time_step, dump_interval, runs = parse_run_input(run_path)
299
369
  time = time_axis(len(data), time_step, dump_interval, runs)
300
370
  temperature = data[:, 0]
301
371
  potential = data[:, 2]
302
- pressure = data[:, 3:6]
372
+ pressure = data[:, 3:9]
303
373
  lengths, angles, volume = cell_quantities(data)
304
374
  start = equilibrium_start(len(data), runs)
305
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
+
306
386
  abnormal_mask = np.zeros(len(data), dtype=bool)
307
- abnormal_mask[start:] = potential_abnormal_mask(potential[start:])
308
- intervals = mask_intervals(time, abnormal_mask)
387
+ segment_mask = potential_abnormal_mask(potential[start:])
388
+ abnormal_mask[start:] = segment_mask
309
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)
310
415
  if intervals:
311
416
  warnings.append(
312
- "potential-energy: localized abnormal region(s) at "
417
+ f"{warning_prefix} abnormal region(s) at "
313
418
  + _format_intervals(intervals)
314
419
  )
315
420
  else:
@@ -318,13 +423,14 @@ def load_thermo(thermo_path, run_path):
318
423
  warnings.append(f"potential-energy: {potential_reason}")
319
424
  abnormal_mask[start:] = True
320
425
 
321
- warnings.extend(find_abnormal({
322
- "temperature": temperature,
323
- "pressure": pressure,
324
- "lattice-parameter": lengths,
325
- "volume": volume,
326
- "lattice-angle": angles,
327
- }, start))
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
+
328
434
  return ThermoData(
329
435
  time=time,
330
436
  temperature=temperature,
@@ -59,9 +59,11 @@ class MDAnalyzer:
59
59
  if not path.exists():
60
60
  return []
61
61
  runs = []
62
- dump_interval, dump_msd, time_step = 10, None, 1.0
63
- for line in path.read_text(encoding="utf-8").splitlines():
64
- parts = line.split()
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 or dump_interval,
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
- n_cols = data.shape[1]
104
- if n_cols == 12: # orthogonal box: lx, ly, lz in cols 9-11
105
- return data[:, 9] * data[:, 10] * data[:, 11]
106
- if n_cols == 18: # triclinic box: 3x3 matrix in cols 9-17
107
- box = data[:, 9:18].reshape(-1, 3, 3)
108
- return np.abs(np.einsum("...i,...i->...", box[:, 0],
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
- dump_interval = self.runs_info[-1]["dump_interval"] if self.runs_info else 1
115
- sl = self._last_run_slice(len(data), dump_interval)
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
- dump_msd = self.runs_info[-1]["dump_msd"] if self.runs_info else 1
147
- data = data[self._last_run_slice(len(data), dump_msd)]
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
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. 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. Lattice parameters, volume, temperature, pressure, and angles remain supporting diagnostics. 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.
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
- `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, only frames whose times fall in potential-energy abnormal regions are written to `abnormal.xyz` (or `-ao`); 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.
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