pymdkit 1.5.2__tar.gz → 1.5.4__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.4}/PKG-INFO +24 -5
  2. {pymdkit-1.5.2 → pymdkit-1.5.4}/README.md +24 -5
  3. {pymdkit-1.5.2 → pymdkit-1.5.4}/pyproject.toml +1 -1
  4. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/__init__.py +1 -1
  5. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_cifio.py +1 -1
  6. pymdkit-1.5.4/src/pymdkit/commands/convert.py +488 -0
  7. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/gpumd_msd.py +45 -20
  8. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/realloc_occ.py +93 -10
  9. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit.egg-info/PKG-INFO +24 -5
  10. pymdkit-1.5.2/src/pymdkit/commands/convert.py +0 -186
  11. {pymdkit-1.5.2 → pymdkit-1.5.4}/LICENSE +0 -0
  12. {pymdkit-1.5.2 → pymdkit-1.5.4}/setup.cfg +0 -0
  13. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/__init__.py +0 -0
  14. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_extxyz.py +0 -0
  15. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_fileio.py +0 -0
  16. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_geometry.py +0 -0
  17. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_gpumd.py +0 -0
  18. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_precision.py +0 -0
  19. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_reduction.py +0 -0
  20. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_vasp_output.py +0 -0
  21. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/_vaspset.py +0 -0
  22. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/add_config_type.py +0 -0
  23. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/chemsys_entry.py +0 -0
  24. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/compute_ehull.py +0 -0
  25. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/compute_rmsd.py +0 -0
  26. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/ewald.py +0 -0
  27. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/gather_fs.py +0 -0
  28. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/gpumd_group.py +0 -0
  29. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/gpumd_relax.py +0 -0
  30. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/gpumd_thermo.py +0 -0
  31. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
  32. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/nep_rmse.py +0 -0
  33. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/pca.py +0 -0
  34. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/perturb.py +0 -0
  35. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/submit_vasp.py +0 -0
  36. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/substitute.py +0 -0
  37. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/supercell.py +0 -0
  38. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/symmetrize.py +0 -0
  39. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/trj_extract.py +0 -0
  40. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/trj_sparse.py +0 -0
  41. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/umap.py +0 -0
  42. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/vasp2xyz.py +0 -0
  43. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/vasp_fe.py +0 -0
  44. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/vasp_relax.py +0 -0
  45. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/commands/vasp_static.py +0 -0
  46. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit/pymdkit_main.py +0 -0
  47. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit.egg-info/SOURCES.txt +0 -0
  48. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  49. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit.egg-info/entry_points.txt +0 -0
  50. {pymdkit-1.5.2 → pymdkit-1.5.4}/src/pymdkit.egg-info/requires.txt +0 -0
  51. {pymdkit-1.5.2 → pymdkit-1.5.4}/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.4
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.125 0.25 0.375 0.5 0.625 0.75 0.875 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,12 @@ 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 fixed-width, right-aligned row per job folder. Conductivities use two
218
+ decimal places; diffusion coefficients use compact scientific notation with
219
+ two decimal places. If the
220
+ mobile ion occupies multiple groups, conductivities are summed and diffusion
221
+ coefficients are particle-count-weighted.
216
222
 
217
223
  Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
218
224
  of the final SDC-producing MD run and the official 18-column
@@ -231,7 +237,9 @@ oxidation-number loop, and preserves the original symmetry, coordinates,
231
237
  Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
232
238
  the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
233
239
  and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
234
- `-formula` options.
240
+ `-formula` options. Add `-occ` followed by the only permitted occupancy values;
241
+ three decimal places are supported, so grids containing `0.125`, `0.375`,
242
+ `0.625`, and `0.875` are preserved exactly in generated CIF files.
235
243
 
236
244
  `gather-fs` recursively scans all job folders below the current directory. For
237
245
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -298,7 +306,7 @@ and `Cl`. Oxidation numbers remain in the separate
298
306
 
299
307
  Newly serialized floating-point values use eight digits after the decimal
300
308
  point across PMK structure, trajectory, data, report, and screen output. CIF
301
- `_atom_site_occupancy` values use two digits and
309
+ `_atom_site_occupancy` values use three digits and
302
310
  `_atom_type_oxidation_number` values use one digit. In extended XYZ, zero components
303
311
  inside the second-line `Lattice="..."` value remain `0.0`; other generated
304
312
  floating-point values use eight digits. Commands that select or concatenate
@@ -318,6 +326,17 @@ Single-file and trajectory modes infer the target format from `-o` and `-ot`.
318
326
  Because Wyckoff metadata describes one crystal structure, each `.cif` output
319
327
  file accepts exactly one frame.
320
328
 
329
+ `convert -d2o` orders partially occupied CIF structures. `-sc-matrix 1x1x1`
330
+ means a 1x1x1 cell; three diagonal factors or nine integer matrix entries are
331
+ also accepted. Folder ordering scans recursively and mirrors input subfolders.
332
+ It uses pymatgen `EnumerateStructureTransformation` with one worker to remove
333
+ symmetry-equivalent orderings and select the lowest Ewald energy, then falls
334
+ back to bounded `OrderDisorderedStructureTransformation` when enumlib is not
335
+ available. Equivalent results from separate disorder files are merged and
336
+ written as `BASE-o1.cif`, `BASE-o2.cif`, and so on. For convenience, supplying
337
+ `-sc-matrix` without `-d2o` automatically enables ordering when a disordered
338
+ CIF is detected; ordered inputs instead receive an ordinary supercell.
339
+
321
340
  After `supercell -temp` creates all requested jobs, it checks generated folders
322
341
  that contain `run.in`, `model.xyz`, and `nep.txt`. Existing `ensemble npt_scr`
323
342
  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.125 0.25 0.375 0.5 0.625 0.75 0.875 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,12 @@ 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 fixed-width, right-aligned row per job folder. Conductivities use two
191
+ decimal places; diffusion coefficients use compact scientific notation with
192
+ two decimal places. If the
193
+ mobile ion occupies multiple groups, conductivities are summed and diffusion
194
+ coefficients are particle-count-weighted.
189
195
 
190
196
  Ionic conductivity uses the Nernst-Einstein relation with the mean temperature
191
197
  of the final SDC-producing MD run and the official 18-column
@@ -204,7 +210,9 @@ oxidation-number loop, and preserves the original symmetry, coordinates,
204
210
  Wyckoff labels, and CIF layout. Every input receives `OUTPUT/INPUT_STEM/`; for
205
211
  the supplied Li4YCl6 example, the two outputs are `...-d1.cif` with `Li1=0.50`
206
212
  and `...-d2.cif` with `Li2=0.50`. Folder mode uses `-if` with the same `-of` and
207
- `-formula` options.
213
+ `-formula` options. Add `-occ` followed by the only permitted occupancy values;
214
+ three decimal places are supported, so grids containing `0.125`, `0.375`,
215
+ `0.625`, and `0.875` are preserved exactly in generated CIF files.
208
216
 
209
217
  `gather-fs` recursively scans all job folders below the current directory. For
210
218
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -271,7 +279,7 @@ and `Cl`. Oxidation numbers remain in the separate
271
279
 
272
280
  Newly serialized floating-point values use eight digits after the decimal
273
281
  point across PMK structure, trajectory, data, report, and screen output. CIF
274
- `_atom_site_occupancy` values use two digits and
282
+ `_atom_site_occupancy` values use three digits and
275
283
  `_atom_type_oxidation_number` values use one digit. In extended XYZ, zero components
276
284
  inside the second-line `Lattice="..."` value remain `0.0`; other generated
277
285
  floating-point values use eight digits. Commands that select or concatenate
@@ -291,6 +299,17 @@ Single-file and trajectory modes infer the target format from `-o` and `-ot`.
291
299
  Because Wyckoff metadata describes one crystal structure, each `.cif` output
292
300
  file accepts exactly one frame.
293
301
 
302
+ `convert -d2o` orders partially occupied CIF structures. `-sc-matrix 1x1x1`
303
+ means a 1x1x1 cell; three diagonal factors or nine integer matrix entries are
304
+ also accepted. Folder ordering scans recursively and mirrors input subfolders.
305
+ It uses pymatgen `EnumerateStructureTransformation` with one worker to remove
306
+ symmetry-equivalent orderings and select the lowest Ewald energy, then falls
307
+ back to bounded `OrderDisorderedStructureTransformation` when enumlib is not
308
+ available. Equivalent results from separate disorder files are merged and
309
+ written as `BASE-o1.cif`, `BASE-o2.cif`, and so on. For convenience, supplying
310
+ `-sc-matrix` without `-d2o` automatically enables ordering when a disordered
311
+ CIF is detected; ordered inputs instead receive an ordinary supercell.
312
+
294
313
  After `supercell -temp` creates all requested jobs, it checks generated folders
295
314
  that contain `run.in`, `model.xyz`, and `nep.txt`. Existing `ensemble npt_scr`
296
315
  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.4"
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.4"
@@ -198,7 +198,7 @@ def _format_cif_scalar(value, precision, force=False):
198
198
  def _format_cif_numbers(block):
199
199
  for tag, value in list(block.data.items()):
200
200
  if tag == OCCUPANCY_TAG:
201
- precision = 2
201
+ precision = 3
202
202
  elif tag == OXIDATION_TAG:
203
203
  precision = 1
204
204
  else:
@@ -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()))