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.
- {pymdkit-1.4.1/src/pymdkit.egg-info → pymdkit-1.4.2}/PKG-INFO +12 -6
- {pymdkit-1.4.1 → pymdkit-1.4.2}/README.md +11 -5
- {pymdkit-1.4.1 → pymdkit-1.4.2}/pyproject.toml +3 -3
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/__init__.py +1 -1
- pymdkit-1.4.2/src/pymdkit/commands/_gpumd.py +348 -0
- pymdkit-1.4.2/src/pymdkit/commands/add_config_type.py +64 -0
- pymdkit-1.4.2/src/pymdkit/commands/gpumd_trjcat.py +198 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/trj_extract.py +3 -35
- {pymdkit-1.4.1 → pymdkit-1.4.2/src/pymdkit.egg-info}/PKG-INFO +12 -6
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/SOURCES.txt +1 -0
- pymdkit-1.4.1/src/pymdkit/commands/_gpumd.py +0 -172
- pymdkit-1.4.1/src/pymdkit/commands/gpumd_trjcat.py +0 -134
- {pymdkit-1.4.1 → pymdkit-1.4.2}/LICENSE +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/setup.cfg +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/__init__.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_extxyz.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_fileio.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_geometry.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_reduction.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_vasp_output.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/_vaspset.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/chemsys_entry.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/compute_ehull.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/compute_msd_all_groups.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/compute_rmsd.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/convert.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/ewald.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/gather_contcar.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/gpumd_group.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/gpumd_thermo.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/nep_rmse.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/pca.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/perturb.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/submit_vasp.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/substitute.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/supercell.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/symmetrize.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/umap.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp2xyz.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp_fe.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp_relax.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/commands/vasp_static.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit/pymdkit_main.py +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/dependency_links.txt +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/entry_points.txt +0 -0
- {pymdkit-1.4.1 → pymdkit-1.4.2}/src/pymdkit.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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`.
|
|
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
|
|
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
|
|
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.
|
|
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`.
|
|
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.
|
|
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
|
|
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
|
|
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]
|
|
@@ -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()))
|