pymdkit 1.4.0__tar.gz → 1.4.1__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.0/src/pymdkit.egg-info → pymdkit-1.4.1}/PKG-INFO +13 -2
  2. {pymdkit-1.4.0 → pymdkit-1.4.1}/README.md +14 -3
  3. {pymdkit-1.4.0 → pymdkit-1.4.1}/pyproject.toml +1 -1
  4. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/__init__.py +1 -1
  5. pymdkit-1.4.1/src/pymdkit/commands/_extxyz.py +85 -0
  6. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/_fileio.py +15 -8
  7. pymdkit-1.4.1/src/pymdkit/commands/_geometry.py +12 -0
  8. pymdkit-1.4.1/src/pymdkit/commands/_gpumd.py +172 -0
  9. pymdkit-1.4.1/src/pymdkit/commands/_reduction.py +165 -0
  10. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/compute_ehull.py +4 -8
  11. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/compute_rmsd.py +7 -17
  12. pymdkit-1.4.1/src/pymdkit/commands/gpumd_thermo.py +131 -0
  13. pymdkit-1.4.1/src/pymdkit/commands/gpumd_trjcat.py +134 -0
  14. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/pca.py +1 -1
  15. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/substitute.py +7 -9
  16. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/symmetrize.py +4 -14
  17. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/umap.py +1 -1
  18. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp2xyz.py +9 -17
  19. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp_fe.py +3 -7
  20. {pymdkit-1.4.0 → pymdkit-1.4.1/src/pymdkit.egg-info}/PKG-INFO +13 -2
  21. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/SOURCES.txt +3 -0
  22. pymdkit-1.4.0/src/pymdkit/commands/_extxyz.py +0 -36
  23. pymdkit-1.4.0/src/pymdkit/commands/_reduction.py +0 -99
  24. pymdkit-1.4.0/src/pymdkit/commands/gpumd_thermo.py +0 -250
  25. {pymdkit-1.4.0 → pymdkit-1.4.1}/LICENSE +0 -0
  26. {pymdkit-1.4.0 → pymdkit-1.4.1}/setup.cfg +0 -0
  27. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/__init__.py +0 -0
  28. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/_vasp_output.py +0 -0
  29. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/_vaspset.py +0 -0
  30. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/chemsys_entry.py +0 -0
  31. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/compute_msd_all_groups.py +0 -0
  32. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/convert.py +0 -0
  33. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/ewald.py +0 -0
  34. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/gather_contcar.py +0 -0
  35. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/gpumd_group.py +0 -0
  36. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/nep_rmse.py +0 -0
  37. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/perturb.py +0 -0
  38. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/submit_vasp.py +0 -0
  39. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/supercell.py +0 -0
  40. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/trj_extract.py +0 -0
  41. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp_relax.py +0 -0
  42. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp_static.py +0 -0
  43. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/pymdkit_main.py +0 -0
  44. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  45. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/entry_points.txt +0 -0
  46. {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/requires.txt +0 -0
  47. {pymdkit-1.4.0 → pymdkit-1.4.1}/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.0
3
+ Version: 1.4.1
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
@@ -69,6 +69,7 @@ directory for job sub-folders automatically.
69
69
  | `vasp-fe` | List VASP job final energies from lowest to highest |
70
70
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
71
71
  | `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 |
72
73
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
73
74
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
74
75
  | `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
@@ -125,11 +126,13 @@ pmk ewald -if Li3YCl6-all
125
126
  pmk nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
126
127
  pmk nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
127
128
  pmk perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
128
- pmk pca -i descriptor.out -o pca-descriptor.txt
129
+ pmk pca -i descriptor.out -o pca-descriptor.txt -it train.xyz # also split descriptors by Config_type
129
130
  pmk pca -i descriptor.out -o pca-descriptor.txt -fps 0.01 -it train.xyz
130
131
  pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
131
132
  pmk gpumd-thermo # current job, or recursively scan job folders
132
133
  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
133
136
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
134
137
 
135
138
  pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -163,10 +166,14 @@ folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
163
166
 
164
167
  `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.
165
168
 
169
+ 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
+
166
171
  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`.
167
172
 
168
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.
169
174
 
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.
176
+
170
177
  Each command's full flag list is in `pmk <command> -help`.
171
178
 
172
179
  For long VASP batch submission, run `submit-vasp` with `nohup` and `&` if you
@@ -208,8 +215,12 @@ pymdkit/
208
215
  |-- pymdkit_main.py # dispatcher: discovers and runs commands
209
216
  `-- commands/ # one module per command
210
217
  |-- _fileio.py # shared -i/-o/-if/-of helper (not a command)
218
+ |-- _gpumd.py # shared GPUMD run.in and thermo analysis
219
+ |-- _geometry.py # shared minimum-image geometry helper
211
220
  |-- _vaspset.py # shared VASP input-set helper (not a command)
212
221
  |-- gpumd_group.py
222
+ |-- gpumd_thermo.py
223
+ |-- gpumd_trjcat.py
213
224
  |-- compute_ehull.py
214
225
  |-- compute_rmsd.py
215
226
  |-- ewald.py
@@ -43,6 +43,7 @@ directory for job sub-folders automatically.
43
43
  | `vasp-fe` | List VASP job final energies from lowest to highest |
44
44
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
45
45
  | `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 |
46
47
  | `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
47
48
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
48
49
  | `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
@@ -99,11 +100,13 @@ pmk ewald -if Li3YCl6-all
99
100
  pmk nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
100
101
  pmk nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
101
102
  pmk perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
102
- pmk pca -i descriptor.out -o pca-descriptor.txt
103
+ pmk pca -i descriptor.out -o pca-descriptor.txt -it train.xyz # also split descriptors by Config_type
103
104
  pmk pca -i descriptor.out -o pca-descriptor.txt -fps 0.01 -it train.xyz
104
105
  pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
105
106
  pmk gpumd-thermo # current job, or recursively scan job folders
106
107
  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
107
110
  pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
108
111
 
109
112
  pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -137,10 +140,14 @@ folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
137
140
 
138
141
  `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.
139
142
 
143
+ 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
+
140
145
  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`.
141
146
 
142
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.
143
148
 
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.
150
+
144
151
  Each command's full flag list is in `pmk <command> -help`.
145
152
 
146
153
  For long VASP batch submission, run `submit-vasp` with `nohup` and `&` if you
@@ -181,9 +188,13 @@ pymdkit/
181
188
  `-- src/pymdkit/
182
189
  |-- pymdkit_main.py # dispatcher: discovers and runs commands
183
190
  `-- commands/ # one module per command
184
- |-- _fileio.py # shared -i/-o/-if/-of helper (not a command)
191
+ |-- _fileio.py # shared -i/-o/-if/-of helper (not a command)
192
+ |-- _gpumd.py # shared GPUMD run.in and thermo analysis
193
+ |-- _geometry.py # shared minimum-image geometry helper
185
194
  |-- _vaspset.py # shared VASP input-set helper (not a command)
186
- |-- gpumd_group.py
195
+ |-- gpumd_group.py
196
+ |-- gpumd_thermo.py
197
+ |-- gpumd_trjcat.py
187
198
  |-- compute_ehull.py
188
199
  |-- compute_rmsd.py
189
200
  |-- ewald.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.4.0"
7
+ version = "1.4.1"
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.0"
3
+ __version__ = "1.4.1"
@@ -0,0 +1,85 @@
1
+ """Exact, byte-preserving helpers for extended-XYZ trajectories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ _CONFIG_TYPE_RE = re.compile(r'(?<!\S)Config_type=(?:"([^"]*)"|(\S+))')
9
+
10
+
11
+ def read_blocks(path):
12
+ """Return ``(blocks, atom_counts)`` without parsing or reformatting data."""
13
+ path = Path(path)
14
+ lines = path.read_bytes().splitlines(keepends=True)
15
+ blocks = []
16
+ atom_counts = []
17
+ index = 0
18
+ while index < len(lines):
19
+ if not lines[index].strip():
20
+ index += 1
21
+ continue
22
+ try:
23
+ count = int(lines[index].strip())
24
+ except ValueError as exc:
25
+ raise ValueError(
26
+ f"{path}: expected an atom count at line {index + 1}"
27
+ ) from exc
28
+ stop = index + count + 2
29
+ if stop > len(lines):
30
+ raise ValueError(f"{path}: incomplete frame beginning at line {index + 1}")
31
+ blocks.append(b"".join(lines[index:stop]))
32
+ atom_counts.append(count)
33
+ index = stop
34
+ return blocks, atom_counts
35
+
36
+
37
+ def write_blocks(path, blocks, indices):
38
+ """Write selected frames exactly as they appeared in the source file."""
39
+ Path(path).write_bytes(b"".join(blocks[int(index)] for index in indices))
40
+
41
+
42
+ def _comment_line(block):
43
+ lines = block.splitlines(keepends=True)
44
+ if len(lines) < 2:
45
+ raise ValueError("incomplete XYZ frame: missing comment line")
46
+ raw = lines[1]
47
+ if raw.endswith(b"\r\n"):
48
+ body, newline = raw[:-2], b"\r\n"
49
+ elif raw.endswith(b"\n") or raw.endswith(b"\r"):
50
+ body, newline = raw[:-1], raw[-1:]
51
+ else:
52
+ body, newline = raw, b""
53
+ return lines, body.decode("utf-8"), newline
54
+
55
+
56
+ def config_type_from_block(block):
57
+ """Return Config_type from one raw XYZ frame, if present."""
58
+ _, comment, _ = _comment_line(block)
59
+ match = _CONFIG_TYPE_RE.search(comment)
60
+ if not match:
61
+ return None
62
+ return match.group(1) if match.group(1) is not None else match.group(2)
63
+
64
+
65
+ def read_config_type(path):
66
+ """Read Config_type from the first frame of an XYZ file, if present."""
67
+ blocks, _ = read_blocks(path)
68
+ return config_type_from_block(blocks[0]) if blocks else None
69
+
70
+
71
+ def set_config_type(block, value):
72
+ """Set Config_type while preserving all other bytes in an XYZ frame."""
73
+ lines, comment, newline = _comment_line(block)
74
+ value = str(value)
75
+ if re.fullmatch(r'[^\s"=]+', value):
76
+ encoded_value = value
77
+ else:
78
+ encoded_value = '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
79
+ assignment = f"Config_type={encoded_value}"
80
+ if _CONFIG_TYPE_RE.search(comment):
81
+ comment = _CONFIG_TYPE_RE.sub(assignment, comment, count=1)
82
+ else:
83
+ comment += (" " if comment else "") + assignment
84
+ lines[1] = comment.encode("utf-8") + newline
85
+ return b"".join(lines)
@@ -13,8 +13,15 @@ from pathlib import Path
13
13
  # File extensions we treat as structures when scanning an input folder.
14
14
  STRUCT_EXTS = {
15
15
  ".cif", ".vasp", ".poscar", ".xyz", ".extxyz", ".pdb", ".xsf",
16
- ".cfg", ".gen", ".res", ".json", ".cssr", ".lmp", ".data",
16
+ ".cfg", ".gen", ".res", ".json", ".cssr", ".lmp", ".data", ".contcar",
17
17
  }
18
+ STRUCT_NAMES = {"POSCAR", "CONTCAR"}
19
+
20
+
21
+ def leading_index(name):
22
+ """Return a leading integer, placing nonnumeric names after numbered ones."""
23
+ match = re.match(r"^(\d+)", str(name))
24
+ return int(match.group(1)) if match else 10 ** 9
18
25
 
19
26
 
20
27
  def folder_sort_key(name):
@@ -54,8 +61,12 @@ def is_structure(path, exts=None):
54
61
  """True if *path* looks like a structure file (by extension / POSCAR name)."""
55
62
  exts = STRUCT_EXTS if exts is None else exts
56
63
  p = Path(path)
57
- return p.is_file() and (p.suffix.lower() in exts
58
- or p.name.upper().startswith("POSCAR"))
64
+ upper_name = p.name.upper()
65
+ return p.is_file() and (
66
+ p.suffix.lower() in exts
67
+ or upper_name in STRUCT_NAMES
68
+ or upper_name.startswith("POSCAR")
69
+ )
59
70
 
60
71
 
61
72
  def structure_files(folder):
@@ -63,11 +74,7 @@ def structure_files(folder):
63
74
  folder = Path(folder)
64
75
  if not folder.is_dir():
65
76
  raise SystemExit(f"Error: input folder not found: {folder}")
66
- files = sorted(
67
- p for p in folder.iterdir()
68
- if p.is_file() and (p.suffix.lower() in STRUCT_EXTS
69
- or p.name.upper().startswith("POSCAR"))
70
- )
77
+ files = sorted(p for p in folder.iterdir() if is_structure(p))
71
78
  if not files:
72
79
  raise SystemExit(f"Error: no structure files found in {folder}")
73
80
  return files
@@ -0,0 +1,12 @@
1
+ """Small geometry helpers shared by structure commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def fractional_distance(a, b):
7
+ """Return minimum-image Euclidean distance in fractional coordinates."""
8
+ import numpy as np
9
+
10
+ difference = np.asarray(a, dtype=float) - np.asarray(b, dtype=float)
11
+ difference -= np.round(difference)
12
+ return float(np.linalg.norm(difference))
@@ -0,0 +1,172 @@
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
+ warnings: tuple[str, ...]
22
+
23
+
24
+ def parse_run_input(path):
25
+ time_step = 1.0
26
+ dump_interval = 10
27
+ runs = []
28
+ for raw in Path(path).read_text(encoding="utf-8").splitlines():
29
+ tokens = raw.split("#", 1)[0].split()
30
+ if not tokens:
31
+ continue
32
+ try:
33
+ if tokens[0] == "time_step" and len(tokens) >= 2:
34
+ time_step = float(tokens[1])
35
+ elif tokens[0] == "dump_thermo" and len(tokens) >= 2:
36
+ dump_interval = int(tokens[1])
37
+ elif tokens[0] == "run" and len(tokens) >= 2:
38
+ runs.append((int(tokens[1]), dump_interval, time_step))
39
+ except ValueError as exc:
40
+ raise ValueError(f"invalid {tokens[0]} setting in {path}: {raw}") from exc
41
+ return time_step, dump_interval, runs
42
+
43
+
44
+ def time_axis(length, time_step, dump_interval, runs):
45
+ expected = sum(steps // interval for steps, interval, _ in runs)
46
+ if runs and expected == length:
47
+ values = []
48
+ elapsed = 0.0
49
+ for steps, interval, step_fs in runs:
50
+ count = steps // interval
51
+ increment = step_fs * interval / 1000.0
52
+ values.extend(elapsed + np.arange(count) * increment)
53
+ elapsed += steps * step_fs / 1000.0
54
+ return np.asarray(values, dtype=float)
55
+ increment = time_step * dump_interval / 1000.0
56
+ return np.arange(length, dtype=float) * increment
57
+
58
+
59
+ def equilibrium_start(length, runs):
60
+ if not runs:
61
+ return int(length * 0.2)
62
+ points = runs[-1][0] // runs[-1][1]
63
+ return max(0, length - points)
64
+
65
+
66
+ def cell_quantities(data):
67
+ if data.shape[1] == 12:
68
+ lengths = data[:, 9:12]
69
+ angles = np.full((len(data), 3), 90.0)
70
+ return lengths, angles, np.prod(lengths, axis=1)
71
+ if data.shape[1] != 18:
72
+ raise ValueError(
73
+ f"thermo.out has {data.shape[1]} columns; expected 12 or 18"
74
+ )
75
+
76
+ vectors = data[:, 9:18].reshape(-1, 3, 3)
77
+ lengths = np.linalg.norm(vectors, axis=2)
78
+
79
+ def angle(first, second):
80
+ denominator = np.linalg.norm(first, axis=1) * np.linalg.norm(second, axis=1)
81
+ cosine = np.divide(
82
+ np.einsum("ij,ij->i", first, second), denominator,
83
+ out=np.zeros(len(first)), where=denominator != 0,
84
+ )
85
+ return np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
86
+
87
+ angles = np.column_stack([
88
+ angle(vectors[:, 1], vectors[:, 2]),
89
+ angle(vectors[:, 2], vectors[:, 0]),
90
+ angle(vectors[:, 0], vectors[:, 1]),
91
+ ])
92
+ return lengths, angles, np.abs(np.linalg.det(vectors))
93
+
94
+
95
+ def abnormal_reason(values):
96
+ values = np.asarray(values, dtype=float)
97
+ values = values[np.isfinite(values)]
98
+ if len(values) < 20 or np.ptp(values) == 0:
99
+ return None
100
+
101
+ count = max(3, len(values) // 5)
102
+ first = float(np.mean(values[:count]))
103
+ last = float(np.mean(values[-count:]))
104
+ spread = float(np.std(values))
105
+ scale = max(abs(float(np.median(values))), 1.0)
106
+ threshold = max(0.5 * spread, 0.005 * scale)
107
+
108
+ shift = last - first
109
+ if abs(shift) > threshold:
110
+ return f"level shift {shift:+.5g}"
111
+
112
+ x = np.linspace(-0.5, 0.5, len(values))
113
+ slope = float(np.polyfit(x, values, 1)[0])
114
+ if abs(slope) > threshold:
115
+ return f"sustained drift {slope:+.5g} over the equilibrium segment"
116
+ return None
117
+
118
+
119
+ def find_abnormal(series, start):
120
+ component_names = {
121
+ "pressure": ("x", "y", "z"),
122
+ "lattice-parameter": ("a", "b", "c"),
123
+ "lattice-angle": ("alpha", "beta", "gamma"),
124
+ }
125
+ warnings = []
126
+ for name, values in series.items():
127
+ array = np.asarray(values)
128
+ if array.ndim == 1:
129
+ array = array[:, None]
130
+ labels = component_names.get(name, tuple(str(i) for i in range(array.shape[1])))
131
+ if array.shape[1] == 1:
132
+ labels = (name,)
133
+ for label, column in zip(labels, array.T):
134
+ reason = abnormal_reason(column[start:])
135
+ if reason:
136
+ prefix = name if label == name else f"{name}-{label}"
137
+ warnings.append(f"{prefix}: {reason}")
138
+ return tuple(warnings)
139
+
140
+
141
+ def load_thermo(thermo_path, run_path):
142
+ thermo_path = Path(thermo_path)
143
+ data = np.loadtxt(thermo_path, ndmin=2)
144
+ if data.shape[1] not in (12, 18):
145
+ raise ValueError(f"{thermo_path} must contain 12 or 18 columns")
146
+
147
+ time_step, dump_interval, runs = parse_run_input(run_path)
148
+ time = time_axis(len(data), time_step, dump_interval, runs)
149
+ temperature = data[:, 0]
150
+ potential = data[:, 2]
151
+ pressure = data[:, 3:6]
152
+ lengths, angles, volume = cell_quantities(data)
153
+ start = equilibrium_start(len(data), runs)
154
+ warnings = find_abnormal({
155
+ "temperature": temperature,
156
+ "potential-energy": potential,
157
+ "pressure": pressure,
158
+ "lattice-parameter": lengths,
159
+ "volume": volume,
160
+ "lattice-angle": angles,
161
+ }, start)
162
+ return ThermoData(
163
+ time=time,
164
+ temperature=temperature,
165
+ potential=potential,
166
+ pressure=pressure,
167
+ lengths=lengths,
168
+ angles=angles,
169
+ volume=volume,
170
+ equilibrium_start=start,
171
+ warnings=warnings,
172
+ )
@@ -0,0 +1,165 @@
1
+ """Shared descriptor reduction and farthest-point sampling helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ try:
11
+ from ._extxyz import config_type_from_block, read_blocks, write_blocks
12
+ except ImportError:
13
+ from _extxyz import config_type_from_block, read_blocks, write_blocks
14
+
15
+
16
+ def load_descriptors(path):
17
+ data = np.loadtxt(path, ndmin=2)
18
+ if data.shape[0] == 0 or data.shape[1] == 0:
19
+ raise ValueError(f"descriptor file is empty: {path}")
20
+ if not np.all(np.isfinite(data)):
21
+ raise ValueError(f"descriptor file contains non-finite values: {path}")
22
+ return data
23
+
24
+
25
+ def pca_2d(data):
26
+ centered = data - np.mean(data, axis=0, keepdims=True)
27
+ if len(data) == 1:
28
+ return np.zeros((1, 2)), np.array([0.0, 0.0])
29
+ _, singular, vh = np.linalg.svd(centered, full_matrices=False)
30
+ dimensions = min(2, vh.shape[0])
31
+ reduced = centered @ vh[:dimensions].T
32
+ if dimensions < 2:
33
+ reduced = np.column_stack([reduced, np.zeros(len(data))])
34
+ variance = singular ** 2
35
+ ratio = variance / variance.sum() if variance.sum() else np.zeros_like(variance)
36
+ explained = np.zeros(2)
37
+ explained[:min(2, len(ratio))] = ratio[:2]
38
+ return reduced, explained
39
+
40
+
41
+ def umap_2d(data):
42
+ if len(data) < 3:
43
+ return pca_2d(data)[0]
44
+ import umap
45
+
46
+ neighbors = min(15, len(data) - 1)
47
+ reducer = umap.UMAP(
48
+ n_components=2,
49
+ n_neighbors=neighbors,
50
+ random_state=42,
51
+ transform_seed=42,
52
+ )
53
+ return reducer.fit_transform(data)
54
+
55
+
56
+ def farthest_point_sampling(points, min_distance):
57
+ if min_distance < 0:
58
+ raise ValueError("-fps must be non-negative")
59
+ selected = [0]
60
+ min_distances = np.linalg.norm(points - points[0], axis=1)
61
+ min_distances[0] = -np.inf
62
+ while True:
63
+ index = int(np.argmax(min_distances))
64
+ if min_distances[index] < min_distance:
65
+ break
66
+ selected.append(index)
67
+ distances = np.linalg.norm(points - points[index], axis=1)
68
+ min_distances = np.minimum(min_distances, distances)
69
+ min_distances[selected] = -np.inf
70
+ return np.asarray(selected, dtype=int)
71
+
72
+
73
+ def fps_label(value):
74
+ return format(float(value), "g")
75
+
76
+
77
+ def _filename_label(config_type):
78
+ label = re.sub(r"[^A-Za-z0-9_.+-]+", "-", str(config_type)).strip("-.")
79
+ return label or "unknown"
80
+
81
+
82
+ def _labeled_path(path, config_type):
83
+ path = Path(path)
84
+ return path.with_name(f"{path.stem}-{_filename_label(config_type)}{path.suffix}")
85
+
86
+
87
+ def _config_groups(blocks):
88
+ groups = {}
89
+ labels = {}
90
+ for index, block in enumerate(blocks):
91
+ config_type = config_type_from_block(block)
92
+ if config_type is None:
93
+ continue
94
+ label = _filename_label(config_type)
95
+ if label in labels and labels[label] != config_type:
96
+ raise ValueError(
97
+ f"Config_type values {labels[label]!r} and {config_type!r} "
98
+ f"map to the same filename label {label!r}"
99
+ )
100
+ labels[label] = config_type
101
+ groups.setdefault(config_type, []).append(index)
102
+ return {name: np.asarray(indices, dtype=int) for name, indices in groups.items()}
103
+
104
+
105
+ def _write_config_outputs(descriptor_path, reduced, groups, selected=None,
106
+ trajectory_path=None, blocks=None):
107
+ for config_type, group_indices in groups.items():
108
+ indices = group_indices
109
+ if selected is not None:
110
+ indices = np.intersect1d(selected, group_indices, assume_unique=True)
111
+ config_descriptor = _labeled_path(descriptor_path, config_type)
112
+ np.savetxt(config_descriptor, reduced[indices], fmt="%.8f")
113
+ outputs = [str(config_descriptor)]
114
+ if trajectory_path is not None:
115
+ config_trajectory = _labeled_path(trajectory_path, config_type)
116
+ write_blocks(config_trajectory, blocks, indices)
117
+ outputs.append(str(config_trajectory))
118
+ print(f"Config_type={config_type}: {len(indices)} row(s) -> "
119
+ + " and ".join(outputs))
120
+
121
+
122
+ def write_reduction(output, reduced, fps=None, trajectory=None):
123
+ if fps is not None and trajectory is None:
124
+ raise ValueError("-it is required when -fps is used")
125
+
126
+ output = Path(output)
127
+ output.parent.mkdir(parents=True, exist_ok=True)
128
+ np.savetxt(output, reduced, fmt="%.8f")
129
+ print(f"Wrote {len(reduced)} reduced descriptor(s) to {output}")
130
+
131
+ blocks = None
132
+ groups = {}
133
+ if trajectory is not None:
134
+ blocks, _ = read_blocks(trajectory)
135
+ if len(blocks) != len(reduced):
136
+ raise ValueError(
137
+ f"{trajectory} has {len(blocks)} frame(s), but the descriptor file "
138
+ f"has {len(reduced)} row(s)"
139
+ )
140
+ groups = _config_groups(blocks)
141
+ _write_config_outputs(output, reduced, groups)
142
+ untyped = len(blocks) - sum(len(indices) for indices in groups.values())
143
+ if untyped:
144
+ print(f"{untyped} frame(s) have no Config_type and were not split by type.")
145
+
146
+ if fps is None:
147
+ return
148
+
149
+ selected = np.sort(farthest_point_sampling(reduced, fps))
150
+ label = fps_label(fps)
151
+ descriptor_output = output.with_name(f"fps-{label}-{output.name}")
152
+ trajectory = Path(trajectory)
153
+ trajectory_output = trajectory.with_name(f"fps-{label}-{trajectory.name}")
154
+ np.savetxt(descriptor_output, reduced[selected], fmt="%.8f")
155
+ write_blocks(trajectory_output, blocks, selected)
156
+ print(f"FPS distance {label}: selected {len(selected)} of {len(reduced)} structure(s)")
157
+ print(f"Wrote {descriptor_output} and {trajectory_output}")
158
+ _write_config_outputs(
159
+ descriptor_output,
160
+ reduced,
161
+ groups,
162
+ selected=selected,
163
+ trajectory_path=trajectory_output,
164
+ blocks=blocks,
165
+ )
@@ -4,15 +4,15 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import math
7
- import re
8
7
  import warnings
9
8
  from collections import defaultdict
10
9
  from pathlib import Path
11
10
 
12
11
 
13
12
  try:
14
- from . import _vasp_output
13
+ from . import _fileio, _vasp_output
15
14
  except ImportError: # running as a standalone script
15
+ import _fileio
16
16
  import _vasp_output
17
17
 
18
18
  warnings.filterwarnings("ignore", category=UserWarning)
@@ -22,10 +22,6 @@ HELP = "Compute E_hull for every VASP job folder."
22
22
  REPORT_FILE = "ehull.txt"
23
23
 
24
24
 
25
- def folder_index(name: str) -> int:
26
- m = re.match(r"^(\d+)", name)
27
- return int(m.group(1)) if m else 10 ** 9
28
-
29
25
 
30
26
  def chemsys_label(entry) -> str:
31
27
  def keyfn(el):
@@ -98,7 +94,7 @@ def collect_entries(root):
98
94
  root = Path(root)
99
95
  folders = sorted(
100
96
  (f for f in root.iterdir() if f.is_dir() and _vasp_output.has_vasp_output(f)),
101
- key=lambda f: folder_index(f.name),
97
+ key=lambda f: _fileio.leading_index(f.name),
102
98
  )
103
99
  out = []
104
100
  for folder in folders:
@@ -208,7 +204,7 @@ def run(args):
208
204
  "e_hull": e_hull,
209
205
  })
210
206
 
211
- results.sort(key=lambda r: folder_index(r["folder"]))
207
+ results.sort(key=lambda r: _fileio.leading_index(r["folder"]))
212
208
  write_report(results, root, REPORT_FILE, reference)
213
209
  print(f"\nWrote {REPORT_FILE} ({len(results)} structures).")
214
210
  return 0