pymdkit 1.5.1__tar.gz → 1.5.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 (51) hide show
  1. {pymdkit-1.5.1 → pymdkit-1.5.2}/PKG-INFO +39 -4
  2. {pymdkit-1.5.1 → pymdkit-1.5.2}/README.md +41 -6
  3. {pymdkit-1.5.1 → pymdkit-1.5.2}/pyproject.toml +1 -1
  4. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/__init__.py +1 -1
  5. pymdkit-1.5.2/src/pymdkit/commands/gpumd_msd.py +461 -0
  6. pymdkit-1.5.2/src/pymdkit/commands/realloc_occ.py +452 -0
  7. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit.egg-info/PKG-INFO +39 -4
  8. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit.egg-info/SOURCES.txt +2 -1
  9. pymdkit-1.5.1/src/pymdkit/commands/compute_msd_all_groups.py +0 -375
  10. {pymdkit-1.5.1 → pymdkit-1.5.2}/LICENSE +0 -0
  11. {pymdkit-1.5.1 → pymdkit-1.5.2}/setup.cfg +0 -0
  12. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/__init__.py +0 -0
  13. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_cifio.py +0 -0
  14. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_extxyz.py +0 -0
  15. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_fileio.py +0 -0
  16. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_geometry.py +0 -0
  17. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_gpumd.py +0 -0
  18. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_precision.py +0 -0
  19. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_reduction.py +0 -0
  20. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_vasp_output.py +0 -0
  21. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/_vaspset.py +0 -0
  22. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/add_config_type.py +0 -0
  23. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/chemsys_entry.py +0 -0
  24. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/compute_ehull.py +0 -0
  25. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/compute_rmsd.py +0 -0
  26. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/convert.py +0 -0
  27. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/ewald.py +0 -0
  28. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/gather_fs.py +0 -0
  29. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/gpumd_group.py +0 -0
  30. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/gpumd_relax.py +0 -0
  31. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/gpumd_thermo.py +0 -0
  32. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
  33. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/nep_rmse.py +0 -0
  34. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/pca.py +0 -0
  35. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/perturb.py +0 -0
  36. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/submit_vasp.py +0 -0
  37. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/substitute.py +0 -0
  38. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/supercell.py +0 -0
  39. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/symmetrize.py +0 -0
  40. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/trj_extract.py +0 -0
  41. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/trj_sparse.py +0 -0
  42. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/umap.py +0 -0
  43. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/vasp2xyz.py +0 -0
  44. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/vasp_fe.py +0 -0
  45. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/vasp_relax.py +0 -0
  46. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/commands/vasp_static.py +0 -0
  47. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit/pymdkit_main.py +0 -0
  48. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  49. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit.egg-info/entry_points.txt +0 -0
  50. {pymdkit-1.5.1 → pymdkit-1.5.2}/src/pymdkit.egg-info/requires.txt +0 -0
  51. {pymdkit-1.5.1 → pymdkit-1.5.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.5.1
3
+ Version: 1.5.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
@@ -84,7 +84,7 @@ directory for job sub-folders automatically.
84
84
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
85
85
  | `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
86
86
  | `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
87
- | `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
87
+ | `gpumd-msd` | Recursively extract directional SDC and Nernst-Einstein conductivity from GPUMD jobs |
88
88
  | `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
89
89
  | `perturb` | Generate perturbed structures with dpdata |
90
90
  | `vasp2xyz` | Collect SCF-converged VASP job folders (any name) into one extxyz file |
@@ -92,6 +92,7 @@ directory for job sub-folders automatically.
92
92
  | `rmsd` | Compare structures by RMSD and optionally remove higher-energy duplicates |
93
93
  | `chemsys-entry` | Download stable Materials Project structures for a chemical system |
94
94
  | `convert` | Convert structures, trajectories, or folders between ASE/pymatgen formats |
95
+ | `realloc-occ` | Generate charge-balanced CIF occupancy allocations for a target formula |
95
96
  | `substitute` | Randomly substitute or remove selected atoms/sites from a structure |
96
97
  | `supercell` | Build a supercell with cell lengths capped at a maximum (Angstrom); optional per-temperature GPUMD setup |
97
98
  | `symmetrize` | Detect/refine symmetry for structure files, folders, or CSV rows -> CIF |
@@ -119,8 +120,8 @@ pmk gpumd-relax -if example -nep nep89_20250409.txt # model.xyz + run
119
120
  pmk gpumd-relax -it train.xyz -nep nep.txt -in run.in # one ./frame_N/ GPUMD job per frame
120
121
  pmk vasp-static -if cifs/ -custom-setting my_incar.txt # static inputs, custom INCAR
121
122
  pmk vasp-static -it traj.xyz # one ./frame_N/ job per trajectory frame
122
- pmk msd # scans <structure>/<temp>/ -> per-job msd/ + msd_summary.txt
123
- pmk msd -diffuse_ion Li -ion_charge 1 # choose the mobile ion for conductivity
123
+ pmk gpumd-msd # recursively scans GPUMD jobs -> per-job msd/ + sigma-sdc-summary.txt
124
+ pmk gpumd-msd -diffuse_ion Li -ion_charge 1 # choose the mobile ion and signed valence
124
125
  pmk chemsys-entry -s Li La Ta Cl # MP stable entries -> Li-La-Ta-Cl-stable-entries/
125
126
  pmk ehull -mp-api-key $MP_API_KEY # scans ./ for VASP jobs -> ehull.txt
126
127
  pmk ehull -local Li-La-Ta-Cl-stable-entries-opted
@@ -132,6 +133,8 @@ pmk vasp2xyz # scans ./ for VASP output folde
132
133
  pmk vasp2xyz -position-only # write positions only, without energy/forces/stress
133
134
  pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
134
135
  nohup pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30 > submit-vasp.log 2>&1 &
136
+ pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6
137
+ pmk realloc-occ -if cifs -of chg-balance -formula Li3YCl6
135
138
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we Na -wn 3 -on 100
136
139
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we none -on 100
137
140
  pmk substitute -i Li96Ta6La11Cl72.cif -se Li1 Li2 -sn 20 67 -we none -on 100
@@ -200,6 +203,36 @@ dump_xyz -1 0 1 relaxed.xyz force
200
203
  run 1
201
204
  ```
202
205
 
206
+ `gpumd-msd` recursively discovers job folders at any depth below `-entry-path`
207
+ (the current directory by default). A job must contain `msd.out`, `model.xyz`,
208
+ `run.in`, and `thermo.out`; the common accidental name `thermos.out` is also
209
+ accepted. Following the [official GPUMD `msd.out` format](https://gpumd.org/gpumd/output_files/msd_out.html), each group block is read as three MSD columns
210
+ followed by three directional SDC columns. PMK takes the arithmetic mean over
211
+ the last 50% of the SDC correlation-time range, converts A^2/ps to cm^2/s by
212
+ multiplying by `1e-4`, and reports `D_total = (D_x + D_y + D_z) / 3`.
213
+ Each job receives `msd/group_INDEX_ELEMENT_TEMP_msd.txt`, a matching `_sdc.txt`,
214
+ and a `_results.txt`. The root receives `sigma-sdc-summary.txt` with directional
215
+ and total diffusion coefficients and conductivities.
216
+
217
+ Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
218
+ of the final SDC-producing MD run and the official 18-column
219
+ [`thermo.out` format](https://gpumd.org/gpumd/output_files/thermo_out.html):
220
+ for NPT ensembles (`npt_scr`, `npt_mttk`, and other `npt_*` forms), PMK uses
221
+ the mean triclinic-box volume over that run; for NVT ensembles (`nvt_bdp`,
222
+ `nvt_ber`, and other `nvt_*` forms), it uses the final fixed-cell volume.
223
+ `V(A^3) * 1e-24 -> V(cm^3)`, exact SI values for elementary charge and the
224
+ Boltzmann constant, and a final `* 1000` conversion from S/cm to mS/cm.
225
+
226
+ `realloc-occ -i INPUT.cif -of OUTPUT -formula FORMULA` computes atom counts as
227
+ Wyckoff multiplicity times occupancy and uses `_cell_formula_units_Z` to map the
228
+ target reduced formula into the existing cell. It enumerates finite site-wise
229
+ occupancy allocations, retains only charge-balanced candidates using the CIF
230
+ oxidation-number loop, and preserves the original symmetry, coordinates,
231
+ Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
232
+ the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
233
+ and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
234
+ `-formula` options.
235
+
203
236
  `gather-fs` recursively scans all job folders below the current directory. For
204
237
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
205
238
  requires full convergence before copying the selected `-fs-name` as `.vasp`. The gathered VASP file's first line is replaced with `energy=<final_energy> eV` for later screening.
@@ -346,6 +379,7 @@ pymdkit/
346
379
  |-- _geometry.py # shared minimum-image geometry helper
347
380
  |-- _vaspset.py # shared VASP input-set helper (not a command)
348
381
  |-- gpumd_group.py
382
+ |-- gpumd_msd.py
349
383
  |-- gpumd_relax.py
350
384
  |-- gpumd_thermo.py
351
385
  |-- gpumd_trjcat.py
@@ -360,6 +394,7 @@ pymdkit/
360
394
  |-- chemsys_entry.py
361
395
  |-- submit_vasp.py
362
396
  |-- convert.py
397
+ |-- realloc_occ.py
363
398
  |-- substitute.py
364
399
  |-- supercell.py
365
400
  |-- vasp2xyz.py
@@ -57,14 +57,15 @@ directory for job sub-folders automatically.
57
57
  | `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
58
58
  | `trj-sparse` | Deterministically retain a requested fraction of XYZ trajectory frames |
59
59
  | `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
60
- | `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
60
+ | `gpumd-msd` | Recursively extract directional SDC and Nernst-Einstein conductivity from GPUMD jobs |
61
61
  | `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
62
62
  | `perturb` | Generate perturbed structures with dpdata |
63
63
  | `vasp2xyz` | Collect SCF-converged VASP job folders (any name) into one extxyz file |
64
64
  | `submit-vasp` | Submit/resubmit VASP job folders while limiting active queue jobs |
65
65
  | `rmsd` | Compare structures by RMSD and optionally remove higher-energy duplicates |
66
66
  | `chemsys-entry` | Download stable Materials Project structures for a chemical system |
67
- | `convert` | Convert structures, trajectories, or folders between ASE/pymatgen formats |
67
+ | `convert` | Convert structures, trajectories, or folders between ASE/pymatgen formats |
68
+ | `realloc-occ` | Generate charge-balanced CIF occupancy allocations for a target formula |
68
69
  | `substitute` | Randomly substitute or remove selected atoms/sites from a structure |
69
70
  | `supercell` | Build a supercell with cell lengths capped at a maximum (Angstrom); optional per-temperature GPUMD setup |
70
71
  | `symmetrize` | Detect/refine symmetry for structure files, folders, or CSV rows -> CIF |
@@ -92,8 +93,8 @@ pmk gpumd-relax -if example -nep nep89_20250409.txt # model.xyz + run
92
93
  pmk gpumd-relax -it train.xyz -nep nep.txt -in run.in # one ./frame_N/ GPUMD job per frame
93
94
  pmk vasp-static -if cifs/ -custom-setting my_incar.txt # static inputs, custom INCAR
94
95
  pmk vasp-static -it traj.xyz # one ./frame_N/ job per trajectory frame
95
- pmk msd # scans <structure>/<temp>/ -> per-job msd/ + msd_summary.txt
96
- pmk msd -diffuse_ion Li -ion_charge 1 # choose the mobile ion for conductivity
96
+ pmk gpumd-msd # recursively scans GPUMD jobs -> per-job msd/ + sigma-sdc-summary.txt
97
+ pmk gpumd-msd -diffuse_ion Li -ion_charge 1 # choose the mobile ion and signed valence
97
98
  pmk chemsys-entry -s Li La Ta Cl # MP stable entries -> Li-La-Ta-Cl-stable-entries/
98
99
  pmk ehull -mp-api-key $MP_API_KEY # scans ./ for VASP jobs -> ehull.txt
99
100
  pmk ehull -local Li-La-Ta-Cl-stable-entries-opted
@@ -105,6 +106,8 @@ pmk vasp2xyz # scans ./ for VASP output folde
105
106
  pmk vasp2xyz -position-only # write positions only, without energy/forces/stress
106
107
  pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
107
108
  nohup pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30 > submit-vasp.log 2>&1 &
109
+ pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6
110
+ pmk realloc-occ -if cifs -of chg-balance -formula Li3YCl6
108
111
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we Na -wn 3 -on 100
109
112
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we none -on 100
110
113
  pmk substitute -i Li96Ta6La11Cl72.cif -se Li1 Li2 -sn 20 67 -we none -on 100
@@ -173,6 +176,36 @@ dump_xyz -1 0 1 relaxed.xyz force
173
176
  run 1
174
177
  ```
175
178
 
179
+ `gpumd-msd` recursively discovers job folders at any depth below `-entry-path`
180
+ (the current directory by default). A job must contain `msd.out`, `model.xyz`,
181
+ `run.in`, and `thermo.out`; the common accidental name `thermos.out` is also
182
+ accepted. Following the [official GPUMD `msd.out` format](https://gpumd.org/gpumd/output_files/msd_out.html), each group block is read as three MSD columns
183
+ followed by three directional SDC columns. PMK takes the arithmetic mean over
184
+ the last 50% of the SDC correlation-time range, converts A^2/ps to cm^2/s by
185
+ multiplying by `1e-4`, and reports `D_total = (D_x + D_y + D_z) / 3`.
186
+ Each job receives `msd/group_INDEX_ELEMENT_TEMP_msd.txt`, a matching `_sdc.txt`,
187
+ and a `_results.txt`. The root receives `sigma-sdc-summary.txt` with directional
188
+ and total diffusion coefficients and conductivities.
189
+
190
+ Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
191
+ of the final SDC-producing MD run and the official 18-column
192
+ [`thermo.out` format](https://gpumd.org/gpumd/output_files/thermo_out.html):
193
+ for NPT ensembles (`npt_scr`, `npt_mttk`, and other `npt_*` forms), PMK uses
194
+ the mean triclinic-box volume over that run; for NVT ensembles (`nvt_bdp`,
195
+ `nvt_ber`, and other `nvt_*` forms), it uses the final fixed-cell volume.
196
+ `V(A^3) * 1e-24 -> V(cm^3)`, exact SI values for elementary charge and the
197
+ Boltzmann constant, and a final `* 1000` conversion from S/cm to mS/cm.
198
+
199
+ `realloc-occ -i INPUT.cif -of OUTPUT -formula FORMULA` computes atom counts as
200
+ Wyckoff multiplicity times occupancy and uses `_cell_formula_units_Z` to map the
201
+ target reduced formula into the existing cell. It enumerates finite site-wise
202
+ occupancy allocations, retains only charge-balanced candidates using the CIF
203
+ oxidation-number loop, and preserves the original symmetry, coordinates,
204
+ Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
205
+ the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
206
+ and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
207
+ `-formula` options.
208
+
176
209
  `gather-fs` recursively scans all job folders below the current directory. For
177
210
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
178
211
  requires full convergence before copying the selected `-fs-name` as `.vasp`. The gathered VASP file's first line is replaced with `energy=<final_energy> eV` for later screening.
@@ -318,7 +351,8 @@ pymdkit/
318
351
  |-- _gpumd.py # shared GPUMD run.in and thermo analysis
319
352
  |-- _geometry.py # shared minimum-image geometry helper
320
353
  |-- _vaspset.py # shared VASP input-set helper (not a command)
321
- |-- gpumd_group.py
354
+ |-- gpumd_group.py
355
+ |-- gpumd_msd.py
322
356
  |-- gpumd_relax.py
323
357
  |-- gpumd_thermo.py
324
358
  |-- gpumd_trjcat.py
@@ -332,7 +366,8 @@ pymdkit/
332
366
  |-- _cifio.py
333
367
  |-- chemsys_entry.py
334
368
  |-- submit_vasp.py
335
- |-- convert.py
369
+ |-- convert.py
370
+ |-- realloc_occ.py
336
371
  |-- substitute.py
337
372
  |-- supercell.py
338
373
  |-- vasp2xyz.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.5.1"
7
+ version = "1.5.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"
@@ -1,3 +1,3 @@
1
1
  """pymdkit -- a unified CLI for atomistic / MD structure workflows."""
2
2
 
3
- __version__ = "1.5.1"
3
+ __version__ = "1.5.2"
@@ -0,0 +1,461 @@
1
+ """Extract directional self-diffusion and conductivity from GPUMD MSD jobs.
2
+
3
+ ``pmk gpumd-msd`` recursively scans for job folders containing ``msd.out``,
4
+ ``thermo.out`` (or ``thermos.out``), ``model.xyz``, and ``run.in``. GPUMD
5
+ already writes directional self-diffusion coefficients (SDC) in columns 5-7
6
+ of each six-column group block. Reported diffusion coefficients are arithmetic
7
+ means over the last 50% of the correlation-time range.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import numpy as np
19
+
20
+ try:
21
+ from ._gpumd import cell_quantities
22
+ except ImportError: # pragma: no cover - direct script execution
23
+ from _gpumd import cell_quantities
24
+
25
+ COMMAND = "gpumd-msd"
26
+ HELP = "Recursively extract GPUMD self-diffusion and ionic conductivity."
27
+ CORE_JOB_FILES = ("msd.out", "model.xyz", "run.in")
28
+ THERMO_NAMES = ("thermo.out", "thermos.out")
29
+ SUMMARY_FILE = "sigma-sdc-summary.txt"
30
+ ELEMENTARY_CHARGE_C = 1.602176634e-19
31
+ BOLTZMANN_J_PER_K = 1.380649e-23
32
+ A2_PER_PS_TO_CM2_PER_S = 1.0e-4
33
+ AVERAGE_FRACTION = 0.5
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class MsdSpec:
38
+ sample_interval: int
39
+ correlation_steps: int
40
+ time_step_fs: float
41
+ group_method: int = 0
42
+ group_id: Optional[int] = None
43
+ all_groups: bool = False
44
+
45
+ @property
46
+ def maximum_time_ps(self):
47
+ return (self.sample_interval * self.correlation_steps
48
+ * self.time_step_fs / 1000.0)
49
+
50
+
51
+ def _parse_compute_msd(parts, time_step_fs):
52
+ if len(parts) < 3:
53
+ raise ValueError("compute_msd requires sample_interval and Nc")
54
+ sample_interval, correlation_steps = int(parts[1]), int(parts[2])
55
+ if sample_interval <= 0 or correlation_steps <= 0:
56
+ raise ValueError("compute_msd sample_interval and Nc must be positive")
57
+ tail = parts[3:]
58
+ group_method, group_id, all_groups = 0, None, False
59
+ if "all_groups" in tail:
60
+ index = tail.index("all_groups")
61
+ if index + 1 >= len(tail):
62
+ raise ValueError("compute_msd all_groups requires a group method")
63
+ group_method, all_groups = int(tail[index + 1]), True
64
+ elif "group" in tail:
65
+ index = tail.index("group")
66
+ if index + 2 >= len(tail):
67
+ raise ValueError("compute_msd group requires a method and group index")
68
+ group_method, group_id = int(tail[index + 1]), int(tail[index + 2])
69
+ return MsdSpec(sample_interval, correlation_steps, float(time_step_fs),
70
+ group_method, group_id, all_groups)
71
+
72
+
73
+ def parse_run_file(path):
74
+ """Return the final SDC-producing specification, runs, and target run."""
75
+ time_step_fs = 1.0
76
+ active_dump_thermo = None
77
+ active_ensemble = None
78
+ active_msd = None
79
+ runs = []
80
+ for raw in Path(path).read_text(encoding="utf-8").splitlines():
81
+ parts = raw.split("#", 1)[0].split()
82
+ if not parts:
83
+ continue
84
+ key = parts[0].lower()
85
+ if key == "time_step" and len(parts) >= 2:
86
+ time_step_fs = float(parts[1])
87
+ elif key == "dump_thermo" and len(parts) >= 2:
88
+ active_dump_thermo = int(parts[1])
89
+ elif key == "ensemble" and len(parts) >= 2:
90
+ active_ensemble = parts[1].lower()
91
+ elif key == "compute_msd":
92
+ active_msd = _parse_compute_msd(parts, time_step_fs)
93
+ elif key == "run" and len(parts) >= 2:
94
+ runs.append({
95
+ "index": len(runs),
96
+ "steps": int(parts[1]),
97
+ "dump_thermo": active_dump_thermo,
98
+ "time_step_fs": time_step_fs,
99
+ "ensemble": active_ensemble,
100
+ "compute_msd": active_msd,
101
+ })
102
+ active_dump_thermo = None
103
+ active_ensemble = None
104
+ active_msd = None
105
+ sdc_runs = [run for run in runs if run["compute_msd"] is not None]
106
+ if not sdc_runs:
107
+ raise ValueError("run.in contains no compute_msd directive")
108
+ target_run = sdc_runs[-1]
109
+ return target_run["compute_msd"], runs, target_run
110
+
111
+
112
+ def _last_monotonic_segment(data):
113
+ """Keep the final block if appended GPUMD runs reset correlation time."""
114
+ if len(data) < 2:
115
+ return data
116
+ resets = np.flatnonzero(np.diff(data[:, 0]) <= 0.0)
117
+ return data[resets[-1] + 1:] if len(resets) else data
118
+
119
+
120
+ def _thermo_run_slice(n_points, runs, target_run):
121
+ """Map the target run to its rows in a concatenated thermo.out."""
122
+ thermo_runs = [run for run in runs if run["dump_thermo"]]
123
+ if target_run["dump_thermo"] is None:
124
+ raise ValueError("the SDC-producing run has no dump_thermo directive")
125
+ counts = {
126
+ run["index"]: max(1, run["steps"] // run["dump_thermo"])
127
+ for run in thermo_runs
128
+ }
129
+ expected_total = sum(counts.values())
130
+ target_count = counts[target_run["index"]]
131
+ if expected_total == n_points:
132
+ start = sum(
133
+ counts[run["index"]]
134
+ for run in thermo_runs
135
+ if run["index"] < target_run["index"]
136
+ )
137
+ return slice(start, start + target_count)
138
+ if thermo_runs[-1]["index"] == target_run["index"]:
139
+ return slice(max(0, n_points - target_count), n_points)
140
+ raise ValueError(
141
+ "thermo.out row count does not match run.in, so the SDC-producing "
142
+ "run cannot be mapped safely"
143
+ )
144
+
145
+
146
+ def _thermo_path(folder):
147
+ for name in THERMO_NAMES:
148
+ path = Path(folder) / name
149
+ if path.is_file():
150
+ return path
151
+ return None
152
+
153
+
154
+ def load_thermo(path, runs, target_run):
155
+ """Return temperature and ensemble-appropriate volume for the SDC run."""
156
+ data = np.atleast_2d(np.loadtxt(Path(path)))
157
+ if data.shape[1] != 18:
158
+ raise ValueError("thermo.out must use the official 18-column GPUMD format")
159
+ selected = data[_thermo_run_slice(len(data), runs, target_run)]
160
+ if not len(selected):
161
+ raise ValueError("the SDC-producing run has no mapped thermo records")
162
+ volumes = cell_quantities(selected)[2]
163
+ ensemble = target_run["ensemble"]
164
+ if ensemble is None:
165
+ raise ValueError("the SDC-producing run has no ensemble directive")
166
+ if ensemble.startswith(("npt_", "nph_")):
167
+ volume = float(np.mean(volumes))
168
+ volume_source = "mean over SDC-producing run"
169
+ elif ensemble == "nve" or ensemble.startswith("nvt_"):
170
+ volume = float(volumes[-1])
171
+ volume_source = "final cell of SDC-producing run"
172
+ else:
173
+ raise ValueError(
174
+ f"unsupported ensemble '{ensemble}' for conductivity volume"
175
+ )
176
+ temperature = float(np.mean(selected[:, 0]))
177
+ if temperature <= 0.0 or volume <= 0.0:
178
+ raise ValueError("temperature and volume must be positive")
179
+ return temperature, volume, ensemble, volume_source
180
+
181
+
182
+ def load_groups(path, group_method=0):
183
+ """Return group_id -> {element, count} from model.xyz."""
184
+ from ase.io import read
185
+
186
+ atoms = read(str(path), index=0)
187
+ symbols = np.asarray(atoms.get_chemical_symbols(), dtype=object)
188
+ raw_groups = atoms.arrays.get("group")
189
+ if raw_groups is None:
190
+ group_values = np.zeros(len(atoms), dtype=int)
191
+ else:
192
+ raw_groups = np.asarray(raw_groups)
193
+ if raw_groups.ndim == 1:
194
+ if group_method != 0:
195
+ raise ValueError(
196
+ f"model.xyz has one grouping method, not method {group_method}")
197
+ group_values = raw_groups.astype(int)
198
+ else:
199
+ if group_method < 0 or group_method >= raw_groups.shape[1]:
200
+ raise ValueError(
201
+ f"model.xyz has {raw_groups.shape[1]} grouping method(s), "
202
+ f"not method {group_method}")
203
+ group_values = raw_groups[:, group_method].astype(int)
204
+ groups = {}
205
+ for group_id in sorted(set(group_values.tolist())):
206
+ selected = symbols[group_values == group_id]
207
+ elements = sorted(set(selected.tolist()))
208
+ groups[int(group_id)] = {
209
+ "element": elements[0] if len(elements) == 1 else "mixed",
210
+ "count": int(len(selected)),
211
+ }
212
+ return groups
213
+
214
+
215
+ def load_msd(path, spec, groups):
216
+ """Return group_id -> (time, MSD_xyz, SDC_xyz) from official blocks."""
217
+ data = np.atleast_2d(np.loadtxt(Path(path)))
218
+ if data.shape[1] < 7 or (data.shape[1] - 1) % 6:
219
+ raise ValueError(
220
+ "msd.out must contain time plus one or more six-column MSD/SDC blocks")
221
+ data = _last_monotonic_segment(data)
222
+ if len(data) < 2 or np.any(np.diff(data[:, 0]) <= 0.0):
223
+ raise ValueError("msd.out needs at least two increasing time points")
224
+ block_count = (data.shape[1] - 1) // 6
225
+ if spec.all_groups:
226
+ group_ids = sorted(groups)
227
+ if block_count != len(group_ids):
228
+ raise ValueError(
229
+ f"msd.out has {block_count} blocks, but model.xyz grouping method "
230
+ f"{spec.group_method} has {len(group_ids)} groups")
231
+ elif spec.group_id is not None:
232
+ if block_count != 1 or spec.group_id not in groups:
233
+ raise ValueError("single-group msd.out cannot be mapped to model.xyz")
234
+ group_ids = [spec.group_id]
235
+ elif block_count == 1 and len(groups) == 1:
236
+ group_ids = [next(iter(groups))]
237
+ else:
238
+ raise ValueError(
239
+ "compute_msd without group/all_groups cannot map multiple groups")
240
+ result = {}
241
+ for block_index, group_id in enumerate(group_ids):
242
+ start = 1 + 6 * block_index
243
+ result[group_id] = (data[:, 0], data[:, start:start + 3],
244
+ data[:, start + 3:start + 6])
245
+ return result
246
+
247
+
248
+ def average_sdc(times, sdc_xyz, fraction=AVERAGE_FRACTION):
249
+ """Average directional SDC over the final correlation-time fraction."""
250
+ if not 0.0 < fraction <= 1.0:
251
+ raise ValueError("SDC averaging fraction must be in (0, 1]")
252
+ start_time = times[0] + (1.0 - fraction) * (times[-1] - times[0])
253
+ mask = times >= start_time
254
+ if np.count_nonzero(mask) < 2:
255
+ mask = np.zeros(len(times), dtype=bool)
256
+ mask[max(0, len(times) // 2):] = True
257
+ averaged = np.mean(sdc_xyz[mask], axis=0)
258
+ diffusion_xyz = averaged * A2_PER_PS_TO_CM2_PER_S
259
+ return np.append(diffusion_xyz, np.mean(diffusion_xyz)), mask, averaged
260
+
261
+
262
+ def calculate_conductivity(diffusion, num_ions, charge, volume_a3, temperature):
263
+ """Nernst-Einstein conductivity in mS/cm for D in cm^2/s."""
264
+ number_density_cm3 = int(num_ions) / (float(volume_a3) * 1.0e-24)
265
+ charge_c = float(charge) * ELEMENTARY_CHARGE_C
266
+ factor = (number_density_cm3 * charge_c ** 2
267
+ / (BOLTZMANN_J_PER_K * float(temperature)))
268
+ return np.asarray(diffusion) * factor * 1000.0
269
+
270
+
271
+ def _safe_name(value):
272
+ return re.sub(r"[^A-Za-z0-9_.+-]+", "_", str(value)).strip("_") or "unknown"
273
+
274
+
275
+ def _temperature_label(folder_name, temperature):
276
+ match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)K?", str(folder_name), re.I)
277
+ if match:
278
+ value = float(match.group(1))
279
+ return str(int(value)) if value.is_integer() else match.group(1)
280
+ return str(int(round(float(temperature))))
281
+
282
+
283
+ def save_group_data(output_dir, group_id, element, temp_label,
284
+ times, msd_xyz, sdc_xyz):
285
+ stem = f"group_{group_id}_{_safe_name(element)}_{temp_label}"
286
+ np.savetxt(Path(output_dir) / f"{stem}_msd.txt",
287
+ np.column_stack([times, msd_xyz, np.sum(msd_xyz, axis=1)]),
288
+ fmt="%.8f",
289
+ header="time(ps) MSD_x(A^2) MSD_y(A^2) MSD_z(A^2) MSD_total(A^2)",
290
+ comments="", encoding="utf-8")
291
+ np.savetxt(Path(output_dir) / f"{stem}_sdc.txt",
292
+ np.column_stack([times, sdc_xyz]), fmt="%.8f",
293
+ header="time(ps) SDC_x(A^2/ps) SDC_y(A^2/ps) SDC_z(A^2/ps)",
294
+ comments="", encoding="utf-8")
295
+ return stem
296
+
297
+
298
+ def save_group_results(output_dir, stem, record, averaged_sdc):
299
+ labels = ("x", "y", "z", "total")
300
+ lines = [
301
+ f"Job: {record['job']}",
302
+ f"Group: {record['group_id']}",
303
+ f"Element: {record['element']}",
304
+ f"Mobile particles: {record['count']}",
305
+ f"Mean temperature (K): {record['temperature']:.8f}",
306
+ f"Ensemble: {record['ensemble']}",
307
+ f"Conductivity volume (A^3): {record['volume']:.8f}",
308
+ f"Volume source: {record['volume_source']}",
309
+ ("SDC averaging interval (ps): "
310
+ f"{record['average_start']:.8f} to {record['average_end']:.8f}"),
311
+ "Average SDC (A^2/ps):",
312
+ *(f" SDC_{label} = {value:.8f}"
313
+ for label, value in zip(("x", "y", "z"), averaged_sdc)),
314
+ "Diffusion coefficient (cm^2/s):",
315
+ *(f" D_{label} = {value:.8f}"
316
+ for label, value in zip(labels, record["diffusion"])),
317
+ ]
318
+ if record["conductivity"] is not None:
319
+ lines.extend([
320
+ "Ionic conductivity (mS/cm):",
321
+ *(f" sigma_{label} = {value:.8f}"
322
+ for label, value in zip(labels, record["conductivity"])),
323
+ ])
324
+ (Path(output_dir) / f"{stem}_results.txt").write_text(
325
+ "\n".join(lines) + "\n", encoding="utf-8")
326
+
327
+
328
+ def discover_jobs(root):
329
+ """Recursively find GPUMD MSD job folders at any depth."""
330
+ root = Path(root)
331
+ if not root.is_dir():
332
+ raise SystemExit(f"Error: entry path not found: {root}")
333
+ jobs = []
334
+ for msd_path in root.rglob("msd.out"):
335
+ folder = msd_path.parent
336
+ thermo = _thermo_path(folder)
337
+ if (thermo is not None
338
+ and all((folder / name).is_file() for name in CORE_JOB_FILES)):
339
+ jobs.append((folder, thermo))
340
+ return sorted(jobs, key=lambda item: item[0].relative_to(root).as_posix())
341
+
342
+
343
+ def analyze_job(root, folder, thermo_path, args):
344
+ spec, runs, target_run = parse_run_file(folder / "run.in")
345
+ temperature, volume, ensemble, volume_source = load_thermo(
346
+ thermo_path, runs, target_run
347
+ )
348
+ groups = load_groups(folder / "model.xyz", spec.group_method)
349
+ blocks = load_msd(folder / "msd.out", spec, groups)
350
+ temp_label = _temperature_label(folder.name, temperature)
351
+ job_label = folder.relative_to(root).as_posix() or "."
352
+ output_dir = folder / "msd"
353
+ output_dir.mkdir(parents=True, exist_ok=True)
354
+ end_time = next(iter(blocks.values()))[0][-1]
355
+ interval_ps = spec.sample_interval * spec.time_step_fs / 1000.0
356
+ if abs(end_time - spec.maximum_time_ps) > max(interval_ps, 1.0e-8):
357
+ print(f" [WARN] {folder}: msd.out ends at {end_time:.8f} ps; "
358
+ f"run.in implies {spec.maximum_time_ps:.8f} ps")
359
+
360
+ records, job_sigma, mobile_found = [], np.zeros(4), False
361
+ for group_id in sorted(blocks):
362
+ times, msd_xyz, sdc_xyz = blocks[group_id]
363
+ info = groups[group_id]
364
+ diffusion, average_mask, averaged_sdc = average_sdc(times, sdc_xyz)
365
+ conductivity = None
366
+ if info["element"] == args.diffuse_ion:
367
+ conductivity = calculate_conductivity(
368
+ diffusion, info["count"], args.ion_charge, volume, temperature)
369
+ job_sigma += conductivity
370
+ mobile_found = True
371
+ stem = save_group_data(output_dir, group_id, info["element"], temp_label,
372
+ times, msd_xyz, sdc_xyz)
373
+ record = {
374
+ "job": job_label, "temperature": temperature, "volume": volume,
375
+ "ensemble": ensemble, "volume_source": volume_source,
376
+ "group_id": group_id, "element": info["element"],
377
+ "count": info["count"],
378
+ "average_start": float(times[average_mask][0]),
379
+ "average_end": float(times[average_mask][-1]),
380
+ "diffusion": diffusion,
381
+ "conductivity": conductivity,
382
+ }
383
+ save_group_results(output_dir, stem, record, averaged_sdc)
384
+ records.append(record)
385
+ sigma_text = (f", total sigma_{args.diffuse_ion}={job_sigma[3]:.8f} mS/cm"
386
+ if mobile_found else f", no {args.diffuse_ion} group")
387
+ print(
388
+ f" {job_label} -> {output_dir} "
389
+ f"({len(records)} group(s), {ensemble}, {volume_source}{sigma_text})"
390
+ )
391
+ return records
392
+
393
+
394
+ def write_summary(root, records, diffuse_ion):
395
+ columns = [
396
+ "Job", "T(K)", "V(A^3)", "Ensemble", "Volume_source",
397
+ "Group", "Element", "N", "average_start(ps)", "average_end(ps)",
398
+ "D_x(cm^2/s)", "D_y(cm^2/s)", "D_z(cm^2/s)", "D_total(cm^2/s)",
399
+ "sigma_x(mS/cm)", "sigma_y(mS/cm)", "sigma_z(mS/cm)",
400
+ "sigma_total(mS/cm)",
401
+ ]
402
+ lines = [
403
+ "GPUMD SDC and Nernst-Einstein conductivity summary",
404
+ f"Mobile ion: {diffuse_ion}",
405
+ "SDC average: arithmetic mean over the last 50% of correlation time",
406
+ "\t".join(columns),
407
+ ]
408
+ for record in sorted(records, key=lambda item: (item["job"], item["group_id"])):
409
+ conductivity = record["conductivity"]
410
+ sigma = ([f"{value:.8f}" for value in conductivity]
411
+ if conductivity is not None else ["N/A"] * 4)
412
+ fields = [
413
+ record["job"], f"{record['temperature']:.8f}",
414
+ f"{record['volume']:.8f}", record["ensemble"],
415
+ record["volume_source"], str(record["group_id"]),
416
+ record["element"], str(record["count"]),
417
+ f"{record['average_start']:.8f}",
418
+ f"{record['average_end']:.8f}",
419
+ *(f"{value:.8f}" for value in record["diffusion"]), *sigma,
420
+ ]
421
+ lines.append("\t".join(fields))
422
+ (Path(root) / SUMMARY_FILE).write_text(
423
+ "\n".join(lines) + "\n", encoding="utf-8")
424
+
425
+
426
+ def add_arguments(parser):
427
+ parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
428
+ parser.add_argument("-entry-path", default=".",
429
+ help="Root recursively scanned for GPUMD MSD jobs.")
430
+ parser.add_argument("-diffuse_ion", default="Li",
431
+ help="Mobile ion used for conductivity.")
432
+ parser.add_argument("-ion_charge", type=int, default=1,
433
+ help="Signed mobile-ion valence Z (the formula uses Z squared).")
434
+
435
+
436
+ def run(args):
437
+ root = Path(args.entry_path).resolve()
438
+ jobs = discover_jobs(root)
439
+ if not jobs:
440
+ print("No GPUMD MSD jobs found recursively (need msd.out, thermo.out "
441
+ "or thermos.out, model.xyz, and run.in in one folder).")
442
+ return 0
443
+ print(f"Found {len(jobs)} GPUMD MSD job folder(s).")
444
+ records = []
445
+ for folder, thermo_path in jobs:
446
+ try:
447
+ records.extend(analyze_job(root, folder, thermo_path, args))
448
+ except Exception as exc:
449
+ print(f" {folder}: analysis failed ({exc}) -> skipping")
450
+ if records:
451
+ write_summary(root, records, args.diffuse_ion)
452
+ print(f"Wrote {root / SUMMARY_FILE} ({len(records)} group record(s)).")
453
+ return 0
454
+ print("No GPUMD MSD job could be analyzed; summary was not written.")
455
+ return 1
456
+
457
+
458
+ if __name__ == "__main__":
459
+ _parser = argparse.ArgumentParser(description=__doc__)
460
+ add_arguments(_parser)
461
+ raise SystemExit(run(_parser.parse_args()))