pymdkit 1.5.2__tar.gz → 1.5.3__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.2 → pymdkit-1.5.3}/PKG-INFO +22 -4
  2. {pymdkit-1.5.2 → pymdkit-1.5.3}/README.md +22 -4
  3. {pymdkit-1.5.2 → pymdkit-1.5.3}/pyproject.toml +1 -1
  4. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/__init__.py +1 -1
  5. pymdkit-1.5.3/src/pymdkit/commands/convert.py +488 -0
  6. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/gpumd_msd.py +23 -16
  7. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/realloc_occ.py +90 -7
  8. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit.egg-info/PKG-INFO +22 -4
  9. pymdkit-1.5.2/src/pymdkit/commands/convert.py +0 -186
  10. {pymdkit-1.5.2 → pymdkit-1.5.3}/LICENSE +0 -0
  11. {pymdkit-1.5.2 → pymdkit-1.5.3}/setup.cfg +0 -0
  12. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/__init__.py +0 -0
  13. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_cifio.py +0 -0
  14. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_extxyz.py +0 -0
  15. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_fileio.py +0 -0
  16. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_geometry.py +0 -0
  17. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_gpumd.py +0 -0
  18. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_precision.py +0 -0
  19. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_reduction.py +0 -0
  20. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_vasp_output.py +0 -0
  21. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/_vaspset.py +0 -0
  22. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/add_config_type.py +0 -0
  23. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/chemsys_entry.py +0 -0
  24. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/compute_ehull.py +0 -0
  25. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/compute_rmsd.py +0 -0
  26. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/ewald.py +0 -0
  27. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/gather_fs.py +0 -0
  28. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/gpumd_group.py +0 -0
  29. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/gpumd_relax.py +0 -0
  30. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/gpumd_thermo.py +0 -0
  31. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
  32. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/nep_rmse.py +0 -0
  33. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/pca.py +0 -0
  34. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/perturb.py +0 -0
  35. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/submit_vasp.py +0 -0
  36. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/substitute.py +0 -0
  37. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/supercell.py +0 -0
  38. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/symmetrize.py +0 -0
  39. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/trj_extract.py +0 -0
  40. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/trj_sparse.py +0 -0
  41. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/umap.py +0 -0
  42. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/vasp2xyz.py +0 -0
  43. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/vasp_fe.py +0 -0
  44. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/vasp_relax.py +0 -0
  45. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/commands/vasp_static.py +0 -0
  46. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit/pymdkit_main.py +0 -0
  47. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit.egg-info/SOURCES.txt +0 -0
  48. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  49. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit.egg-info/entry_points.txt +0 -0
  50. {pymdkit-1.5.2 → pymdkit-1.5.3}/src/pymdkit.egg-info/requires.txt +0 -0
  51. {pymdkit-1.5.2 → pymdkit-1.5.3}/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.2
3
+ Version: 1.5.3
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
@@ -110,6 +110,7 @@ pmk gpumd-group -elements Li Y Cl # scan subfolders
110
110
  pmk convert -i opted.vasp -o opted.xyz # convert one structure
111
111
  pmk convert -if vasp-opted -of cif-opted -oe cif # folder conversion target format
112
112
  pmk convert -it train.xyz -ot train.extxyz # convert a complete trajectory
113
+ pmk convert -if Li4YCl6-disorder -of Li4YCl6-order -d2o -sc-matrix 1x1x1
113
114
  pmk supercell -i opted.vasp -o sc.vasp -max-abc 20 # cell lengths <= 20 A
114
115
  pmk supercell -if vasp-opted -max-abc 20 -individual # per-structure ./<name>/<name>.<ext>
115
116
  pmk supercell -if extxyz-opted -max-abc 24 -individual -temp 500 600 -md-if input-files -group Li Y Cl
@@ -135,6 +136,7 @@ pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
135
136
  nohup pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30 > submit-vasp.log 2>&1 &
136
137
  pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6
137
138
  pmk realloc-occ -if cifs -of chg-balance -formula Li3YCl6
139
+ pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6 -occ 0 0.25 0.5 0.75 1
138
140
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we Na -wn 3 -on 100
139
141
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we none -on 100
140
142
  pmk substitute -i Li96Ta6La11Cl72.cif -se Li1 Li2 -sn 20 67 -we none -on 100
@@ -211,8 +213,11 @@ followed by three directional SDC columns. PMK takes the arithmetic mean over
211
213
  the last 50% of the SDC correlation-time range, converts A^2/ps to cm^2/s by
212
214
  multiplying by `1e-4`, and reports `D_total = (D_x + D_y + D_z) / 3`.
213
215
  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
+ and a `_results.txt`. The root receives a compact `sigma-sdc-summary.txt` with
217
+ one row per job folder. Conductivities use two decimal places; diffusion
218
+ coefficients use compact scientific notation with two decimal places. If the
219
+ mobile ion occupies multiple groups, conductivities are summed and diffusion
220
+ coefficients are particle-count-weighted.
216
221
 
217
222
  Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
218
223
  of the final SDC-producing MD run and the official 18-column
@@ -231,7 +236,9 @@ oxidation-number loop, and preserves the original symmetry, coordinates,
231
236
  Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
232
237
  the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
233
238
  and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
234
- `-formula` options.
239
+ `-formula` options. Add `-occ` followed by the only permitted occupancy values;
240
+ for example, `-occ 0 0.25 0.5 0.75 1` also permits the valid allocation
241
+ `Li1=0.75, Li2=0.75`.
235
242
 
236
243
  `gather-fs` recursively scans all job folders below the current directory. For
237
244
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -318,6 +325,17 @@ Single-file and trajectory modes infer the target format from `-o` and `-ot`.
318
325
  Because Wyckoff metadata describes one crystal structure, each `.cif` output
319
326
  file accepts exactly one frame.
320
327
 
328
+ `convert -d2o` orders partially occupied CIF structures. `-sc-matrix 1x1x1`
329
+ means a 1x1x1 cell; three diagonal factors or nine integer matrix entries are
330
+ also accepted. Folder ordering scans recursively and mirrors input subfolders.
331
+ It uses pymatgen `EnumerateStructureTransformation` with one worker to remove
332
+ symmetry-equivalent orderings and select the lowest Ewald energy, then falls
333
+ back to bounded `OrderDisorderedStructureTransformation` when enumlib is not
334
+ available. Equivalent results from separate disorder files are merged and
335
+ written as `BASE-o1.cif`, `BASE-o2.cif`, and so on. For convenience, supplying
336
+ `-sc-matrix` without `-d2o` automatically enables ordering when a disordered
337
+ CIF is detected; ordered inputs instead receive an ordinary supercell.
338
+
321
339
  After `supercell -temp` creates all requested jobs, it checks generated folders
322
340
  that contain `run.in`, `model.xyz`, and `nep.txt`. Existing `ensemble npt_scr`
323
341
  lines retain their temperature parameters but are rewritten with three
@@ -83,6 +83,7 @@ pmk gpumd-group -elements Li Y Cl # scan subfolders
83
83
  pmk convert -i opted.vasp -o opted.xyz # convert one structure
84
84
  pmk convert -if vasp-opted -of cif-opted -oe cif # folder conversion target format
85
85
  pmk convert -it train.xyz -ot train.extxyz # convert a complete trajectory
86
+ pmk convert -if Li4YCl6-disorder -of Li4YCl6-order -d2o -sc-matrix 1x1x1
86
87
  pmk supercell -i opted.vasp -o sc.vasp -max-abc 20 # cell lengths <= 20 A
87
88
  pmk supercell -if vasp-opted -max-abc 20 -individual # per-structure ./<name>/<name>.<ext>
88
89
  pmk supercell -if extxyz-opted -max-abc 24 -individual -temp 500 600 -md-if input-files -group Li Y Cl
@@ -107,7 +108,8 @@ pmk vasp2xyz -position-only # write positions only, without e
107
108
  pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
108
109
  nohup pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30 > submit-vasp.log 2>&1 &
109
110
  pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6
110
- pmk realloc-occ -if cifs -of chg-balance -formula Li3YCl6
111
+ pmk realloc-occ -if cifs -of chg-balance -formula Li3YCl6
112
+ pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6 -occ 0 0.25 0.5 0.75 1
111
113
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we Na -wn 3 -on 100
112
114
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we none -on 100
113
115
  pmk substitute -i Li96Ta6La11Cl72.cif -se Li1 Li2 -sn 20 67 -we none -on 100
@@ -184,8 +186,11 @@ followed by three directional SDC columns. PMK takes the arithmetic mean over
184
186
  the last 50% of the SDC correlation-time range, converts A^2/ps to cm^2/s by
185
187
  multiplying by `1e-4`, and reports `D_total = (D_x + D_y + D_z) / 3`.
186
188
  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
+ and a `_results.txt`. The root receives a compact `sigma-sdc-summary.txt` with
190
+ one row per job folder. Conductivities use two decimal places; diffusion
191
+ coefficients use compact scientific notation with two decimal places. If the
192
+ mobile ion occupies multiple groups, conductivities are summed and diffusion
193
+ coefficients are particle-count-weighted.
189
194
 
190
195
  Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
191
196
  of the final SDC-producing MD run and the official 18-column
@@ -204,7 +209,9 @@ oxidation-number loop, and preserves the original symmetry, coordinates,
204
209
  Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
205
210
  the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
206
211
  and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
207
- `-formula` options.
212
+ `-formula` options. Add `-occ` followed by the only permitted occupancy values;
213
+ for example, `-occ 0 0.25 0.5 0.75 1` also permits the valid allocation
214
+ `Li1=0.75, Li2=0.75`.
208
215
 
209
216
  `gather-fs` recursively scans all job folders below the current directory. For
210
217
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -291,6 +298,17 @@ Single-file and trajectory modes infer the target format from `-o` and `-ot`.
291
298
  Because Wyckoff metadata describes one crystal structure, each `.cif` output
292
299
  file accepts exactly one frame.
293
300
 
301
+ `convert -d2o` orders partially occupied CIF structures. `-sc-matrix 1x1x1`
302
+ means a 1x1x1 cell; three diagonal factors or nine integer matrix entries are
303
+ also accepted. Folder ordering scans recursively and mirrors input subfolders.
304
+ It uses pymatgen `EnumerateStructureTransformation` with one worker to remove
305
+ symmetry-equivalent orderings and select the lowest Ewald energy, then falls
306
+ back to bounded `OrderDisorderedStructureTransformation` when enumlib is not
307
+ available. Equivalent results from separate disorder files are merged and
308
+ written as `BASE-o1.cif`, `BASE-o2.cif`, and so on. For convenience, supplying
309
+ `-sc-matrix` without `-d2o` automatically enables ordering when a disordered
310
+ CIF is detected; ordered inputs instead receive an ordinary supercell.
311
+
294
312
  After `supercell -temp` creates all requested jobs, it checks generated folders
295
313
  that contain `run.in`, `model.xyz`, and `nep.txt`. Existing `ensemble npt_scr`
296
314
  lines retain their temperature parameters but are rewritten with three
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.5.2"
7
+ version = "1.5.3"
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.2"
3
+ __version__ = "1.5.3"
@@ -0,0 +1,488 @@
1
+ """Convert structures and trajectories, including disorder-to-order workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+
10
+ try:
11
+ from . import _cifio, _fileio, _precision
12
+ except ImportError:
13
+ import _cifio
14
+ import _fileio
15
+ import _precision
16
+
17
+ COMMAND = "convert"
18
+ HELP = "Convert structures/trajectories and order disordered structures."
19
+
20
+
21
+ def _read(path, all_frames=False):
22
+ from ase.io import read
23
+
24
+ try:
25
+ return read(str(path), index=":" if all_frames else -1)
26
+ except Exception as ase_error:
27
+ if all_frames:
28
+ raise ValueError(
29
+ f"ASE could not read trajectory {path}: {ase_error}"
30
+ ) from ase_error
31
+ try:
32
+ from pymatgen.core import Molecule, Structure
33
+ from pymatgen.io.ase import AseAtomsAdaptor
34
+
35
+ try:
36
+ obj = Structure.from_file(str(path))
37
+ except Exception:
38
+ obj = Molecule.from_file(str(path))
39
+ return AseAtomsAdaptor.get_atoms(obj)
40
+ except Exception as pmg_error:
41
+ raise ValueError(
42
+ f"neither ASE nor pymatgen could read {path}: "
43
+ f"ASE={ase_error}; pymatgen={pmg_error}"
44
+ ) from pmg_error
45
+
46
+
47
+ def _ase_format(path):
48
+ suffix = Path(path).suffix.lower()
49
+ if suffix in {".xyz", ".extxyz"}:
50
+ return "extxyz"
51
+ if suffix == ".vasp":
52
+ return "vasp"
53
+ return None
54
+
55
+
56
+ def _normalize_output(path):
57
+ suffix = Path(path).suffix.lower()
58
+ if suffix in {".xyz", ".extxyz"}:
59
+ _precision.normalize_extxyz(path)
60
+ elif suffix == ".vasp":
61
+ _precision.normalize_vasp(path)
62
+
63
+
64
+ def _write(path, atoms):
65
+ path = Path(path)
66
+ if _cifio.is_cif(path):
67
+ if isinstance(atoms, (list, tuple)) and len(atoms) != 1:
68
+ raise ValueError("CIF output supports exactly one structure per file")
69
+ one = atoms[0] if isinstance(atoms, (list, tuple)) else atoms
70
+ _cifio.write_cif(one, path, data_name=path.stem)
71
+ return
72
+
73
+ from ase.io import write
74
+
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ kwargs = {}
77
+ output_format = _ase_format(path)
78
+ if output_format:
79
+ kwargs["format"] = output_format
80
+ if output_format == "vasp":
81
+ kwargs["direct"] = True
82
+ try:
83
+ write(str(path), atoms, **kwargs)
84
+ _normalize_output(path)
85
+ return
86
+ except Exception as ase_error:
87
+ if isinstance(atoms, (list, tuple)) and len(atoms) != 1:
88
+ raise ValueError(
89
+ f"ASE cannot write this multi-frame trajectory to {path}: "
90
+ f"{ase_error}"
91
+ ) from ase_error
92
+ one = atoms[0] if isinstance(atoms, (list, tuple)) else atoms
93
+ try:
94
+ from pymatgen.io.ase import AseAtomsAdaptor
95
+
96
+ if one.pbc.any():
97
+ obj = AseAtomsAdaptor.get_structure(one)
98
+ else:
99
+ obj = AseAtomsAdaptor.get_molecule(one)
100
+ obj.to(filename=str(path))
101
+ _normalize_output(path)
102
+ except Exception as pmg_error:
103
+ raise ValueError(
104
+ f"neither ASE nor pymatgen could write {path}: "
105
+ f"ASE={ase_error}; pymatgen={pmg_error}"
106
+ ) from pmg_error
107
+
108
+
109
+ def _read_structure(path):
110
+ from pymatgen.core import Structure
111
+
112
+ return Structure.from_file(str(path))
113
+
114
+
115
+ def _write_structure(path, structure, refine=True):
116
+ path = Path(path)
117
+ if _cifio.is_cif(path):
118
+ _cifio.write_cif(
119
+ structure, path, data_name=path.stem, refine=refine
120
+ )
121
+ return
122
+ from pymatgen.io.ase import AseAtomsAdaptor
123
+
124
+ _write(path, AseAtomsAdaptor.get_atoms(structure))
125
+
126
+
127
+ def _matrix_determinant(matrix):
128
+ return (
129
+ matrix[0][0] * (matrix[1][1] * matrix[2][2]
130
+ - matrix[1][2] * matrix[2][1])
131
+ - matrix[0][1] * (matrix[1][0] * matrix[2][2]
132
+ - matrix[1][2] * matrix[2][0])
133
+ + matrix[0][2] * (matrix[1][0] * matrix[2][1]
134
+ - matrix[1][1] * matrix[2][0])
135
+ )
136
+
137
+
138
+ def parse_supercell_matrix(values):
139
+ if not values:
140
+ return None
141
+ tokens = []
142
+ for value in values:
143
+ tokens.extend(token for token in re.split(r"[ ,xX]+", str(value)) if token)
144
+ try:
145
+ numbers = [int(token) for token in tokens]
146
+ except ValueError as exc:
147
+ raise ValueError("-sc-matrix values must be integers") from exc
148
+ if len(numbers) == 3:
149
+ if any(value <= 0 for value in numbers):
150
+ raise ValueError("diagonal -sc-matrix factors must be positive")
151
+ return [[numbers[0], 0, 0], [0, numbers[1], 0], [0, 0, numbers[2]]]
152
+ if len(numbers) == 9:
153
+ matrix = [numbers[0:3], numbers[3:6], numbers[6:9]]
154
+ if _matrix_determinant(matrix) == 0:
155
+ raise ValueError("-sc-matrix must be nonsingular")
156
+ return matrix
157
+ raise ValueError(
158
+ "-sc-matrix accepts 1x1x1, three diagonal factors, or nine matrix entries"
159
+ )
160
+
161
+
162
+ def convert_one(input_path, output_path, trajectory=False, supercell_matrix=None):
163
+ if supercell_matrix is not None:
164
+ if trajectory:
165
+ raise ValueError("-sc-matrix is not supported with trajectory mode")
166
+ structure = _read_structure(input_path)
167
+ structure.make_supercell(supercell_matrix)
168
+ _write_structure(output_path, structure)
169
+ else:
170
+ atoms = _read(input_path, all_frames=trajectory)
171
+ _write(output_path, atoms)
172
+ count = len(atoms) if trajectory and supercell_matrix is None else 1
173
+ label = f" ({count} frame(s))" if trajectory else ""
174
+ print(f"{input_path} -> {output_path}{label}")
175
+
176
+
177
+ def _recursive_structure_files(folder, cif_only=False):
178
+ folder = Path(folder)
179
+ if not folder.is_dir():
180
+ raise SystemExit(f"Error: input folder not found: {folder}")
181
+ files = [
182
+ path for path in folder.rglob("*")
183
+ if _fileio.is_structure(path)
184
+ and (not cif_only or path.suffix.lower() == ".cif")
185
+ ]
186
+ files.sort(key=lambda path: path.relative_to(folder).as_posix())
187
+ if not files:
188
+ kind = "CIF" if cif_only else "structure"
189
+ raise SystemExit(f"Error: no {kind} files found recursively in {folder}")
190
+ return files
191
+
192
+
193
+ def _add_oxidation_states(structure, source):
194
+ if all(hasattr(specie, "oxi_state")
195
+ for site in structure for specie in site.species):
196
+ return structure
197
+ if Path(source).suffix.lower() != ".cif":
198
+ raise ValueError("Ewald ordering requires oxidation-state-decorated input")
199
+ try:
200
+ from .realloc_occ import parse_cif
201
+ except ImportError:
202
+ from realloc_occ import parse_cif
203
+
204
+ parsed = parse_cif(source)
205
+ oxidation = {}
206
+ for site in parsed.sites:
207
+ previous = oxidation.setdefault(site.element, site.oxidation)
208
+ if abs(previous - site.oxidation) > 1.0e-8:
209
+ raise ValueError(
210
+ f"{site.element} has multiple oxidation states; "
211
+ "site-specific Ewald decoration is ambiguous"
212
+ )
213
+ structure.add_oxidation_state_by_element(oxidation)
214
+ return structure
215
+
216
+
217
+ def _ranked_structure(result):
218
+ if isinstance(result, list):
219
+ if not result:
220
+ raise ValueError("ordering transformation returned no structures")
221
+ result = result[0]
222
+ return result["structure"] if isinstance(result, dict) else result
223
+
224
+
225
+ def _ewald_energy_per_atom(structure):
226
+ from pymatgen.analysis.ewald import EwaldSummation
227
+
228
+ return float(EwaldSummation(structure).total_energy) / len(structure)
229
+
230
+
231
+ def _order_source(source, supercell_matrix):
232
+ structure = _read_structure(source)
233
+ if structure.is_ordered:
234
+ return None
235
+ structure = _add_oxidation_states(structure, source)
236
+ structure.make_supercell(supercell_matrix)
237
+ try:
238
+ from pymatgen.transformations.advanced_transformations import (
239
+ EnumerateStructureTransformation,
240
+ )
241
+
242
+ transformation = EnumerateStructureTransformation(
243
+ min_cell_size=1,
244
+ max_cell_size=1,
245
+ symm_prec=0.1,
246
+ refine_structure=False,
247
+ sort_criteria="ewald",
248
+ n_jobs=1,
249
+ )
250
+ result = transformation.apply_transformation(
251
+ structure, return_ranked_list=1
252
+ )
253
+ method = "EnumerateStructureTransformation"
254
+ except Exception as enum_error:
255
+ from pymatgen.transformations.standard_transformations import (
256
+ OrderDisorderedStructureTransformation,
257
+ )
258
+
259
+ transformation = OrderDisorderedStructureTransformation(
260
+ algo=OrderDisorderedStructureTransformation.ALGO_BEST_FIRST
261
+ )
262
+ result = transformation.apply_transformation(
263
+ structure, return_ranked_list=1
264
+ )
265
+ method = (
266
+ "OrderDisorderedStructureTransformation fallback "
267
+ f"({type(enum_error).__name__}: {enum_error})"
268
+ )
269
+ ordered = _ranked_structure(result)
270
+ if not ordered.is_ordered:
271
+ raise ValueError("ordering transformation returned a disordered structure")
272
+ return ordered, _ewald_energy_per_atom(ordered), method
273
+
274
+
275
+ def _deduplicate_orderings(candidates):
276
+ from pymatgen.analysis.structure_matcher import StructureMatcher
277
+
278
+ matcher = StructureMatcher()
279
+ unique = []
280
+ for candidate in sorted(candidates, key=lambda item: item[1]):
281
+ structure = candidate[0]
282
+ if any(matcher.fit(structure, kept[0]) for kept in unique):
283
+ continue
284
+ unique.append(candidate)
285
+ return unique
286
+
287
+
288
+ def _ordering_base(path):
289
+ return re.sub(r"-d\d+$", "", Path(path).stem, flags=re.IGNORECASE)
290
+
291
+
292
+ def _folder_contains_disorder(folder):
293
+ folder = Path(folder)
294
+ if not folder.is_dir():
295
+ raise SystemExit(f"Error: input folder not found: {folder}")
296
+ for source in sorted(folder.rglob("*.cif")):
297
+ try:
298
+ if not _read_structure(source).is_ordered:
299
+ return True
300
+ except Exception:
301
+ continue
302
+ return False
303
+
304
+
305
+ def _single_contains_disorder(source):
306
+ return Path(source).suffix.lower() == ".cif" and not _read_structure(source).is_ordered
307
+
308
+
309
+ def order_single(source, output, supercell_matrix):
310
+ candidate = _order_source(Path(source), supercell_matrix)
311
+ if candidate is None:
312
+ raise ValueError(f"input structure is already ordered: {source}")
313
+ structure, energy, method = candidate
314
+ output = Path(output)
315
+ if output.suffix.lower() != ".cif":
316
+ raise ValueError("disorder-to-order output must be a .cif file")
317
+ _write_structure(output, structure, refine=False)
318
+ print(f"{source} -> {output} (Ewald={energy:.8f} eV/atom; {method})")
319
+
320
+
321
+ def order_folder(input_folder, output_folder, supercell_matrix):
322
+ input_folder = Path(input_folder)
323
+ output_folder = Path(output_folder)
324
+ groups = defaultdict(list)
325
+ for source in _recursive_structure_files(input_folder, cif_only=True):
326
+ relative_parent = source.parent.relative_to(input_folder)
327
+ groups[(relative_parent, _ordering_base(source))].append(source)
328
+
329
+ written, processed, failed = 0, 0, 0
330
+ for (relative_parent, base), sources in sorted(
331
+ groups.items(), key=lambda item: (item[0][0].as_posix(), item[0][1])):
332
+ candidates = []
333
+ for source in sources:
334
+ try:
335
+ candidate = _order_source(source, supercell_matrix)
336
+ if candidate is None:
337
+ print(f"{source}: already ordered -> skipped")
338
+ continue
339
+ candidates.append((*candidate, source))
340
+ processed += 1
341
+ except Exception as exc:
342
+ failed += 1
343
+ print(f"{source}: ordering failed ({exc})")
344
+ destination = output_folder / relative_parent
345
+ destination.mkdir(parents=True, exist_ok=True)
346
+ for number, candidate in enumerate(
347
+ _deduplicate_orderings(candidates), start=1):
348
+ structure, energy, method, source = candidate
349
+ output = destination / f"{base}-o{number}.cif"
350
+ _write_structure(output, structure, refine=False)
351
+ print(
352
+ f"{source} -> {output} "
353
+ f"(Ewald={energy:.8f} eV/atom; {method})"
354
+ )
355
+ written += 1
356
+ print(
357
+ f"Done: ordered {processed} disordered CIF(s), wrote {written} "
358
+ f"symmetry-distinct lowest-energy CIF(s) to {output_folder}"
359
+ )
360
+ return 0 if processed and written and not failed else 1
361
+
362
+
363
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
364
+ parser.add_argument("-i", "-input", dest="input",
365
+ help="Single input structure.")
366
+ parser.add_argument("-o", "-output", dest="output",
367
+ help="Single output structure; with -if, output folder.")
368
+ parser.add_argument("-it", "-input-trajectory", dest="input_trajectory",
369
+ help="Input trajectory containing multiple structures.")
370
+ parser.add_argument("-ot", "-output-trajectory", dest="output_trajectory",
371
+ help="Output trajectory.")
372
+ parser.add_argument("-if", "-input-folder", dest="input_folder",
373
+ help="Folder of input structures.")
374
+ parser.add_argument("-of", "-output-folder", dest="output_folder",
375
+ help="Output folder for batch conversion.")
376
+ parser.add_argument("-oe", "-output-extension", dest="output_extension",
377
+ default="xyz",
378
+ help="Batch/scan output extension (default: xyz).")
379
+ parser.add_argument(
380
+ "-d2o", action="store_true",
381
+ help="Order partially occupied structures and retain lowest-Ewald results.",
382
+ )
383
+ parser.add_argument(
384
+ "-sc-matrix", nargs="+", metavar="N",
385
+ help="Supercell as 1x1x1, three diagonal factors, or nine matrix entries.",
386
+ )
387
+
388
+
389
+ def _extension(value):
390
+ value = str(value).strip()
391
+ return value if value.startswith(".") else "." + value
392
+
393
+
394
+ def run(args) -> int:
395
+ modes = [bool(args.input), bool(args.input_trajectory), bool(args.input_folder)]
396
+ if sum(modes) > 1:
397
+ raise SystemExit("Error: use only one of -i, -it, or -if.")
398
+ try:
399
+ supercell_matrix = parse_supercell_matrix(args.sc_matrix)
400
+ except ValueError as exc:
401
+ raise SystemExit(f"Error: {exc}") from exc
402
+ if args.input_trajectory and (args.d2o or supercell_matrix is not None):
403
+ raise SystemExit("Error: -d2o and -sc-matrix do not support -it.")
404
+
405
+ ordering_mode = bool(args.d2o)
406
+ if supercell_matrix is not None and not ordering_mode:
407
+ if args.input:
408
+ ordering_mode = _single_contains_disorder(args.input)
409
+ elif args.input_folder:
410
+ ordering_mode = _folder_contains_disorder(args.input_folder)
411
+ if ordering_mode:
412
+ matrix = supercell_matrix or [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
413
+ if args.input:
414
+ if not args.output:
415
+ raise SystemExit("Error: -o is required with -i -d2o.")
416
+ try:
417
+ order_single(args.input, args.output, matrix)
418
+ except Exception as exc:
419
+ raise SystemExit(f"Error: {exc}") from exc
420
+ return 0
421
+ if args.input_folder:
422
+ if args.output and args.output_folder:
423
+ raise SystemExit("Error: use only one of -o or -of with -if.")
424
+ folder_option = args.output_folder or args.output
425
+ output_folder = (
426
+ Path(folder_option) if folder_option
427
+ else Path(args.input_folder).with_name(
428
+ Path(args.input_folder).name + "-order"
429
+ )
430
+ )
431
+ return order_folder(args.input_folder, output_folder, matrix)
432
+ raise SystemExit("Error: -d2o requires -i or -if.")
433
+
434
+ if args.input:
435
+ if not args.output:
436
+ raise SystemExit("Error: -o is required with -i.")
437
+ convert_one(
438
+ args.input, args.output, supercell_matrix=supercell_matrix
439
+ )
440
+ return 0
441
+
442
+ if args.input_trajectory:
443
+ if not args.output_trajectory:
444
+ raise SystemExit("Error: -ot is required with -it.")
445
+ convert_one(args.input_trajectory, args.output_trajectory, trajectory=True)
446
+ return 0
447
+
448
+ extension = _extension(args.output_extension)
449
+ if args.input_folder:
450
+ input_folder = Path(args.input_folder)
451
+ if args.output and args.output_folder:
452
+ raise SystemExit("Error: use only one of -o or -of with -if.")
453
+ folder_option = args.output_folder or args.output
454
+ output_folder = (
455
+ Path(folder_option) if folder_option
456
+ else input_folder.with_name(input_folder.name + "-converted")
457
+ )
458
+ output_folder.mkdir(parents=True, exist_ok=True)
459
+ files = _fileio.structure_files(input_folder)
460
+ for path in files:
461
+ convert_one(
462
+ path,
463
+ output_folder / (path.stem + extension),
464
+ supercell_matrix=supercell_matrix,
465
+ )
466
+ print(f"Done: converted {len(files)} file(s) to {output_folder}")
467
+ return 0
468
+
469
+ if supercell_matrix is not None:
470
+ raise SystemExit("Error: -sc-matrix requires -i or -if.")
471
+ count = 0
472
+ for folder in _fileio.subfolders("."):
473
+ for path in sorted(folder.iterdir()):
474
+ if not _fileio.is_structure(path):
475
+ continue
476
+ if path.suffix.lower() == extension.lower():
477
+ continue
478
+ name = args.output if args.output else path.stem + extension
479
+ convert_one(path, folder / name)
480
+ count += 1
481
+ print(f"Done: converted {count} file(s) across sub-folders.")
482
+ return 0
483
+
484
+
485
+ if __name__ == "__main__":
486
+ _parser = argparse.ArgumentParser(description=__doc__)
487
+ add_arguments(_parser)
488
+ raise SystemExit(run(_parser.parse_args()))
@@ -391,32 +391,39 @@ def analyze_job(root, folder, thermo_path, args):
391
391
  return records
392
392
 
393
393
 
394
+ def _compact_scientific(value):
395
+ mantissa, exponent = f"{float(value):.2e}".split("e")
396
+ return f"{mantissa}e{int(exponent)}"
397
+
398
+
394
399
  def write_summary(root, records, diffuse_ion):
395
400
  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
+ "Folder", "sigma_total(mS/cm)", "sigma_x(mS/cm)",
402
+ "sigma_y(mS/cm)", "sigma_z(mS/cm)", "D_total(cm^2/s)",
403
+ "D_x(cm^2/s)", "D_y(cm^2/s)", "D_z(cm^2/s)",
401
404
  ]
402
405
  lines = [
403
406
  "GPUMD SDC and Nernst-Einstein conductivity summary",
404
407
  f"Mobile ion: {diffuse_ion}",
405
408
  "SDC average: arithmetic mean over the last 50% of correlation time",
406
409
  "\t".join(columns),
410
+ "-" * len("\t".join(columns).expandtabs(4)),
407
411
  ]
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
+ jobs = {}
413
+ for record in records:
414
+ if record["element"] == diffuse_ion and record["conductivity"] is not None:
415
+ jobs.setdefault(record["job"], []).append(record)
416
+ for job, job_records in sorted(jobs.items()):
417
+ sigma = np.sum([record["conductivity"] for record in job_records], axis=0)
418
+ count = sum(record["count"] for record in job_records)
419
+ diffusion = sum(
420
+ record["diffusion"] * record["count"] for record in job_records
421
+ ) / count
412
422
  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,
423
+ job,
424
+ *(f"{sigma[index]:.2f}" for index in (3, 0, 1, 2)),
425
+ *(_compact_scientific(diffusion[index])
426
+ for index in (3, 0, 1, 2)),
420
427
  ]
421
428
  lines.append("\t".join(fields))
422
429
  (Path(root) / SUMMARY_FILE).write_text(
@@ -3,6 +3,7 @@
3
3
  Examples:
4
4
  pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6
5
5
  pmk realloc-occ -if structures -of chg-balance -formula Li3YCl6
6
+ pmk realloc-occ -i input.cif -of out -formula Li3YCl6 -occ 0 0.5 1
6
7
  """
7
8
 
8
9
  from __future__ import annotations
@@ -244,7 +245,73 @@ def _infer_z(parsed, target):
244
245
  return min(scores)[2]
245
246
 
246
247
 
247
- def _adjustment_options(sites, target_count):
248
+ def _normalize_occupancies(values):
249
+ if values is None:
250
+ return None
251
+ occupancies = []
252
+ for value in values:
253
+ number = float(value)
254
+ if not -TOLERANCE <= number <= 1.0 + TOLERANCE:
255
+ raise ValueError("-occ values must be between 0 and 1")
256
+ rounded = round(number, 2)
257
+ if abs(number - rounded) > TOLERANCE:
258
+ raise ValueError("-occ values support at most two decimal places")
259
+ occupancies.append(min(1.0, max(0.0, rounded)))
260
+ result = tuple(sorted(set(occupancies)))
261
+ if not result:
262
+ raise ValueError("-occ requires at least one occupancy value")
263
+ return result
264
+
265
+
266
+ def _grid_occupancy_options(sites, target_count, allowed_occupancies):
267
+ if len(sites) > MAX_ADJUSTABLE_SITES:
268
+ raise ValueError(f"more than {MAX_ADJUSTABLE_SITES} adjustable sites")
269
+ minimum, maximum = min(allowed_occupancies), max(allowed_occupancies)
270
+ options = []
271
+
272
+ def visit(index, total, values):
273
+ if len(options) > MAX_OUTPUTS:
274
+ raise ValueError(f"occupancy allocations exceed {MAX_OUTPUTS}")
275
+ if index == len(sites):
276
+ if abs(total - target_count) <= TOLERANCE:
277
+ options.append(tuple(values))
278
+ return
279
+ remaining = sites[index + 1:]
280
+ minimum_remaining = sum(site.multiplicity * minimum for site in remaining)
281
+ maximum_remaining = sum(site.multiplicity * maximum for site in remaining)
282
+ site = sites[index]
283
+ choices = sorted(
284
+ allowed_occupancies,
285
+ key=lambda value: (abs(value - site.occupancy), value),
286
+ )
287
+ for value in choices:
288
+ subtotal = total + site.multiplicity * value
289
+ if subtotal + minimum_remaining > target_count + TOLERANCE:
290
+ continue
291
+ if subtotal + maximum_remaining < target_count - TOLERANCE:
292
+ continue
293
+ visit(index + 1, subtotal, values + [value])
294
+
295
+ visit(0, 0.0, [])
296
+ options = list(dict.fromkeys(options))
297
+ options.sort(key=lambda values: (
298
+ sum(abs(site.occupancy - value) > TOLERANCE
299
+ for site, value in zip(sites, values)),
300
+ tuple(index for index, (site, value) in enumerate(zip(sites, values))
301
+ if abs(site.occupancy - value) > TOLERANCE),
302
+ values,
303
+ ))
304
+ if not options:
305
+ allowed = ", ".join(f"{value:.2f}" for value in allowed_occupancies)
306
+ raise ValueError(
307
+ f"no allocation reaches the target using -occ values: {allowed}"
308
+ )
309
+ return options
310
+
311
+
312
+ def _adjustment_options(sites, target_count, allowed_occupancies=None):
313
+ if allowed_occupancies is not None:
314
+ return _grid_occupancy_options(sites, target_count, allowed_occupancies)
248
315
  current = sum(site.multiplicity * site.occupancy for site in sites)
249
316
  delta = target_count - current
250
317
  if abs(delta) <= TOLERANCE:
@@ -296,7 +363,7 @@ def _adjustment_options(sites, target_count):
296
363
  return options
297
364
 
298
365
 
299
- def generate_candidates(parsed, target_formula):
366
+ def generate_candidates(parsed, target_formula, allowed_occupancies=None):
300
367
  target = _parse_formula(target_formula)
301
368
  sites_by_element = OrderedDict()
302
369
  for index, site in enumerate(parsed.sites):
@@ -313,8 +380,11 @@ def generate_candidates(parsed, target_formula):
313
380
 
314
381
  element_options = []
315
382
  for element, indexed_sites in sites_by_element.items():
316
- options = _adjustment_options([site for _, site in indexed_sites],
317
- target_counts.get(element, 0.0))
383
+ options = _adjustment_options(
384
+ [site for _, site in indexed_sites],
385
+ target_counts.get(element, 0.0),
386
+ allowed_occupancies,
387
+ )
318
388
  element_options.append((indexed_sites, options))
319
389
  output_count = math.prod(len(options) for _, options in element_options)
320
390
  if output_count > MAX_OUTPUTS:
@@ -389,9 +459,11 @@ def _detected_formula(parsed):
389
459
  return _format_formula(composition)
390
460
 
391
461
 
392
- def process_one(source, output_root, target_formula):
462
+ def process_one(source, output_root, target_formula, allowed_occupancies=None):
393
463
  parsed = parse_cif(source)
394
- target, target_counts, z, candidates = generate_candidates(parsed, target_formula)
464
+ target, target_counts, z, candidates = generate_candidates(
465
+ parsed, target_formula, allowed_occupancies
466
+ )
395
467
  destination = Path(output_root) / Path(source).stem
396
468
  if destination.exists() and (not destination.is_dir()
397
469
  or any(destination.iterdir())):
@@ -417,6 +489,10 @@ def add_arguments(parser):
417
489
  help="Top-level output folder; each input gets a subfolder.")
418
490
  parser.add_argument("-formula", required=True,
419
491
  help="Target reduced formula, for example Li3YCl6.")
492
+ parser.add_argument(
493
+ "-occ", nargs="+", type=float,
494
+ help="Allowed site occupancies, for example 0 0.25 0.5 0.75 1.",
495
+ )
420
496
 
421
497
 
422
498
  def run(args):
@@ -432,12 +508,19 @@ def run(args):
432
508
  if not sources:
433
509
  raise SystemExit(f"Error: no CIF files found in {folder}")
434
510
 
511
+ try:
512
+ allowed_occupancies = _normalize_occupancies(args.occ)
513
+ except ValueError as exc:
514
+ raise SystemExit(f"Error: {exc}") from exc
515
+
435
516
  output_root = Path(args.output_folder)
436
517
  output_root.mkdir(parents=True, exist_ok=True)
437
518
  files_written, inputs_done = 0, 0
438
519
  for source in sources:
439
520
  try:
440
- files_written += process_one(source, output_root, args.formula)
521
+ files_written += process_one(
522
+ source, output_root, args.formula, allowed_occupancies
523
+ )
441
524
  inputs_done += 1
442
525
  except Exception as exc:
443
526
  print(f"{source}: failed ({exc})")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.5.2
3
+ Version: 1.5.3
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
@@ -110,6 +110,7 @@ pmk gpumd-group -elements Li Y Cl # scan subfolders
110
110
  pmk convert -i opted.vasp -o opted.xyz # convert one structure
111
111
  pmk convert -if vasp-opted -of cif-opted -oe cif # folder conversion target format
112
112
  pmk convert -it train.xyz -ot train.extxyz # convert a complete trajectory
113
+ pmk convert -if Li4YCl6-disorder -of Li4YCl6-order -d2o -sc-matrix 1x1x1
113
114
  pmk supercell -i opted.vasp -o sc.vasp -max-abc 20 # cell lengths <= 20 A
114
115
  pmk supercell -if vasp-opted -max-abc 20 -individual # per-structure ./<name>/<name>.<ext>
115
116
  pmk supercell -if extxyz-opted -max-abc 24 -individual -temp 500 600 -md-if input-files -group Li Y Cl
@@ -135,6 +136,7 @@ pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30
135
136
  nohup pmk submit-vasp -subscript sub_vasp -queue slurm -max-job-num 30 > submit-vasp.log 2>&1 &
136
137
  pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6
137
138
  pmk realloc-occ -if cifs -of chg-balance -formula Li3YCl6
139
+ pmk realloc-occ -i 66-Li4YCl6-5.cif -of chg-balance -formula Li3YCl6 -occ 0 0.25 0.5 0.75 1
138
140
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we Na -wn 3 -on 100
139
141
  pmk substitute -i Li3YCl6.cif -se Li -sn 3 -we none -on 100
140
142
  pmk substitute -i Li96Ta6La11Cl72.cif -se Li1 Li2 -sn 20 67 -we none -on 100
@@ -211,8 +213,11 @@ followed by three directional SDC columns. PMK takes the arithmetic mean over
211
213
  the last 50% of the SDC correlation-time range, converts A^2/ps to cm^2/s by
212
214
  multiplying by `1e-4`, and reports `D_total = (D_x + D_y + D_z) / 3`.
213
215
  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
+ and a `_results.txt`. The root receives a compact `sigma-sdc-summary.txt` with
217
+ one row per job folder. Conductivities use two decimal places; diffusion
218
+ coefficients use compact scientific notation with two decimal places. If the
219
+ mobile ion occupies multiple groups, conductivities are summed and diffusion
220
+ coefficients are particle-count-weighted.
216
221
 
217
222
  Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
218
223
  of the final SDC-producing MD run and the official 18-column
@@ -231,7 +236,9 @@ oxidation-number loop, and preserves the original symmetry, coordinates,
231
236
  Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
232
237
  the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
233
238
  and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
234
- `-formula` options.
239
+ `-formula` options. Add `-occ` followed by the only permitted occupancy values;
240
+ for example, `-occ 0 0.25 0.5 0.75 1` also permits the valid allocation
241
+ `Li1=0.75, Li2=0.75`.
235
242
 
236
243
  `gather-fs` recursively scans all job folders below the current directory. For
237
244
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -318,6 +325,17 @@ Single-file and trajectory modes infer the target format from `-o` and `-ot`.
318
325
  Because Wyckoff metadata describes one crystal structure, each `.cif` output
319
326
  file accepts exactly one frame.
320
327
 
328
+ `convert -d2o` orders partially occupied CIF structures. `-sc-matrix 1x1x1`
329
+ means a 1x1x1 cell; three diagonal factors or nine integer matrix entries are
330
+ also accepted. Folder ordering scans recursively and mirrors input subfolders.
331
+ It uses pymatgen `EnumerateStructureTransformation` with one worker to remove
332
+ symmetry-equivalent orderings and select the lowest Ewald energy, then falls
333
+ back to bounded `OrderDisorderedStructureTransformation` when enumlib is not
334
+ available. Equivalent results from separate disorder files are merged and
335
+ written as `BASE-o1.cif`, `BASE-o2.cif`, and so on. For convenience, supplying
336
+ `-sc-matrix` without `-d2o` automatically enables ordering when a disordered
337
+ CIF is detected; ordered inputs instead receive an ordinary supercell.
338
+
321
339
  After `supercell -temp` creates all requested jobs, it checks generated folders
322
340
  that contain `run.in`, `model.xyz`, and `nep.txt`. Existing `ensemble npt_scr`
323
341
  lines retain their temperature parameters but are rewritten with three
@@ -1,186 +0,0 @@
1
- """Convert structures and trajectories between ASE/pymatgen formats."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- from pathlib import Path
7
-
8
- try:
9
- from . import _cifio, _fileio, _precision
10
- except ImportError:
11
- import _cifio
12
- import _fileio
13
- import _precision
14
-
15
- COMMAND = "convert"
16
- HELP = "Convert structures or trajectories between ASE/pymatgen formats."
17
-
18
-
19
- def _read(path, all_frames=False):
20
- from ase.io import read
21
-
22
- try:
23
- return read(str(path), index=":" if all_frames else -1)
24
- except Exception as ase_error:
25
- if all_frames:
26
- raise ValueError(f"ASE could not read trajectory {path}: {ase_error}") from ase_error
27
- try:
28
- from pymatgen.core import Molecule, Structure
29
- from pymatgen.io.ase import AseAtomsAdaptor
30
-
31
- try:
32
- obj = Structure.from_file(str(path))
33
- except Exception:
34
- obj = Molecule.from_file(str(path))
35
- return AseAtomsAdaptor.get_atoms(obj)
36
- except Exception as pmg_error:
37
- raise ValueError(
38
- f"neither ASE nor pymatgen could read {path}: "
39
- f"ASE={ase_error}; pymatgen={pmg_error}"
40
- ) from pmg_error
41
-
42
-
43
- def _ase_format(path):
44
- suffix = Path(path).suffix.lower()
45
- if suffix in {".xyz", ".extxyz"}:
46
- return "extxyz"
47
- if suffix == ".vasp":
48
- return "vasp"
49
- return None
50
-
51
-
52
- def _normalize_output(path):
53
- suffix = Path(path).suffix.lower()
54
- if suffix in {".xyz", ".extxyz"}:
55
- _precision.normalize_extxyz(path)
56
- elif suffix == ".vasp":
57
- _precision.normalize_vasp(path)
58
-
59
-
60
- def _write(path, atoms):
61
- path = Path(path)
62
- if _cifio.is_cif(path):
63
- if isinstance(atoms, (list, tuple)) and len(atoms) != 1:
64
- raise ValueError("CIF output supports exactly one structure per file")
65
- one = atoms[0] if isinstance(atoms, (list, tuple)) else atoms
66
- _cifio.write_cif(one, path, data_name=path.stem)
67
- return
68
-
69
- from ase.io import write
70
-
71
- path.parent.mkdir(parents=True, exist_ok=True)
72
- kwargs = {}
73
- output_format = _ase_format(path)
74
- if output_format:
75
- kwargs["format"] = output_format
76
- if output_format == "vasp":
77
- kwargs["direct"] = True
78
- try:
79
- write(str(path), atoms, **kwargs)
80
- _normalize_output(path)
81
- return
82
- except Exception as ase_error:
83
- if isinstance(atoms, (list, tuple)) and len(atoms) != 1:
84
- raise ValueError(
85
- f"ASE cannot write this multi-frame trajectory to {path}: {ase_error}"
86
- ) from ase_error
87
- one = atoms[0] if isinstance(atoms, (list, tuple)) else atoms
88
- try:
89
- from pymatgen.io.ase import AseAtomsAdaptor
90
-
91
- if one.pbc.any():
92
- obj = AseAtomsAdaptor.get_structure(one)
93
- else:
94
- obj = AseAtomsAdaptor.get_molecule(one)
95
- obj.to(filename=str(path))
96
- _normalize_output(path)
97
- except Exception as pmg_error:
98
- raise ValueError(
99
- f"neither ASE nor pymatgen could write {path}: "
100
- f"ASE={ase_error}; pymatgen={pmg_error}"
101
- ) from pmg_error
102
-
103
-
104
- def convert_one(input_path, output_path, trajectory=False):
105
- atoms = _read(input_path, all_frames=trajectory)
106
- _write(output_path, atoms)
107
- count = len(atoms) if trajectory else 1
108
- label = f" ({count} frame(s))" if trajectory else ""
109
- print(f"{input_path} -> {output_path}{label}")
110
-
111
-
112
- def add_arguments(parser: argparse.ArgumentParser) -> None:
113
- parser.add_argument("-i", "-input", dest="input",
114
- help="Single input structure.")
115
- parser.add_argument("-o", "-output", dest="output",
116
- help="Single output structure; with -if, output folder.")
117
- parser.add_argument("-it", "-input-trajectory", dest="input_trajectory",
118
- help="Input trajectory containing multiple structures.")
119
- parser.add_argument("-ot", "-output-trajectory", dest="output_trajectory",
120
- help="Output trajectory.")
121
- parser.add_argument("-if", "-input-folder", dest="input_folder",
122
- help="Folder of input structures.")
123
- parser.add_argument("-of", "-output-folder", dest="output_folder",
124
- help="Output folder for batch conversion.")
125
- parser.add_argument("-oe", "-output-extension", dest="output_extension",
126
- default="xyz",
127
- help="Batch/scan output extension (default: xyz).")
128
-
129
-
130
- def _extension(value):
131
- value = str(value).strip()
132
- return value if value.startswith(".") else "." + value
133
-
134
-
135
- def run(args) -> int:
136
- modes = [bool(args.input), bool(args.input_trajectory), bool(args.input_folder)]
137
- if sum(modes) > 1:
138
- raise SystemExit("Error: use only one of -i, -it, or -if.")
139
-
140
- if args.input:
141
- if not args.output:
142
- raise SystemExit("Error: -o is required with -i.")
143
- convert_one(args.input, args.output)
144
- return 0
145
-
146
- if args.input_trajectory:
147
- if not args.output_trajectory:
148
- raise SystemExit("Error: -ot is required with -it.")
149
- convert_one(args.input_trajectory, args.output_trajectory, trajectory=True)
150
- return 0
151
-
152
- extension = _extension(args.output_extension)
153
- if args.input_folder:
154
- input_folder = Path(args.input_folder)
155
- if args.output and args.output_folder:
156
- raise SystemExit("Error: use only one of -o or -of with -if.")
157
- folder_option = args.output_folder or args.output
158
- output_folder = (
159
- Path(folder_option) if folder_option
160
- else input_folder.with_name(input_folder.name + "-converted")
161
- )
162
- output_folder.mkdir(parents=True, exist_ok=True)
163
- files = _fileio.structure_files(input_folder)
164
- for path in files:
165
- convert_one(path, output_folder / (path.stem + extension))
166
- print(f"Done: converted {len(files)} file(s) to {output_folder}")
167
- return 0
168
-
169
- count = 0
170
- for folder in _fileio.subfolders("."):
171
- for path in sorted(folder.iterdir()):
172
- if not _fileio.is_structure(path):
173
- continue
174
- if path.suffix.lower() == extension.lower():
175
- continue
176
- name = args.output if args.output else path.stem + extension
177
- convert_one(path, folder / name)
178
- count += 1
179
- print(f"Done: converted {count} file(s) across sub-folders.")
180
- return 0
181
-
182
-
183
- if __name__ == "__main__":
184
- _parser = argparse.ArgumentParser(description=__doc__)
185
- add_arguments(_parser)
186
- raise SystemExit(run(_parser.parse_args()))
File without changes
File without changes