pymdkit 1.4.2__tar.gz → 1.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {pymdkit-1.4.2/src/pymdkit.egg-info → pymdkit-1.4.4}/PKG-INFO +9 -3
  2. {pymdkit-1.4.2 → pymdkit-1.4.4}/README.md +10 -4
  3. {pymdkit-1.4.2 → pymdkit-1.4.4}/pyproject.toml +1 -1
  4. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/__init__.py +1 -1
  5. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_gpumd.py +168 -71
  6. {pymdkit-1.4.2 → 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.2 → pymdkit-1.4.4/src/pymdkit.egg-info}/PKG-INFO +9 -3
  10. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/SOURCES.txt +1 -0
  11. pymdkit-1.4.2/src/pymdkit/commands/gpumd_thermo.py +0 -131
  12. {pymdkit-1.4.2 → pymdkit-1.4.4}/LICENSE +0 -0
  13. {pymdkit-1.4.2 → pymdkit-1.4.4}/setup.cfg +0 -0
  14. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/__init__.py +0 -0
  15. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_extxyz.py +0 -0
  16. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_fileio.py +0 -0
  17. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_geometry.py +0 -0
  18. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_reduction.py +0 -0
  19. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_vasp_output.py +0 -0
  20. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/_vaspset.py +0 -0
  21. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/add_config_type.py +0 -0
  22. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/chemsys_entry.py +0 -0
  23. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/compute_ehull.py +0 -0
  24. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/compute_rmsd.py +0 -0
  25. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/convert.py +0 -0
  26. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/ewald.py +0 -0
  27. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/gather_contcar.py +0 -0
  28. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/gpumd_group.py +0 -0
  29. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
  30. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/nep_rmse.py +0 -0
  31. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/pca.py +0 -0
  32. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/perturb.py +0 -0
  33. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/submit_vasp.py +0 -0
  34. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/substitute.py +0 -0
  35. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/supercell.py +0 -0
  36. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/symmetrize.py +0 -0
  37. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/trj_extract.py +0 -0
  38. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/umap.py +0 -0
  39. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp2xyz.py +0 -0
  40. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_fe.py +0 -0
  41. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_relax.py +0 -0
  42. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/commands/vasp_static.py +0 -0
  43. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit/pymdkit_main.py +0 -0
  44. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  45. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/entry_points.txt +0 -0
  46. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/requires.txt +0 -0
  47. {pymdkit-1.4.2 → pymdkit-1.4.4}/src/pymdkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.4.2
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: sustained potential-energy shifts, drifts, or localized departures define an abnormal job or time region. 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: sustained potential-energy shifts, drifts, or localized departures define an abnormal job or time region. 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.2"
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.2"
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 sustained potential-energy transitions or departures."""
204
+ def _bridge_mask(mask, max_gap):
205
+ mask = np.asarray(mask, dtype=bool).copy()
206
+ index = 0
207
+ while index < len(mask):
208
+ if mask[index]:
209
+ index += 1
210
+ continue
211
+ end = index
212
+ while end < len(mask) and not mask[end]:
213
+ end += 1
214
+ if index > 0 and end < len(mask) and end - index <= max_gap:
215
+ mask[index:end] = True
216
+ index = end
217
+ return mask
218
+
219
+
220
+ def _keep_major_regions(mask):
221
+ mask = np.asarray(mask, dtype=bool)
222
+ regions = []
223
+ index = 0
224
+ while index < len(mask):
225
+ if not mask[index]:
226
+ index += 1
227
+ continue
228
+ end = index
229
+ while end < len(mask) and mask[end]:
230
+ end += 1
231
+ regions.append((index, end))
232
+ index = end
233
+ if not regions:
234
+ return np.zeros(len(mask), dtype=bool)
235
+
236
+ longest = max(end - begin for begin, end in regions)
237
+ minimum = max(3, len(mask) // 50, longest // 4)
238
+ result = np.zeros(len(mask), dtype=bool)
239
+ for begin, end in regions:
240
+ if end - begin >= minimum:
241
+ result[begin:end] = True
242
+ return result
243
+
244
+
245
+ def _persistent_change_mask(values, corroborated=False, bridge=True):
199
246
  values = np.asarray(values, dtype=float)
200
247
  size = len(values)
201
248
  if size < 20 or not np.all(np.isfinite(values)):
@@ -204,55 +251,69 @@ def potential_abnormal_mask(values):
204
251
  edge_count = max(5, size // 10)
205
252
  left_center, left_spread = _robust_spread(values[:edge_count])
206
253
  right_center, right_spread = _robust_spread(values[-edge_count:])
254
+ noise = max(left_spread, right_spread, 1e-12)
207
255
  difference = abs(right_center - left_center)
208
256
 
209
- window = max(5, size // 50)
257
+ window = max(5, size // 100)
210
258
  if window % 2 == 0:
211
259
  window += 1
212
260
  radius = window // 2
213
- smooth = np.asarray([
214
- np.median(values[max(0, i - radius):min(size, i + radius + 1)])
215
- for i in range(size)
216
- ])
261
+ padded = np.pad(values, (radius, radius), mode="edge")
262
+ cumulative = np.cumsum(np.insert(padded, 0, 0.0))
263
+ smooth = (cumulative[window:] - cumulative[:-window]) / window
217
264
 
218
- mean_threshold = max(
219
- 6.0 * np.hypot(left_spread, right_spread) / np.sqrt(edge_count),
265
+ effect_multiplier = 0.5 if corroborated else 1.5
266
+ practical_shift = max(
267
+ effect_multiplier * noise,
220
268
  1e-6 * max(abs(left_center), abs(right_center), 1.0),
221
269
  )
222
- if difference > mean_threshold:
223
- # Both edge plateaus are valid equilibrium levels. Mark only samples
224
- # between them, not either stable plateau.
225
- plateau_tolerance = max(
226
- 4.0 * max(left_spread, right_spread) / np.sqrt(window),
227
- 0.02 * difference,
228
- 1e-12,
229
- )
230
- distance = np.minimum(
270
+ if difference > practical_shift:
271
+ tolerance_multiplier = 0.25 if corroborated else 0.75
272
+ tolerance = max(tolerance_multiplier * noise, 0.05 * difference)
273
+ smooth_distance = np.minimum(
231
274
  np.abs(smooth - left_center), np.abs(smooth - right_center)
232
275
  )
233
- transition = _clean_mask(distance > plateau_tolerance)
234
- if transition.any():
235
- return transition
236
-
237
- # An abrupt jump may contain no intermediate values. In that case,
238
- # retain a narrow window around the change rather than one plateau.
239
- score = np.zeros(size)
240
- for index in range(window, size - window):
241
- score[index] = abs(
242
- np.mean(smooth[index:index + window])
243
- - np.mean(smooth[index - window:index])
276
+ candidate = smooth_distance > tolerance
277
+ if not corroborated:
278
+ raw_distance = np.minimum(
279
+ np.abs(values - left_center), np.abs(values - right_center)
244
280
  )
281
+ candidate &= raw_distance > tolerance
282
+ else:
283
+ baseline = np.concatenate((values[:edge_count], values[-edge_count:]))
284
+ center, spread = _robust_spread(baseline)
285
+ multiplier = 1.0 if corroborated else 2.0
245
286
  threshold = max(
246
- 0.1 * difference,
247
- 4.0 * max(left_spread, right_spread) / np.sqrt(window),
287
+ multiplier * spread, 1e-6 * max(abs(center), 1.0), 1e-12
248
288
  )
249
- return _clean_mask(score > threshold)
250
-
251
- baseline = np.concatenate((values[:edge_count], values[-edge_count:]))
252
- center, spread = _robust_spread(baseline)
253
- threshold = max(6.0 * spread, 1e-6 * max(abs(center), 1.0), 1e-12)
254
- return _clean_mask(np.abs(smooth - center) > threshold)
255
-
289
+ candidate = np.abs(smooth - center) > threshold
290
+ if not corroborated:
291
+ candidate &= np.abs(values - center) > threshold
292
+
293
+ if corroborated and bridge:
294
+ candidate = _bridge_mask(candidate, max(1, size // 20))
295
+ return _clean_mask(candidate)
296
+
297
+
298
+ def potential_abnormal_mask(values, corroborated=False):
299
+ """Locate persistent potential-energy transitions or abnormal plateaus."""
300
+ return _persistent_change_mask(values, corroborated=corroborated)
301
+
302
+
303
+ def structural_abnormal_mask(lengths, volume):
304
+ """Find dominant transition regions supported by multiple cell measures."""
305
+ components = [np.asarray(lengths)[:, index] for index in range(3)]
306
+ components.append(np.asarray(volume))
307
+ masks = [
308
+ _persistent_change_mask(values, corroborated=True, bridge=False)
309
+ for values in components
310
+ ]
311
+ votes = np.sum(masks, axis=0)
312
+ candidate = votes >= 2
313
+ if not candidate.any():
314
+ candidate = votes >= 1
315
+ candidate = _bridge_mask(candidate, max(1, len(candidate) // 50))
316
+ return _keep_major_regions(_clean_mask(candidate))
256
317
 
257
318
  def mask_intervals(time, mask):
258
319
  """Return inclusive physical-time intervals for contiguous true mask regions."""
@@ -278,7 +339,7 @@ def _format_intervals(intervals):
278
339
 
279
340
  def find_abnormal(series, start):
280
341
  component_names = {
281
- "pressure": ("x", "y", "z"),
342
+ "pressure": ("xx", "yy", "zz", "yz", "xz", "xy"),
282
343
  "lattice-parameter": ("a", "b", "c"),
283
344
  "lattice-angle": ("alpha", "beta", "gamma"),
284
345
  }
@@ -301,24 +362,59 @@ def find_abnormal(series, start):
301
362
  def load_thermo(thermo_path, run_path):
302
363
  thermo_path = Path(thermo_path)
303
364
  data = np.loadtxt(thermo_path, ndmin=2)
304
- if data.shape[1] not in (12, 18):
305
- 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")
306
367
 
307
368
  time_step, dump_interval, runs = parse_run_input(run_path)
308
369
  time = time_axis(len(data), time_step, dump_interval, runs)
309
370
  temperature = data[:, 0]
310
371
  potential = data[:, 2]
311
- pressure = data[:, 3:6]
372
+ pressure = data[:, 3:9]
312
373
  lengths, angles, volume = cell_quantities(data)
313
374
  start = equilibrium_start(len(data), runs)
314
375
 
376
+ structural_warnings = find_abnormal({
377
+ "lattice-parameter": lengths,
378
+ "volume": volume,
379
+ "lattice-angle": angles,
380
+ }, start)
381
+ other_warnings = find_abnormal({
382
+ "temperature": temperature,
383
+ "pressure": pressure,
384
+ }, start)
385
+
315
386
  abnormal_mask = np.zeros(len(data), dtype=bool)
316
- abnormal_mask[start:] = potential_abnormal_mask(potential[start:])
317
- intervals = mask_intervals(time, abnormal_mask)
387
+ segment_mask = potential_abnormal_mask(potential[start:])
388
+ abnormal_mask[start:] = segment_mask
318
389
  warnings = []
390
+
391
+ if not segment_mask.any() and structural_warnings:
392
+ structural_mask = structural_abnormal_mask(
393
+ lengths[start:], volume[start:]
394
+ )
395
+ relaxed_energy = potential_abnormal_mask(
396
+ potential[start:], corroborated=True
397
+ )
398
+ overlap = relaxed_energy & structural_mask
399
+ if overlap.any():
400
+ segment_mask = _keep_major_regions(
401
+ _bridge_mask(overlap, max(1, len(overlap) // 20))
402
+ )
403
+ warning_prefix = "potential-energy: structurally corroborated"
404
+ elif structural_mask.any():
405
+ segment_mask = structural_mask
406
+ warning_prefix = "structure: mapped fallback"
407
+ else:
408
+ segment_mask = np.ones(len(potential) - start, dtype=bool)
409
+ warning_prefix = "structure: full equilibrium fallback"
410
+ abnormal_mask[start:] = segment_mask
411
+ else:
412
+ warning_prefix = "potential-energy: localized"
413
+
414
+ intervals = mask_intervals(time, abnormal_mask)
319
415
  if intervals:
320
416
  warnings.append(
321
- "potential-energy: localized abnormal region(s) at "
417
+ f"{warning_prefix} abnormal region(s) at "
322
418
  + _format_intervals(intervals)
323
419
  )
324
420
  else:
@@ -327,13 +423,14 @@ def load_thermo(thermo_path, run_path):
327
423
  warnings.append(f"potential-energy: {potential_reason}")
328
424
  abnormal_mask[start:] = True
329
425
 
330
- warnings.extend(find_abnormal({
331
- "temperature": temperature,
332
- "pressure": pressure,
333
- "lattice-parameter": lengths,
334
- "volume": volume,
335
- "lattice-angle": angles,
336
- }, 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
+
337
434
  return ThermoData(
338
435
  time=time,
339
436
  temperature=temperature,
@@ -59,9 +59,11 @@ class MDAnalyzer:
59
59
  if not path.exists():
60
60
  return []
61
61
  runs = []
62
- dump_interval, 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.2
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: sustained potential-energy shifts, drifts, or localized departures define an abnormal job or time region. 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