pymdkit 1.4.1__tar.gz → 1.4.2__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.1/src/pymdkit.egg-info → pymdkit-1.4.2}/PKG-INFO +12 -6
  2. {pymdkit-1.4.1 → pymdkit-1.4.2}/README.md +11 -5
  3. {pymdkit-1.4.1 → pymdkit-1.4.2}/pyproject.toml +3 -3
  4. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/__init__.py +1 -1
  5. pymdkit-1.4.2/src/pymdkit/commands/_gpumd.py +348 -0
  6. pymdkit-1.4.2/src/pymdkit/commands/add_config_type.py +64 -0
  7. pymdkit-1.4.2/src/pymdkit/commands/gpumd_trjcat.py +198 -0
  8. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/trj_extract.py +3 -35
  9. {pymdkit-1.4.1 → pymdkit-1.4.2/src/pymdkit.egg-info}/PKG-INFO +12 -6
  10. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/SOURCES.txt +1 -0
  11. pymdkit-1.4.1/src/pymdkit/commands/_gpumd.py +0 -172
  12. pymdkit-1.4.1/src/pymdkit/commands/gpumd_trjcat.py +0 -134
  13. {pymdkit-1.4.1 → pymdkit-1.4.2}/LICENSE +0 -0
  14. {pymdkit-1.4.1 → pymdkit-1.4.2}/setup.cfg +0 -0
  15. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/__init__.py +0 -0
  16. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_extxyz.py +0 -0
  17. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_fileio.py +0 -0
  18. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_geometry.py +0 -0
  19. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_reduction.py +0 -0
  20. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_vasp_output.py +0 -0
  21. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_vaspset.py +0 -0
  22. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/chemsys_entry.py +0 -0
  23. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/compute_ehull.py +0 -0
  24. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/compute_msd_all_groups.py +0 -0
  25. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/compute_rmsd.py +0 -0
  26. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/convert.py +0 -0
  27. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/ewald.py +0 -0
  28. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/gather_contcar.py +0 -0
  29. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/gpumd_group.py +0 -0
  30. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/gpumd_thermo.py +0 -0
  31. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/nep_rmse.py +0 -0
  32. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/pca.py +0 -0
  33. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/perturb.py +0 -0
  34. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/submit_vasp.py +0 -0
  35. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/substitute.py +0 -0
  36. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/supercell.py +0 -0
  37. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/symmetrize.py +0 -0
  38. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/umap.py +0 -0
  39. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp2xyz.py +0 -0
  40. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp_fe.py +0 -0
  41. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp_relax.py +0 -0
  42. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp_static.py +0 -0
  43. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/pymdkit_main.py +0 -0
  44. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  45. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/entry_points.txt +0 -0
  46. {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/requires.txt +0 -0
  47. {pymdkit-1.4.1 → pymdkit-1.4.2}/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.1
3
+ Version: 1.4.2
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
@@ -63,13 +63,14 @@ directory for job sub-folders automatically.
63
63
 
64
64
  | Command | What it does |
65
65
  |---|---|
66
+ | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
66
67
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
67
68
  | `ewald` | Compute CIF electrostatic energy with pymatgen EwaldSummation |
68
69
  | `ehull` | Auto-detect VASP job folders and compute E_hull vs Materials Project |
69
70
  | `vasp-fe` | List VASP job final energies from lowest to highest |
70
71
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
71
72
  | `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
72
- | `gpumd-trjcat` | Merge trajectories from GPUMD jobs with stable equilibrium thermo data |
73
+ | `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
73
74
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
74
75
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
75
76
  | `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
@@ -90,6 +91,8 @@ directory for job sub-folders automatically.
90
91
  ## Examples
91
92
 
92
93
  ```bash
94
+ pmk add-config-type -i single-structure.xyz -n P-3m1
95
+ pmk add-config-type -it trajectory.xyz -n P-3m1
93
96
  pmk gpumd-group -i opted.cif -elements Li Y Cl -o model.xyz
94
97
  pmk gpumd-group -if cifs/ -elements Li Y Cl -of cifs-grouped/ # whole folder
95
98
  pmk gpumd-group -elements Li Y Cl # scan subfolders, tag each model.xyz in place
@@ -131,8 +134,8 @@ pmk pca -i descriptor.out -o pca-descriptor.txt -fps 0.01 -it train.xyz
131
134
  pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
132
135
  pmk gpumd-thermo # current job, or recursively scan job folders
133
136
  pmk gpumd-thermo -t thermo.out -in run.in
134
- pmk gpumd-trjcat # stable ./<job>/traj.xyz files -> train.xyz
135
- pmk gpumd-trjcat -i traj.xyz -o train.xyz
137
+ pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
138
+ pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
136
139
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
137
140
 
138
141
  pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -164,15 +167,17 @@ multi-structure trajectory and writes one job sub-folder per frame
164
167
  folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
165
168
  `vasp2xyz`.
166
169
 
170
+ `add-config-type` updates its input atomically in place. With `-i`, the file must contain exactly one XYZ structure; use `-it` for a trajectory. Existing `Config_type` values are replaced, missing values are added, and all other extended-XYZ bytes remain unchanged.
171
+
167
172
  `nep-rmse -select`, PCA FPS, and UMAP FPS copy selected extended-XYZ frame blocks directly from the input trajectory. No parser rewrites energy, stress, forces, positions, precision, or extra metadata.
168
173
 
169
174
  When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every frame and writes one additional descriptor table per value, such as `pca-descriptor-P-3m1.txt` and `pca-descriptor-Pnma.txt`. With `-fps`, it also writes matching files such as `fps-0.01-pca-descriptor-P-3m1.txt` and `fps-0.01-train-P-3m1.xyz`. These files partition the single global FPS selection; FPS is not rerun independently for each `Config_type`. Frames without `Config_type` remain in the main outputs but do not receive a type-specific file.
170
175
 
171
176
  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`.
172
177
 
173
- `gpumd-thermo` writes `temperature.txt`, `potential-energy.txt`, `pressure.txt`, `lattice-parameters.txt`, `volume.txt`, `lattice-angles.txt`, and a headless `thermo.png` in every detected GPUMD job folder. Its warning is a numerical reminder based on sustained drift or a level shift in the last `run` segment (or the last 80% when `run.in` has no `run` record); inspect the plot before deciding whether a trajectory is equilibrated.
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.
174
179
 
175
- `gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Jobs with missing, unreadable, or abnormal thermo data are skipped. Stable trajectories are merged in deterministic folder order. When `<job>/model.xyz` contains `Config_type`, that value is applied to every frame from the job while all other extended-XYZ bytes remain unchanged.
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.
176
181
 
177
182
  Each command's full flag list is in `pmk <command> -help`.
178
183
 
@@ -227,6 +232,7 @@ pymdkit/
227
232
  |-- vasp_fe.py
228
233
  |-- perturb.py
229
234
  |-- nep_rmse.py
235
+ |-- add_config_type.py
230
236
  |-- chemsys_entry.py
231
237
  |-- submit_vasp.py
232
238
  |-- convert.py
@@ -37,13 +37,14 @@ directory for job sub-folders automatically.
37
37
 
38
38
  | Command | What it does |
39
39
  |---|---|
40
+ | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
40
41
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
41
42
  | `ewald` | Compute CIF electrostatic energy with pymatgen EwaldSummation |
42
43
  | `ehull` | Auto-detect VASP job folders and compute E_hull vs Materials Project |
43
44
  | `vasp-fe` | List VASP job final energies from lowest to highest |
44
45
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
45
46
  | `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
46
- | `gpumd-trjcat` | Merge trajectories from GPUMD jobs with stable equilibrium thermo data |
47
+ | `gpumd-trjcat` | Merge stable GPUMD trajectories and extract abnormal time regions |
47
48
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
48
49
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
49
50
  | `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
@@ -64,6 +65,8 @@ directory for job sub-folders automatically.
64
65
  ## Examples
65
66
 
66
67
  ```bash
68
+ pmk add-config-type -i single-structure.xyz -n P-3m1
69
+ pmk add-config-type -it trajectory.xyz -n P-3m1
67
70
  pmk gpumd-group -i opted.cif -elements Li Y Cl -o model.xyz
68
71
  pmk gpumd-group -if cifs/ -elements Li Y Cl -of cifs-grouped/ # whole folder
69
72
  pmk gpumd-group -elements Li Y Cl # scan subfolders, tag each model.xyz in place
@@ -105,8 +108,8 @@ pmk pca -i descriptor.out -o pca-descriptor.txt -fps 0.01 -it train.xyz
105
108
  pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
106
109
  pmk gpumd-thermo # current job, or recursively scan job folders
107
110
  pmk gpumd-thermo -t thermo.out -in run.in
108
- pmk gpumd-trjcat # stable ./<job>/traj.xyz files -> train.xyz
109
- pmk gpumd-trjcat -i traj.xyz -o train.xyz
111
+ pmk gpumd-trjcat # stable frames -> train.xyz; abnormal regions -> abnormal.xyz
112
+ pmk gpumd-trjcat -i traj.xyz -o train.xyz -ao abnormal.xyz
110
113
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
111
114
 
112
115
  pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -138,15 +141,17 @@ multi-structure trajectory and writes one job sub-folder per frame
138
141
  folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
139
142
  `vasp2xyz`.
140
143
 
144
+ `add-config-type` updates its input atomically in place. With `-i`, the file must contain exactly one XYZ structure; use `-it` for a trajectory. Existing `Config_type` values are replaced, missing values are added, and all other extended-XYZ bytes remain unchanged.
145
+
141
146
  `nep-rmse -select`, PCA FPS, and UMAP FPS copy selected extended-XYZ frame blocks directly from the input trajectory. No parser rewrites energy, stress, forces, positions, precision, or extra metadata.
142
147
 
143
148
  When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every frame and writes one additional descriptor table per value, such as `pca-descriptor-P-3m1.txt` and `pca-descriptor-Pnma.txt`. With `-fps`, it also writes matching files such as `fps-0.01-pca-descriptor-P-3m1.txt` and `fps-0.01-train-P-3m1.xyz`. These files partition the single global FPS selection; FPS is not rerun independently for each `Config_type`. Frames without `Config_type` remain in the main outputs but do not receive a type-specific file.
144
149
 
145
150
  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`.
146
151
 
147
- `gpumd-thermo` writes `temperature.txt`, `potential-energy.txt`, `pressure.txt`, `lattice-parameters.txt`, `volume.txt`, `lattice-angles.txt`, and a headless `thermo.png` in every detected GPUMD job folder. Its warning is a numerical reminder based on sustained drift or a level shift in the last `run` segment (or the last 80% when `run.in` has no `run` record); inspect the plot before deciding whether a trajectory is equilibrated.
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.
148
153
 
149
- `gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Jobs with missing, unreadable, or abnormal thermo data are skipped. Stable trajectories are merged in deterministic folder order. When `<job>/model.xyz` contains `Config_type`, that value is applied to every frame from the job while all other extended-XYZ bytes remain unchanged.
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.
150
155
 
151
156
  Each command's full flag list is in `pmk <command> -help`.
152
157
 
@@ -201,6 +206,7 @@ pymdkit/
201
206
  |-- vasp_fe.py
202
207
  |-- perturb.py
203
208
  |-- nep_rmse.py
209
+ |-- add_config_type.py
204
210
  |-- chemsys_entry.py
205
211
  |-- submit_vasp.py
206
212
  |-- convert.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.4.1"
7
+ version = "1.4.2"
8
8
  description = "A unified command-line toolkit for atomistic / MD structure workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Topic :: Scientific/Engineering :: Physics",
21
21
  ]
22
22
 
23
- # All dependencies are installed up front so a plain ppip install pymdkitp
23
+ # All dependencies are installed up front so a plain `pip install pymdkit`
24
24
  # gives a fully working CLI. Heavy libraries are still imported lazily inside
25
25
  # individual commands, so the dispatcher and pmk -help stay fast.
26
26
  dependencies = [
@@ -36,7 +36,7 @@ dependencies = [
36
36
  ]
37
37
 
38
38
  [project.scripts]
39
- # The distribution remains pymdkit; the concise public executable is ppmkp.
39
+ # The distribution remains pymdkit; the concise public executable is `pmk`.
40
40
  pmk = "pymdkit.pymdkit_main:main"
41
41
 
42
42
  [tool.setuptools.packages.find]
@@ -1,3 +1,3 @@
1
1
  """pymdkit -- a unified CLI for atomistic / MD structure workflows."""
2
2
 
3
- __version__ = "1.4.1"
3
+ __version__ = "1.4.2"
@@ -0,0 +1,348 @@
1
+ """Shared GPUMD input and thermo-analysis utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ThermoData:
13
+ time: np.ndarray
14
+ temperature: np.ndarray
15
+ potential: np.ndarray
16
+ pressure: np.ndarray
17
+ lengths: np.ndarray
18
+ angles: np.ndarray
19
+ volume: np.ndarray
20
+ equilibrium_start: int
21
+ abnormal_mask: np.ndarray
22
+ warnings: tuple[str, ...]
23
+
24
+
25
+ def parse_run_input(path):
26
+ time_step = 1.0
27
+ dump_interval = 10
28
+ runs = []
29
+ for raw in Path(path).read_text(encoding="utf-8").splitlines():
30
+ tokens = raw.split("#", 1)[0].split()
31
+ if not tokens:
32
+ continue
33
+ try:
34
+ if tokens[0] == "time_step" and len(tokens) >= 2:
35
+ time_step = float(tokens[1])
36
+ elif tokens[0] == "dump_thermo" and len(tokens) >= 2:
37
+ dump_interval = int(tokens[1])
38
+ elif tokens[0] == "run" and len(tokens) >= 2:
39
+ runs.append((int(tokens[1]), dump_interval, time_step))
40
+ except ValueError as exc:
41
+ raise ValueError(f"invalid {tokens[0]} setting in {path}: {raw}") from exc
42
+ return time_step, dump_interval, runs
43
+
44
+
45
+ def trajectory_interval(run_input, trajectory):
46
+ """Return the GPUMD XYZ dump timing as (time_step, steps, ps)."""
47
+ time_step = None
48
+ dump_intervals = []
49
+ trajectory_name = Path(trajectory).name
50
+ for raw in Path(run_input).read_text(encoding="utf-8").splitlines():
51
+ tokens = raw.split("#", 1)[0].split()
52
+ if not tokens:
53
+ continue
54
+ if tokens[0] == "time_step" and len(tokens) >= 2:
55
+ time_step = float(tokens[1])
56
+ elif tokens[0] == "dump_xyz" and len(tokens) >= 2:
57
+ numeric = []
58
+ for token in tokens[1:-1] if len(tokens) > 2 else tokens[1:]:
59
+ try:
60
+ value = int(token)
61
+ except ValueError:
62
+ continue
63
+ if value > 0:
64
+ numeric.append(value)
65
+ if numeric:
66
+ dump_intervals.append((tokens[-1] == trajectory_name, numeric[-1]))
67
+ if time_step is None:
68
+ raise ValueError(f"no time_step setting found in {run_input}")
69
+ matching = [value for matches, value in dump_intervals if matches]
70
+ if matching:
71
+ dump_interval = matching[-1]
72
+ elif dump_intervals:
73
+ dump_interval = dump_intervals[-1][1]
74
+ else:
75
+ raise ValueError(f"no usable dump_xyz interval found in {run_input}")
76
+ return time_step, dump_interval, time_step * dump_interval / 1000.0
77
+
78
+
79
+ def time_axis(length, time_step, dump_interval, runs):
80
+ expected = sum(steps // interval for steps, interval, _ in runs)
81
+ if runs and expected == length:
82
+ values = []
83
+ elapsed = 0.0
84
+ 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)
88
+ elapsed += steps * step_fs / 1000.0
89
+ return np.asarray(values, dtype=float)
90
+ increment = time_step * dump_interval / 1000.0
91
+ return np.arange(length, dtype=float) * increment
92
+
93
+
94
+ 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
+
100
+
101
+ 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
+ if data.shape[1] != 18:
107
+ raise ValueError(
108
+ f"thermo.out has {data.shape[1]} columns; expected 12 or 18"
109
+ )
110
+
111
+ vectors = data[:, 9:18].reshape(-1, 3, 3)
112
+ lengths = np.linalg.norm(vectors, axis=2)
113
+
114
+ def angle(first, second):
115
+ denominator = np.linalg.norm(first, axis=1) * np.linalg.norm(second, axis=1)
116
+ cosine = np.divide(
117
+ np.einsum("ij,ij->i", first, second), denominator,
118
+ out=np.zeros(len(first)), where=denominator != 0,
119
+ )
120
+ return np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
121
+
122
+ angles = np.column_stack([
123
+ angle(vectors[:, 1], vectors[:, 2]),
124
+ angle(vectors[:, 2], vectors[:, 0]),
125
+ angle(vectors[:, 0], vectors[:, 1]),
126
+ ])
127
+ return lengths, angles, np.abs(np.linalg.det(vectors))
128
+
129
+
130
+ def abnormal_reason(values):
131
+ values = np.asarray(values, dtype=float)
132
+ values = values[np.isfinite(values)]
133
+ if len(values) < 20 or np.ptp(values) == 0:
134
+ return None
135
+
136
+ count = max(3, len(values) // 5)
137
+ first = float(np.mean(values[:count]))
138
+ last = float(np.mean(values[-count:]))
139
+ spread = float(np.std(values))
140
+ scale = max(abs(float(np.median(values))), 1.0)
141
+ threshold = max(0.5 * spread, 0.005 * scale)
142
+
143
+ shift = last - first
144
+ if abs(shift) > threshold:
145
+ return f"level shift {shift:+.5g}"
146
+
147
+ x = np.linspace(-0.5, 0.5, len(values))
148
+ slope = float(np.polyfit(x, values, 1)[0])
149
+ if abs(slope) > threshold:
150
+ return f"sustained drift {slope:+.5g} over the equilibrium segment"
151
+ return None
152
+
153
+
154
+ def _robust_spread(values):
155
+ values = np.asarray(values, dtype=float)
156
+ center = float(np.median(values))
157
+ spread = 1.4826 * float(np.median(np.abs(values - center)))
158
+ if spread == 0:
159
+ spread = float(np.std(values))
160
+ return center, spread
161
+
162
+
163
+ def _clean_mask(mask):
164
+ mask = np.asarray(mask, dtype=bool).copy()
165
+ size = len(mask)
166
+ if not mask.any():
167
+ return mask
168
+
169
+ max_gap = max(1, size // 200)
170
+ index = 0
171
+ while index < size:
172
+ if mask[index]:
173
+ index += 1
174
+ continue
175
+ end = index
176
+ while end < size and not mask[end]:
177
+ end += 1
178
+ if index > 0 and end < size and end - index <= max_gap:
179
+ mask[index:end] = True
180
+ index = end
181
+
182
+ minimum = max(3, size // 100)
183
+ index = 0
184
+ while index < size:
185
+ if not mask[index]:
186
+ index += 1
187
+ continue
188
+ end = index
189
+ while end < size and mask[end]:
190
+ end += 1
191
+ if end - index < minimum:
192
+ mask[index:end] = False
193
+ index = end
194
+ return mask
195
+
196
+
197
+ def potential_abnormal_mask(values):
198
+ """Locate sustained potential-energy transitions or departures."""
199
+ values = np.asarray(values, dtype=float)
200
+ size = len(values)
201
+ if size < 20 or not np.all(np.isfinite(values)):
202
+ return np.zeros(size, dtype=bool)
203
+
204
+ edge_count = max(5, size // 10)
205
+ left_center, left_spread = _robust_spread(values[:edge_count])
206
+ right_center, right_spread = _robust_spread(values[-edge_count:])
207
+ difference = abs(right_center - left_center)
208
+
209
+ window = max(5, size // 50)
210
+ if window % 2 == 0:
211
+ window += 1
212
+ 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
+ ])
217
+
218
+ mean_threshold = max(
219
+ 6.0 * np.hypot(left_spread, right_spread) / np.sqrt(edge_count),
220
+ 1e-6 * max(abs(left_center), abs(right_center), 1.0),
221
+ )
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(
231
+ np.abs(smooth - left_center), np.abs(smooth - right_center)
232
+ )
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])
244
+ )
245
+ threshold = max(
246
+ 0.1 * difference,
247
+ 4.0 * max(left_spread, right_spread) / np.sqrt(window),
248
+ )
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
+
256
+
257
+ def mask_intervals(time, mask):
258
+ """Return inclusive physical-time intervals for contiguous true mask regions."""
259
+ time = np.asarray(time, dtype=float)
260
+ mask = np.asarray(mask, dtype=bool)
261
+ intervals = []
262
+ index = 0
263
+ while index < len(mask):
264
+ if not mask[index]:
265
+ index += 1
266
+ continue
267
+ end = index
268
+ while end + 1 < len(mask) and mask[end + 1]:
269
+ end += 1
270
+ intervals.append((float(time[index]), float(time[end])))
271
+ index = end + 1
272
+ return intervals
273
+
274
+
275
+ def _format_intervals(intervals):
276
+ return ", ".join(f"{begin:g}-{end:g} ps" for begin, end in intervals)
277
+
278
+
279
+ def find_abnormal(series, start):
280
+ component_names = {
281
+ "pressure": ("x", "y", "z"),
282
+ "lattice-parameter": ("a", "b", "c"),
283
+ "lattice-angle": ("alpha", "beta", "gamma"),
284
+ }
285
+ warnings = []
286
+ for name, values in series.items():
287
+ array = np.asarray(values)
288
+ if array.ndim == 1:
289
+ array = array[:, None]
290
+ labels = component_names.get(name, tuple(str(i) for i in range(array.shape[1])))
291
+ if array.shape[1] == 1:
292
+ labels = (name,)
293
+ for label, column in zip(labels, array.T):
294
+ reason = abnormal_reason(column[start:])
295
+ if reason:
296
+ prefix = name if label == name else f"{name}-{label}"
297
+ warnings.append(f"{prefix}: {reason}")
298
+ return tuple(warnings)
299
+
300
+
301
+ def load_thermo(thermo_path, run_path):
302
+ thermo_path = Path(thermo_path)
303
+ 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")
306
+
307
+ time_step, dump_interval, runs = parse_run_input(run_path)
308
+ time = time_axis(len(data), time_step, dump_interval, runs)
309
+ temperature = data[:, 0]
310
+ potential = data[:, 2]
311
+ pressure = data[:, 3:6]
312
+ lengths, angles, volume = cell_quantities(data)
313
+ start = equilibrium_start(len(data), runs)
314
+
315
+ abnormal_mask = np.zeros(len(data), dtype=bool)
316
+ abnormal_mask[start:] = potential_abnormal_mask(potential[start:])
317
+ intervals = mask_intervals(time, abnormal_mask)
318
+ warnings = []
319
+ if intervals:
320
+ warnings.append(
321
+ "potential-energy: localized abnormal region(s) at "
322
+ + _format_intervals(intervals)
323
+ )
324
+ else:
325
+ potential_reason = abnormal_reason(potential[start:])
326
+ if potential_reason:
327
+ warnings.append(f"potential-energy: {potential_reason}")
328
+ abnormal_mask[start:] = True
329
+
330
+ warnings.extend(find_abnormal({
331
+ "temperature": temperature,
332
+ "pressure": pressure,
333
+ "lattice-parameter": lengths,
334
+ "volume": volume,
335
+ "lattice-angle": angles,
336
+ }, start))
337
+ return ThermoData(
338
+ time=time,
339
+ temperature=temperature,
340
+ potential=potential,
341
+ pressure=pressure,
342
+ lengths=lengths,
343
+ angles=angles,
344
+ volume=volume,
345
+ equilibrium_start=start,
346
+ abnormal_mask=abnormal_mask,
347
+ warnings=tuple(warnings),
348
+ )
@@ -0,0 +1,64 @@
1
+ """Add or replace Config_type in one structure or every trajectory frame."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ try:
9
+ from ._extxyz import read_blocks, set_config_type
10
+ except ImportError:
11
+ from _extxyz import read_blocks, set_config_type
12
+
13
+ COMMAND = "add-config-type"
14
+ HELP = "Add Config_type to a single XYZ structure or every trajectory frame."
15
+
16
+
17
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
18
+ inputs = parser.add_mutually_exclusive_group(required=True)
19
+ inputs.add_argument("-i", "-input", dest="input",
20
+ help="Single-structure XYZ file to update in place.")
21
+ inputs.add_argument("-it", "-input-trajectory", dest="input_trajectory",
22
+ help="XYZ trajectory to update in place.")
23
+ parser.add_argument("-n", "-name", dest="name", required=True,
24
+ help="Config_type value, for example P-3m1.")
25
+
26
+
27
+ def update_file(path, config_type, single_structure=False):
28
+ path = Path(path)
29
+ if not path.is_file():
30
+ raise ValueError(f"input file not found: {path}")
31
+ blocks, _ = read_blocks(path)
32
+ if not blocks:
33
+ raise ValueError(f"input contains no XYZ structures: {path}")
34
+ if single_structure and len(blocks) != 1:
35
+ raise ValueError(
36
+ f"-i requires exactly one structure, but {path} has {len(blocks)} frames; "
37
+ "use -it for a trajectory"
38
+ )
39
+
40
+ updated = [set_config_type(block, config_type) for block in blocks]
41
+ temporary = path.with_name(f".{path.name}.pmk-tmp")
42
+ try:
43
+ temporary.write_bytes(b"".join(updated))
44
+ temporary.replace(path)
45
+ except Exception:
46
+ temporary.unlink(missing_ok=True)
47
+ raise
48
+ return len(updated)
49
+
50
+
51
+ def run(args) -> int:
52
+ path = args.input if args.input else args.input_trajectory
53
+ try:
54
+ count = update_file(path, args.name, single_structure=bool(args.input))
55
+ except (OSError, ValueError) as exc:
56
+ raise SystemExit(f"Error: {exc}") from exc
57
+ print(f"Set Config_type={args.name} in {count} structure(s): {path}")
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ _parser = argparse.ArgumentParser(description=__doc__)
63
+ add_arguments(_parser)
64
+ raise SystemExit(run(_parser.parse_args()))