pymdkit 1.6.2__tar.gz → 1.6.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.
- {pymdkit-1.6.2 → pymdkit-1.6.4}/PKG-INFO +32 -4
- {pymdkit-1.6.2 → pymdkit-1.6.4}/README.md +31 -3
- {pymdkit-1.6.2 → pymdkit-1.6.4}/pyproject.toml +1 -1
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/__init__.py +1 -1
- pymdkit-1.6.4/src/pymdkit/_config.py +89 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_cifio.py +19 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_symmetry.py +1 -3
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_vaspset.py +3 -5
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/compute_rmsd.py +3 -4
- pymdkit-1.6.4/src/pymdkit/commands/config.py +45 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/convert.py +2 -5
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/ewald.py +6 -3
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/realloc_occ.py +2 -4
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/substitute.py +1 -3
- pymdkit-1.6.4/src/pymdkit/pymdkit_main.py +253 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/PKG-INFO +32 -4
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/SOURCES.txt +3 -0
- pymdkit-1.6.4/tests/test_config_logging.py +97 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_lattice_symmetry.py +34 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_realloc_occ.py +2 -2
- pymdkit-1.6.2/src/pymdkit/pymdkit_main.py +0 -123
- {pymdkit-1.6.2 → pymdkit-1.6.4}/LICENSE +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/setup.cfg +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/__init__.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_extxyz.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_fileio.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_geometry.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_gpumd.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_lattice.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_oxidation.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_precision.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_reduction.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_supercell.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_vasp_output.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/add_config_type.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/chemsys_entry.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/compute_ehull.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gather_fs.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_group.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_msd.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_relax.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_thermo.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/nep_rmse.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/pca.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/perturb.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/submit_vasp.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/supercell.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/trj_extract.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/trj_sparse.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/umap.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp2xyz.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp_fe.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp_relax.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp_static.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/dependency_links.txt +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/entry_points.txt +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/requires.txt +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/top_level.txt +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_convert.py +0 -0
- {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_vaspset.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymdkit
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -63,6 +63,27 @@ pmk -help # lists every command
|
|
|
63
63
|
pmk <command> -help # shows that command's flags
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
## Global configuration and logs
|
|
67
|
+
|
|
68
|
+
On first use, PMK creates `.pmkrc.yaml` in the active Python environment root.
|
|
69
|
+
For example, an executable at `~/anaconda3/envs/pymdkit/bin/pmk` uses
|
|
70
|
+
`~/anaconda3/envs/pymdkit/.pmkrc.yaml`. View the active file and settings with:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pmk config
|
|
74
|
+
pmk config silence -on
|
|
75
|
+
pmk config silence -off
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
With silence enabled, successful commands do not print to the terminal. Errors
|
|
79
|
+
that prevent an operation are still shown. Command details are never discarded:
|
|
80
|
+
every `pmk <command> ...` invocation appends its exact command, timestamp, exit
|
|
81
|
+
status, and screen output to `<command>.txt` in the directory where PMK was
|
|
82
|
+
invoked. Repeated runs remain in chronological order in the same file. This log
|
|
83
|
+
always stays in the invocation directory even when `-of` selects an output
|
|
84
|
+
folder. Existing command reports that use the same filename, such as `rmsd.txt`
|
|
85
|
+
and `ehull.txt`, are preserved as the report section of that run.
|
|
86
|
+
|
|
66
87
|
## Commands
|
|
67
88
|
|
|
68
89
|
Commands that transform structures accept either a single file (`-i`/`-o`) or a
|
|
@@ -71,6 +92,7 @@ directory for job sub-folders automatically.
|
|
|
71
92
|
|
|
72
93
|
| Command | What it does |
|
|
73
94
|
|---|---|
|
|
95
|
+
| `config` | View or edit global PMK settings in `.pmkrc.yaml` |
|
|
74
96
|
| `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
|
|
75
97
|
| `gpumd-group` | Tag atoms with a GPUMD group index by element order |
|
|
76
98
|
| `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
|
|
@@ -265,7 +287,7 @@ first, until every multiplicity times occupancy times supercell determinant is
|
|
|
265
287
|
an integer. Equal-volume choices favor the most isotropic resulting cell.
|
|
266
288
|
All selections, site atom counts, oxidation states, charges, failures, the
|
|
267
289
|
allowed occupancy grid, and the final summary are recorded in
|
|
268
|
-
`
|
|
290
|
+
`realloc-occ.txt` in the invocation directory for both single-file and folder modes.
|
|
269
291
|
|
|
270
292
|
`gather-fs` recursively scans all job folders below the current directory. For
|
|
271
293
|
VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
|
|
@@ -336,8 +358,14 @@ CIF input with pymatgen rather than ASE. ASE interprets
|
|
|
336
358
|
`_space_group_crystal_system` as a setting hint only for rhombohedral groups
|
|
337
359
|
and emits a potentially misleading warning for other crystal systems. Using
|
|
338
360
|
pymatgen avoids that ambiguity while PMK continues to use ASE for non-CIF
|
|
339
|
-
formats.
|
|
340
|
-
|
|
361
|
+
formats. Pymatgen intentionally snaps fractional coordinates within its
|
|
362
|
+
default `1e-4` tolerance to ideal values before applying symmetry. PMK keeps
|
|
363
|
+
that corrective behavior but hides only its corresponding finite-precision
|
|
364
|
+
notice; composition, occupancy, malformed-CIF, and all other warnings remain
|
|
365
|
+
visible. See the
|
|
366
|
+
[pymatgen CifParser documentation](https://pymatgen.org/pymatgen.io.html#pymatgen.io.cif.CifParser).
|
|
367
|
+
A partially occupied CIF must first be ordered with `convert -d2o` before it
|
|
368
|
+
can be represented as an explicit atom list.
|
|
341
369
|
|
|
342
370
|
CIF `_atom_type_symbol` and `_atom_site_label` values use per-element
|
|
343
371
|
inequivalent-site identifiers such as `Li1`, `Y1`, `Cl1`, and `Cl2`.
|
|
@@ -36,6 +36,27 @@ pmk -help # lists every command
|
|
|
36
36
|
pmk <command> -help # shows that command's flags
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
## Global configuration and logs
|
|
40
|
+
|
|
41
|
+
On first use, PMK creates `.pmkrc.yaml` in the active Python environment root.
|
|
42
|
+
For example, an executable at `~/anaconda3/envs/pymdkit/bin/pmk` uses
|
|
43
|
+
`~/anaconda3/envs/pymdkit/.pmkrc.yaml`. View the active file and settings with:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pmk config
|
|
47
|
+
pmk config silence -on
|
|
48
|
+
pmk config silence -off
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With silence enabled, successful commands do not print to the terminal. Errors
|
|
52
|
+
that prevent an operation are still shown. Command details are never discarded:
|
|
53
|
+
every `pmk <command> ...` invocation appends its exact command, timestamp, exit
|
|
54
|
+
status, and screen output to `<command>.txt` in the directory where PMK was
|
|
55
|
+
invoked. Repeated runs remain in chronological order in the same file. This log
|
|
56
|
+
always stays in the invocation directory even when `-of` selects an output
|
|
57
|
+
folder. Existing command reports that use the same filename, such as `rmsd.txt`
|
|
58
|
+
and `ehull.txt`, are preserved as the report section of that run.
|
|
59
|
+
|
|
39
60
|
## Commands
|
|
40
61
|
|
|
41
62
|
Commands that transform structures accept either a single file (`-i`/`-o`) or a
|
|
@@ -44,6 +65,7 @@ directory for job sub-folders automatically.
|
|
|
44
65
|
|
|
45
66
|
| Command | What it does |
|
|
46
67
|
|---|---|
|
|
68
|
+
| `config` | View or edit global PMK settings in `.pmkrc.yaml` |
|
|
47
69
|
| `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
|
|
48
70
|
| `gpumd-group` | Tag atoms with a GPUMD group index by element order |
|
|
49
71
|
| `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
|
|
@@ -238,7 +260,7 @@ first, until every multiplicity times occupancy times supercell determinant is
|
|
|
238
260
|
an integer. Equal-volume choices favor the most isotropic resulting cell.
|
|
239
261
|
All selections, site atom counts, oxidation states, charges, failures, the
|
|
240
262
|
allowed occupancy grid, and the final summary are recorded in
|
|
241
|
-
`
|
|
263
|
+
`realloc-occ.txt` in the invocation directory for both single-file and folder modes.
|
|
242
264
|
|
|
243
265
|
`gather-fs` recursively scans all job folders below the current directory. For
|
|
244
266
|
VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
|
|
@@ -309,8 +331,14 @@ CIF input with pymatgen rather than ASE. ASE interprets
|
|
|
309
331
|
`_space_group_crystal_system` as a setting hint only for rhombohedral groups
|
|
310
332
|
and emits a potentially misleading warning for other crystal systems. Using
|
|
311
333
|
pymatgen avoids that ambiguity while PMK continues to use ASE for non-CIF
|
|
312
|
-
formats.
|
|
313
|
-
|
|
334
|
+
formats. Pymatgen intentionally snaps fractional coordinates within its
|
|
335
|
+
default `1e-4` tolerance to ideal values before applying symmetry. PMK keeps
|
|
336
|
+
that corrective behavior but hides only its corresponding finite-precision
|
|
337
|
+
notice; composition, occupancy, malformed-CIF, and all other warnings remain
|
|
338
|
+
visible. See the
|
|
339
|
+
[pymatgen CifParser documentation](https://pymatgen.org/pymatgen.io.html#pymatgen.io.cif.CifParser).
|
|
340
|
+
A partially occupied CIF must first be ordered with `convert -d2o` before it
|
|
341
|
+
can be represented as an explicit atom list.
|
|
314
342
|
|
|
315
343
|
CIF `_atom_type_symbol` and `_atom_site_label` values use per-element
|
|
316
344
|
inequivalent-site identifiers such as `Li1`, `Y1`, `Cl1`, and `Cl2`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Global PMK configuration stored in the active Python environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG: Dict[str, Any] = {"silence": False}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_path() -> Path:
|
|
16
|
+
"""Return the PMK configuration file for the active Python environment."""
|
|
17
|
+
override = os.environ.get("PMK_CONFIG_FILE")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override).expanduser().resolve()
|
|
20
|
+
return Path(sys.prefix).resolve() / ".pmkrc.yaml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_scalar(raw: str) -> Any:
|
|
24
|
+
value = raw.strip()
|
|
25
|
+
lowered = value.lower()
|
|
26
|
+
if lowered in {"true", "yes", "on"}:
|
|
27
|
+
return True
|
|
28
|
+
if lowered in {"false", "no", "off"}:
|
|
29
|
+
return False
|
|
30
|
+
if lowered in {"null", "none", "~"}:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return ast.literal_eval(value)
|
|
34
|
+
except (SyntaxError, ValueError):
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_flat_yaml(path: Path) -> Dict[str, Any]:
|
|
39
|
+
settings: Dict[str, Any] = {}
|
|
40
|
+
for line_number, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
41
|
+
stripped = line.strip()
|
|
42
|
+
if not stripped or stripped.startswith("#"):
|
|
43
|
+
continue
|
|
44
|
+
if ":" not in line:
|
|
45
|
+
raise ValueError(f"invalid YAML at {path}:{line_number}: expected 'name: value'")
|
|
46
|
+
key, raw = line.split(":", 1)
|
|
47
|
+
key = key.strip()
|
|
48
|
+
if not key or any(char.isspace() for char in key):
|
|
49
|
+
raise ValueError(f"invalid setting name at {path}:{line_number}")
|
|
50
|
+
settings[key] = _parse_scalar(raw)
|
|
51
|
+
return settings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_scalar(value: Any) -> str:
|
|
55
|
+
if isinstance(value, bool):
|
|
56
|
+
return "true" if value else "false"
|
|
57
|
+
if value is None:
|
|
58
|
+
return "null"
|
|
59
|
+
if isinstance(value, (int, float)):
|
|
60
|
+
return str(value)
|
|
61
|
+
return json.dumps(str(value), ensure_ascii=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_config(settings: Dict[str, Any], path: Optional[Path] = None) -> Path:
|
|
65
|
+
destination = config_path() if path is None else Path(path)
|
|
66
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
ordered_keys = [key for key in DEFAULT_CONFIG if key in settings]
|
|
68
|
+
ordered_keys.extend(sorted(key for key in settings if key not in DEFAULT_CONFIG))
|
|
69
|
+
text = "# Global PMK settings. Edit with `pmk config` or a text editor.\n"
|
|
70
|
+
text += "".join(f"{key}: {_format_scalar(settings[key])}\n" for key in ordered_keys)
|
|
71
|
+
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
|
72
|
+
temporary.write_text(text, encoding="utf-8")
|
|
73
|
+
temporary.replace(destination)
|
|
74
|
+
return destination
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config(*, create: bool = True) -> Dict[str, Any]:
|
|
78
|
+
path = config_path()
|
|
79
|
+
if path.exists():
|
|
80
|
+
settings = _read_flat_yaml(path)
|
|
81
|
+
else:
|
|
82
|
+
settings = {}
|
|
83
|
+
merged = dict(DEFAULT_CONFIG)
|
|
84
|
+
merged.update(settings)
|
|
85
|
+
if not isinstance(merged["silence"], bool):
|
|
86
|
+
raise ValueError(f"setting 'silence' in {path} must be true or false")
|
|
87
|
+
if create and not path.exists():
|
|
88
|
+
save_config(merged, path)
|
|
89
|
+
return merged
|
|
@@ -41,12 +41,31 @@ CIF_FLOAT_TAGS = {
|
|
|
41
41
|
_CIF_DECIMAL_RE = re.compile(
|
|
42
42
|
r"[-+]?(?:(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?|\d+[eE][-+]?\d+)"
|
|
43
43
|
)
|
|
44
|
+
_BENIGN_FRACTIONAL_ROUNDING_WARNING = (
|
|
45
|
+
r"Issues encountered while parsing CIF:\s*\d+ fractional coordinates? "
|
|
46
|
+
r"rounded to ideal values to avoid issues with finite precision\.\s*$"
|
|
47
|
+
)
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
def is_cif(path):
|
|
47
51
|
return Path(path).suffix.lower() == ".cif"
|
|
48
52
|
|
|
49
53
|
|
|
54
|
+
def read_structure(path):
|
|
55
|
+
"""Read with pymatgen, hiding only its benign CIF idealization notice."""
|
|
56
|
+
from pymatgen.core import Structure
|
|
57
|
+
|
|
58
|
+
if not is_cif(path):
|
|
59
|
+
return Structure.from_file(str(path))
|
|
60
|
+
with warnings.catch_warnings():
|
|
61
|
+
warnings.filterwarnings(
|
|
62
|
+
"ignore",
|
|
63
|
+
message=_BENIGN_FRACTIONAL_ROUNDING_WARNING,
|
|
64
|
+
category=UserWarning,
|
|
65
|
+
)
|
|
66
|
+
return Structure.from_file(str(path))
|
|
67
|
+
|
|
68
|
+
|
|
50
69
|
def as_pymatgen_structure(structure):
|
|
51
70
|
"""Return a periodic pymatgen Structure from pymatgen or ASE input."""
|
|
52
71
|
from pymatgen.core import Structure
|
|
@@ -36,11 +36,9 @@ DEFAULT_OXIDATION_STATES = {
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def load_structure(path):
|
|
39
|
-
from pymatgen.core import Structure
|
|
40
|
-
|
|
41
39
|
path = Path(path)
|
|
42
40
|
try:
|
|
43
|
-
return
|
|
41
|
+
return _cifio.read_structure(path)
|
|
44
42
|
except Exception as pymatgen_error:
|
|
45
43
|
if _cifio.is_cif(path):
|
|
46
44
|
raise ValueError(
|
|
@@ -17,12 +17,12 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import argparse
|
|
19
19
|
import ast
|
|
20
|
-
import warnings
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
|
|
23
22
|
try:
|
|
24
|
-
from . import _lattice, _precision, _supercell
|
|
23
|
+
from . import _cifio, _lattice, _precision, _supercell
|
|
25
24
|
except ImportError:
|
|
25
|
+
import _cifio
|
|
26
26
|
import _lattice
|
|
27
27
|
import _precision
|
|
28
28
|
import _supercell
|
|
@@ -216,8 +216,6 @@ def _run_trajectory(args, set_cls, settings):
|
|
|
216
216
|
def run_vasp_set(args, set_cls, default_settings):
|
|
217
217
|
"""Write *set_cls* input files for one structure (-i), a folder (-if), or a
|
|
218
218
|
trajectory (-it). Defaults can be overridden via ``-custom-setting FILE``."""
|
|
219
|
-
warnings.filterwarnings("ignore", category=UserWarning, module="pymatgen")
|
|
220
|
-
from pymatgen.core import Structure
|
|
221
219
|
|
|
222
220
|
settings = dict(default_settings)
|
|
223
221
|
if args.custom_setting:
|
|
@@ -244,7 +242,7 @@ def run_vasp_set(args, set_cls, default_settings):
|
|
|
244
242
|
written = 0
|
|
245
243
|
for struct_file, out_dir in jobs:
|
|
246
244
|
try:
|
|
247
|
-
structure =
|
|
245
|
+
structure = _cifio.read_structure(struct_file)
|
|
248
246
|
except Exception as e: # noqa: BLE001 - report and continue in batch
|
|
249
247
|
print(f"{struct_file}: cannot read ({e}) -> skipping")
|
|
250
248
|
continue
|
|
@@ -11,8 +11,9 @@ from itertools import combinations
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
try:
|
|
14
|
-
from . import _fileio
|
|
14
|
+
from . import _cifio, _fileio
|
|
15
15
|
except ImportError:
|
|
16
|
+
import _cifio
|
|
16
17
|
import _fileio
|
|
17
18
|
|
|
18
19
|
COMMAND = "rmsd"
|
|
@@ -49,11 +50,9 @@ def create_matcher():
|
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
def load_structure(path):
|
|
52
|
-
from pymatgen.core import Structure
|
|
53
|
-
|
|
54
53
|
path = Path(path)
|
|
55
54
|
try:
|
|
56
|
-
return
|
|
55
|
+
return _cifio.read_structure(path)
|
|
57
56
|
except Exception as pymatgen_error:
|
|
58
57
|
if path.suffix.lower() == ".cif":
|
|
59
58
|
print(f" [warn] pymatgen could not read CIF {path}: {pymatgen_error}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""View or edit global PMK settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pymdkit._config import config_path, load_config, save_config
|
|
6
|
+
|
|
7
|
+
COMMAND = "config"
|
|
8
|
+
HELP = "View or edit global PMK settings."
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_arguments(parser):
|
|
12
|
+
parser.add_argument("setting", nargs="?", choices=("silence",),
|
|
13
|
+
help="Setting to update; omit it to show all settings.")
|
|
14
|
+
switch = parser.add_mutually_exclusive_group()
|
|
15
|
+
switch.add_argument("-on", dest="value", action="store_const", const=True,
|
|
16
|
+
help="Enable the selected setting.")
|
|
17
|
+
switch.add_argument("-off", dest="value", action="store_const", const=False,
|
|
18
|
+
help="Disable the selected setting.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(args):
|
|
22
|
+
settings = load_config()
|
|
23
|
+
value = getattr(args, "value", None)
|
|
24
|
+
if args.setting is None:
|
|
25
|
+
if value is not None:
|
|
26
|
+
print("Error: -on or -off requires a setting name.")
|
|
27
|
+
return 2
|
|
28
|
+
print(f"Configuration file: {config_path()}")
|
|
29
|
+
for name in sorted(settings):
|
|
30
|
+
if settings[name] is True:
|
|
31
|
+
shown = "on"
|
|
32
|
+
elif settings[name] is False:
|
|
33
|
+
shown = "off"
|
|
34
|
+
else:
|
|
35
|
+
shown = settings[name]
|
|
36
|
+
print(f"{name}: {shown}")
|
|
37
|
+
return 0
|
|
38
|
+
if value is None:
|
|
39
|
+
print(f"Error: pmk config {args.setting} requires -on or -off.")
|
|
40
|
+
return 2
|
|
41
|
+
settings[args.setting] = value
|
|
42
|
+
path = save_config(settings)
|
|
43
|
+
print(f"{args.setting}: {'on' if value else 'off'}")
|
|
44
|
+
print(f"Updated {path}")
|
|
45
|
+
return 0
|
|
@@ -23,10 +23,9 @@ HELP = "Convert structures, refine CIF symmetry, or order disorder."
|
|
|
23
23
|
|
|
24
24
|
def _read_cif(path):
|
|
25
25
|
"""Read CIF with pymatgen, avoiding ASE's setting-name ambiguity."""
|
|
26
|
-
from pymatgen.core import Structure
|
|
27
26
|
from pymatgen.io.ase import AseAtomsAdaptor
|
|
28
27
|
|
|
29
|
-
structure =
|
|
28
|
+
structure = _cifio.read_structure(path)
|
|
30
29
|
if not structure.is_ordered:
|
|
31
30
|
raise ValueError(
|
|
32
31
|
"partially occupied CIF cannot be converted to an explicit atom "
|
|
@@ -149,9 +148,7 @@ def _write(
|
|
|
149
148
|
) from pmg_error
|
|
150
149
|
|
|
151
150
|
def _read_structure(path):
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return Structure.from_file(str(path))
|
|
151
|
+
return _cifio.read_structure(path)
|
|
155
152
|
|
|
156
153
|
|
|
157
154
|
def _write_structure(
|
|
@@ -14,14 +14,17 @@ from __future__ import annotations
|
|
|
14
14
|
import argparse
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
try:
|
|
18
|
+
from . import _cifio
|
|
19
|
+
except ImportError:
|
|
20
|
+
import _cifio
|
|
21
|
+
|
|
17
22
|
COMMAND = "ewald"
|
|
18
23
|
HELP = "Compute CIF electrostatic energy using pymatgen EwaldSummation."
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
def read_charged_structure(path):
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
structure = Structure.from_file(str(path))
|
|
27
|
+
structure = _cifio.read_structure(path)
|
|
25
28
|
missing = []
|
|
26
29
|
for site in structure:
|
|
27
30
|
for specie in site.species:
|
|
@@ -580,9 +580,7 @@ def _write_candidate(
|
|
|
580
580
|
temporary = output.with_name(f".{output.stem}.base.cif")
|
|
581
581
|
try:
|
|
582
582
|
render_candidate(parsed, occupancies, target, target_counts, z, temporary)
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
structure = Structure.from_file(temporary)
|
|
583
|
+
structure = _cifio.read_structure(temporary)
|
|
586
584
|
structure.make_supercell(list(matrix))
|
|
587
585
|
_cifio.write_cif(structure, output, data_name=output.stem, refine=False)
|
|
588
586
|
finally:
|
|
@@ -730,7 +728,7 @@ def run(args):
|
|
|
730
728
|
f"input structure(s) to {output_root}."
|
|
731
729
|
)
|
|
732
730
|
_emit(report, summary)
|
|
733
|
-
report_path =
|
|
731
|
+
report_path = Path.cwd() / "realloc-occ.txt"
|
|
734
732
|
report_path.write_text("\n".join(report) + "\n", encoding="utf-8")
|
|
735
733
|
print(f"Wrote {report_path}")
|
|
736
734
|
return 0 if inputs_done == len(sources) else 1
|
|
@@ -207,11 +207,9 @@ def read_cif_labels(path):
|
|
|
207
207
|
|
|
208
208
|
def read_structure(path):
|
|
209
209
|
"""Read any pymatgen-readable structure, falling back to ASE."""
|
|
210
|
-
from pymatgen.core import Structure
|
|
211
|
-
|
|
212
210
|
path = Path(path)
|
|
213
211
|
try:
|
|
214
|
-
return
|
|
212
|
+
return _cifio.read_structure(path)
|
|
215
213
|
except Exception as pymatgen_error:
|
|
216
214
|
if _cifio.is_cif(path):
|
|
217
215
|
raise ValueError(
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""pmk dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ast
|
|
7
|
+
import io
|
|
8
|
+
import importlib
|
|
9
|
+
import os
|
|
10
|
+
import pkgutil
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
import traceback
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Tuple
|
|
17
|
+
|
|
18
|
+
from . import __version__, commands
|
|
19
|
+
from ._config import load_config
|
|
20
|
+
|
|
21
|
+
DESCRIPTION = "A python data processing toolkit for molecular dynamics and high-throughput calculations"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_meta(path: Path, modname: str) -> Tuple[str, str]:
|
|
25
|
+
command, help_text = modname, ""
|
|
26
|
+
try:
|
|
27
|
+
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
|
28
|
+
except (OSError, SyntaxError):
|
|
29
|
+
return command, help_text
|
|
30
|
+
for node in tree.body:
|
|
31
|
+
if isinstance(node, ast.Assign):
|
|
32
|
+
for target in node.targets:
|
|
33
|
+
if (isinstance(target, ast.Name)
|
|
34
|
+
and isinstance(node.value, ast.Constant)
|
|
35
|
+
and isinstance(node.value.value, str)):
|
|
36
|
+
if target.id == "COMMAND":
|
|
37
|
+
command = node.value.value
|
|
38
|
+
elif target.id == "HELP":
|
|
39
|
+
help_text = node.value.value
|
|
40
|
+
if not help_text:
|
|
41
|
+
doc = (ast.get_docstring(tree) or "").strip()
|
|
42
|
+
help_text = doc.splitlines()[0] if doc else ""
|
|
43
|
+
return command, help_text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _discover() -> Dict[str, Tuple[str, str]]:
|
|
47
|
+
pkg_dir = Path(commands.__path__[0])
|
|
48
|
+
found: Dict[str, Tuple[str, str]] = {}
|
|
49
|
+
for info in pkgutil.iter_modules([str(pkg_dir)]):
|
|
50
|
+
if info.name.startswith("_"):
|
|
51
|
+
continue
|
|
52
|
+
command, help_text = _read_meta(pkg_dir / f"{info.name}.py", info.name)
|
|
53
|
+
found[command] = (info.name, help_text)
|
|
54
|
+
return found
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _add_help(parser: argparse.ArgumentParser) -> None:
|
|
58
|
+
parser.add_argument("-h", "-help", action="help",
|
|
59
|
+
help="show this help message and exit")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_main_help(cmds) -> None:
|
|
63
|
+
print("pmk")
|
|
64
|
+
print()
|
|
65
|
+
print(DESCRIPTION)
|
|
66
|
+
print()
|
|
67
|
+
print(f"Version {__version__}")
|
|
68
|
+
print()
|
|
69
|
+
print("Usage: pmk <command> -[options]")
|
|
70
|
+
print()
|
|
71
|
+
print("Available commands:")
|
|
72
|
+
print()
|
|
73
|
+
for name in sorted(cmds):
|
|
74
|
+
print(f" {name:<20} {cmds[name][1]}")
|
|
75
|
+
print()
|
|
76
|
+
print("Command details: pmk <command> -h")
|
|
77
|
+
print()
|
|
78
|
+
print("Options:")
|
|
79
|
+
print(" -h, -help show this help message and exit")
|
|
80
|
+
print(" -v, -version show program's version number and exit")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_top_parser(cmds: Dict[str, Tuple[str, str]]) -> argparse.ArgumentParser:
|
|
84
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
85
|
+
_add_help(parser)
|
|
86
|
+
parser.add_argument("-v", "-version", action="version", version=f"pmk {__version__}")
|
|
87
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
88
|
+
for name in sorted(cmds):
|
|
89
|
+
sub.add_parser(name, help=cmds[name][1], add_help=False)
|
|
90
|
+
return parser
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _OutputRecorder(io.TextIOBase):
|
|
94
|
+
"""Record output while preserving normal live terminal behavior."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, terminal, *, echo: bool):
|
|
97
|
+
self.terminal = terminal
|
|
98
|
+
self.echo = echo
|
|
99
|
+
self.buffer = io.StringIO()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def encoding(self):
|
|
103
|
+
return getattr(self.terminal, "encoding", "utf-8")
|
|
104
|
+
|
|
105
|
+
def isatty(self):
|
|
106
|
+
return bool(getattr(self.terminal, "isatty", lambda: False)())
|
|
107
|
+
|
|
108
|
+
def write(self, text):
|
|
109
|
+
self.buffer.write(text)
|
|
110
|
+
if self.echo:
|
|
111
|
+
self.terminal.write(text)
|
|
112
|
+
self.terminal.flush()
|
|
113
|
+
return len(text)
|
|
114
|
+
|
|
115
|
+
def flush(self):
|
|
116
|
+
if self.echo:
|
|
117
|
+
self.terminal.flush()
|
|
118
|
+
|
|
119
|
+
def getvalue(self):
|
|
120
|
+
return self.buffer.getvalue()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _command_text(argv) -> str:
|
|
124
|
+
return shlex.join(["pmk", *argv])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _write_command_log(command: str, argv, output: str, status: int,
|
|
128
|
+
previous: str, native_report: str) -> Path:
|
|
129
|
+
path = Path.cwd() / f"{command}.txt"
|
|
130
|
+
separator = "=" * 80
|
|
131
|
+
lines = [
|
|
132
|
+
separator,
|
|
133
|
+
f"Run: {datetime.now().astimezone().isoformat(timespec='seconds')}",
|
|
134
|
+
f"Command: {_command_text(argv)}",
|
|
135
|
+
f"Status: {'success' if status == 0 else 'failed'} (exit code {status})",
|
|
136
|
+
"-" * 80,
|
|
137
|
+
]
|
|
138
|
+
details = output.rstrip()
|
|
139
|
+
if details:
|
|
140
|
+
lines.extend(("Output:", details))
|
|
141
|
+
if native_report.strip():
|
|
142
|
+
lines.extend(("Report:", native_report.rstrip()))
|
|
143
|
+
if not details and not native_report.strip():
|
|
144
|
+
lines.append("No screen output.")
|
|
145
|
+
block = "\n".join(lines) + "\n"
|
|
146
|
+
combined = previous.rstrip()
|
|
147
|
+
if combined:
|
|
148
|
+
combined += "\n\n"
|
|
149
|
+
combined += block
|
|
150
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
151
|
+
temporary.write_text(combined, encoding="utf-8")
|
|
152
|
+
temporary.replace(path)
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _native_report(path: Path, previous: str) -> str:
|
|
157
|
+
if not path.exists():
|
|
158
|
+
return ""
|
|
159
|
+
current = path.read_text(encoding="utf-8", errors="replace")
|
|
160
|
+
if current == previous:
|
|
161
|
+
return ""
|
|
162
|
+
if previous and current.startswith(previous):
|
|
163
|
+
return current[len(previous):].lstrip("\r\n")
|
|
164
|
+
return current
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_command(command: str, argv, module, parser, *, silence: bool) -> int:
|
|
168
|
+
log_path = Path.cwd() / f"{command}.txt"
|
|
169
|
+
previous = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
|
|
170
|
+
terminal_out, terminal_err = sys.stdout, sys.stderr
|
|
171
|
+
recorder = _OutputRecorder(terminal_out, echo=not silence)
|
|
172
|
+
status = 0
|
|
173
|
+
pending_exception = None
|
|
174
|
+
exception_text = ""
|
|
175
|
+
try:
|
|
176
|
+
sys.stdout = recorder
|
|
177
|
+
sys.stderr = recorder
|
|
178
|
+
try:
|
|
179
|
+
args = parser.parse_args(argv[1:])
|
|
180
|
+
result = module.run(args)
|
|
181
|
+
status = 0 if result is None else int(result)
|
|
182
|
+
except SystemExit as exc:
|
|
183
|
+
status = exc.code if isinstance(exc.code, int) else 1
|
|
184
|
+
if exc.code and not isinstance(exc.code, int):
|
|
185
|
+
print(exc.code)
|
|
186
|
+
except Exception:
|
|
187
|
+
status = 1
|
|
188
|
+
pending_exception = sys.exc_info()
|
|
189
|
+
exception_text = traceback.format_exc()
|
|
190
|
+
recorder.buffer.write(exception_text)
|
|
191
|
+
finally:
|
|
192
|
+
sys.stdout = terminal_out
|
|
193
|
+
sys.stderr = terminal_err
|
|
194
|
+
|
|
195
|
+
output = recorder.getvalue()
|
|
196
|
+
native_report = _native_report(log_path, previous)
|
|
197
|
+
try:
|
|
198
|
+
_write_command_log(command, argv, output, status, previous, native_report)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
print(f"Error: could not write {log_path}: {exc}", file=terminal_err)
|
|
201
|
+
if status == 0:
|
|
202
|
+
status = 1
|
|
203
|
+
if silence and status != 0 and pending_exception is None:
|
|
204
|
+
message = output or f"Error: {_command_text(argv)} failed with exit code {status}.\n"
|
|
205
|
+
terminal_err.write(message)
|
|
206
|
+
terminal_err.flush()
|
|
207
|
+
if pending_exception is not None:
|
|
208
|
+
_, error, tb = pending_exception
|
|
209
|
+
raise error.with_traceback(tb)
|
|
210
|
+
return status
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv=None) -> int:
|
|
214
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
215
|
+
cmds = _discover()
|
|
216
|
+
top = _build_top_parser(cmds)
|
|
217
|
+
|
|
218
|
+
if not argv:
|
|
219
|
+
print_main_help(cmds)
|
|
220
|
+
return 1
|
|
221
|
+
if argv[0] in ("-h", "-help"):
|
|
222
|
+
print_main_help(cmds)
|
|
223
|
+
return 0
|
|
224
|
+
if argv[0] in ("-v", "-version"):
|
|
225
|
+
top.parse_args(argv)
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
command = argv[0]
|
|
229
|
+
if command not in cmds:
|
|
230
|
+
print_main_help(cmds)
|
|
231
|
+
raise SystemExit(f"\nError: invalid command {command!r}")
|
|
232
|
+
|
|
233
|
+
modname = cmds[command][0]
|
|
234
|
+
module = importlib.import_module(f"{commands.__name__}.{modname}")
|
|
235
|
+
cmd_parser = argparse.ArgumentParser(
|
|
236
|
+
prog=f"pmk {command}",
|
|
237
|
+
description=getattr(module, "HELP", None) or (module.__doc__ or "").strip(),
|
|
238
|
+
add_help=False,
|
|
239
|
+
)
|
|
240
|
+
_add_help(cmd_parser)
|
|
241
|
+
if hasattr(module, "add_arguments"):
|
|
242
|
+
module.add_arguments(cmd_parser)
|
|
243
|
+
try:
|
|
244
|
+
settings = load_config()
|
|
245
|
+
except (OSError, ValueError) as exc:
|
|
246
|
+
print(f"Error: could not load PMK configuration: {exc}", file=sys.stderr)
|
|
247
|
+
return 2
|
|
248
|
+
return _run_command(command, argv, module, cmd_parser,
|
|
249
|
+
silence=settings["silence"])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymdkit
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -63,6 +63,27 @@ pmk -help # lists every command
|
|
|
63
63
|
pmk <command> -help # shows that command's flags
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
## Global configuration and logs
|
|
67
|
+
|
|
68
|
+
On first use, PMK creates `.pmkrc.yaml` in the active Python environment root.
|
|
69
|
+
For example, an executable at `~/anaconda3/envs/pymdkit/bin/pmk` uses
|
|
70
|
+
`~/anaconda3/envs/pymdkit/.pmkrc.yaml`. View the active file and settings with:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pmk config
|
|
74
|
+
pmk config silence -on
|
|
75
|
+
pmk config silence -off
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
With silence enabled, successful commands do not print to the terminal. Errors
|
|
79
|
+
that prevent an operation are still shown. Command details are never discarded:
|
|
80
|
+
every `pmk <command> ...` invocation appends its exact command, timestamp, exit
|
|
81
|
+
status, and screen output to `<command>.txt` in the directory where PMK was
|
|
82
|
+
invoked. Repeated runs remain in chronological order in the same file. This log
|
|
83
|
+
always stays in the invocation directory even when `-of` selects an output
|
|
84
|
+
folder. Existing command reports that use the same filename, such as `rmsd.txt`
|
|
85
|
+
and `ehull.txt`, are preserved as the report section of that run.
|
|
86
|
+
|
|
66
87
|
## Commands
|
|
67
88
|
|
|
68
89
|
Commands that transform structures accept either a single file (`-i`/`-o`) or a
|
|
@@ -71,6 +92,7 @@ directory for job sub-folders automatically.
|
|
|
71
92
|
|
|
72
93
|
| Command | What it does |
|
|
73
94
|
|---|---|
|
|
95
|
+
| `config` | View or edit global PMK settings in `.pmkrc.yaml` |
|
|
74
96
|
| `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
|
|
75
97
|
| `gpumd-group` | Tag atoms with a GPUMD group index by element order |
|
|
76
98
|
| `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
|
|
@@ -265,7 +287,7 @@ first, until every multiplicity times occupancy times supercell determinant is
|
|
|
265
287
|
an integer. Equal-volume choices favor the most isotropic resulting cell.
|
|
266
288
|
All selections, site atom counts, oxidation states, charges, failures, the
|
|
267
289
|
allowed occupancy grid, and the final summary are recorded in
|
|
268
|
-
`
|
|
290
|
+
`realloc-occ.txt` in the invocation directory for both single-file and folder modes.
|
|
269
291
|
|
|
270
292
|
`gather-fs` recursively scans all job folders below the current directory. For
|
|
271
293
|
VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
|
|
@@ -336,8 +358,14 @@ CIF input with pymatgen rather than ASE. ASE interprets
|
|
|
336
358
|
`_space_group_crystal_system` as a setting hint only for rhombohedral groups
|
|
337
359
|
and emits a potentially misleading warning for other crystal systems. Using
|
|
338
360
|
pymatgen avoids that ambiguity while PMK continues to use ASE for non-CIF
|
|
339
|
-
formats.
|
|
340
|
-
|
|
361
|
+
formats. Pymatgen intentionally snaps fractional coordinates within its
|
|
362
|
+
default `1e-4` tolerance to ideal values before applying symmetry. PMK keeps
|
|
363
|
+
that corrective behavior but hides only its corresponding finite-precision
|
|
364
|
+
notice; composition, occupancy, malformed-CIF, and all other warnings remain
|
|
365
|
+
visible. See the
|
|
366
|
+
[pymatgen CifParser documentation](https://pymatgen.org/pymatgen.io.html#pymatgen.io.cif.CifParser).
|
|
367
|
+
A partially occupied CIF must first be ordered with `convert -d2o` before it
|
|
368
|
+
can be represented as an explicit atom list.
|
|
341
369
|
|
|
342
370
|
CIF `_atom_type_symbol` and `_atom_site_label` values use per-element
|
|
343
371
|
inequivalent-site identifiers such as `Li1`, `Y1`, `Cl1`, and `Cl2`.
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/pymdkit/__init__.py
|
|
5
|
+
src/pymdkit/_config.py
|
|
5
6
|
src/pymdkit/pymdkit_main.py
|
|
6
7
|
src/pymdkit.egg-info/PKG-INFO
|
|
7
8
|
src/pymdkit.egg-info/SOURCES.txt
|
|
@@ -27,6 +28,7 @@ src/pymdkit/commands/add_config_type.py
|
|
|
27
28
|
src/pymdkit/commands/chemsys_entry.py
|
|
28
29
|
src/pymdkit/commands/compute_ehull.py
|
|
29
30
|
src/pymdkit/commands/compute_rmsd.py
|
|
31
|
+
src/pymdkit/commands/config.py
|
|
30
32
|
src/pymdkit/commands/convert.py
|
|
31
33
|
src/pymdkit/commands/ewald.py
|
|
32
34
|
src/pymdkit/commands/gather_fs.py
|
|
@@ -49,6 +51,7 @@ src/pymdkit/commands/vasp2xyz.py
|
|
|
49
51
|
src/pymdkit/commands/vasp_fe.py
|
|
50
52
|
src/pymdkit/commands/vasp_relax.py
|
|
51
53
|
src/pymdkit/commands/vasp_static.py
|
|
54
|
+
tests/test_config_logging.py
|
|
52
55
|
tests/test_convert.py
|
|
53
56
|
tests/test_lattice_symmetry.py
|
|
54
57
|
tests/test_realloc_occ.py
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Tests for PMK global configuration and command logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
from pymdkit import _config
|
|
14
|
+
from pymdkit import pymdkit_main
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkingDirectory:
|
|
18
|
+
def __init__(self, path):
|
|
19
|
+
self.path = Path(path)
|
|
20
|
+
self.previous = None
|
|
21
|
+
|
|
22
|
+
def __enter__(self):
|
|
23
|
+
self.previous = Path.cwd()
|
|
24
|
+
os.chdir(self.path)
|
|
25
|
+
return self.path
|
|
26
|
+
|
|
27
|
+
def __exit__(self, *exc_info):
|
|
28
|
+
os.chdir(self.previous)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigTests(unittest.TestCase):
|
|
32
|
+
def test_default_path_uses_active_environment_root(self):
|
|
33
|
+
with patch.object(_config.sys, "prefix", r"C:\envs\pymdkit"), \
|
|
34
|
+
patch.dict(os.environ, {}, clear=True):
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
_config.config_path(),
|
|
37
|
+
Path(r"C:\envs\pymdkit").resolve() / ".pmkrc.yaml",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_config_command_silence_and_append_only_log(self):
|
|
41
|
+
with tempfile.TemporaryDirectory() as tmp, WorkingDirectory(tmp):
|
|
42
|
+
config_file = Path(tmp) / "environment" / ".pmkrc.yaml"
|
|
43
|
+
environment = {"PMK_CONFIG_FILE": str(config_file)}
|
|
44
|
+
with patch.dict(os.environ, environment, clear=False):
|
|
45
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
46
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
pymdkit_main.main(["config", "silence", "-on"]), 0
|
|
49
|
+
)
|
|
50
|
+
self.assertIn("silence: on", stdout.getvalue())
|
|
51
|
+
self.assertIn("silence: true", config_file.read_text(encoding="utf-8"))
|
|
52
|
+
|
|
53
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
54
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
55
|
+
self.assertEqual(pymdkit_main.main(["config"]), 0)
|
|
56
|
+
self.assertEqual(stdout.getvalue(), "")
|
|
57
|
+
self.assertEqual(stderr.getvalue(), "")
|
|
58
|
+
|
|
59
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
60
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
61
|
+
self.assertEqual(pymdkit_main.main(["config", "silence"]), 2)
|
|
62
|
+
self.assertEqual(stdout.getvalue(), "")
|
|
63
|
+
self.assertIn("requires -on or -off", stderr.getvalue())
|
|
64
|
+
|
|
65
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
66
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
67
|
+
self.assertEqual(
|
|
68
|
+
pymdkit_main.main(["config", "silence", "-off"]), 0
|
|
69
|
+
)
|
|
70
|
+
self.assertEqual(stdout.getvalue(), "")
|
|
71
|
+
self.assertIn("silence: false", config_file.read_text(encoding="utf-8"))
|
|
72
|
+
|
|
73
|
+
report = (Path(tmp) / "config.txt").read_text(encoding="utf-8")
|
|
74
|
+
self.assertIn("Command: pmk config silence -on", report)
|
|
75
|
+
self.assertIn("Command: pmk config silence -off", report)
|
|
76
|
+
self.assertEqual(report.count("Command:"), 4)
|
|
77
|
+
self.assertIn("Status: failed (exit code 2)", report)
|
|
78
|
+
|
|
79
|
+
def test_native_report_is_preserved_in_command_log(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as tmp, WorkingDirectory(tmp):
|
|
81
|
+
path = Path(tmp) / "rmsd.txt"
|
|
82
|
+
previous = "older run\n"
|
|
83
|
+
path.write_text(previous, encoding="utf-8")
|
|
84
|
+
path.write_text("new RMSD details\n", encoding="utf-8")
|
|
85
|
+
native = pymdkit_main._native_report(path, previous)
|
|
86
|
+
pymdkit_main._write_command_log(
|
|
87
|
+
"rmsd", ["rmsd", "-if", "structures"], "Wrote rmsd.txt\n",
|
|
88
|
+
0, previous, native,
|
|
89
|
+
)
|
|
90
|
+
report = path.read_text(encoding="utf-8")
|
|
91
|
+
self.assertIn("older run", report)
|
|
92
|
+
self.assertIn("Command: pmk rmsd -if structures", report)
|
|
93
|
+
self.assertIn("Report:\nnew RMSD details", report)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
unittest.main()
|
|
@@ -2,6 +2,7 @@ import math
|
|
|
2
2
|
import sys
|
|
3
3
|
import types
|
|
4
4
|
import unittest
|
|
5
|
+
import warnings
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from unittest.mock import patch
|
|
7
8
|
|
|
@@ -38,6 +39,39 @@ class FakeLattice:
|
|
|
38
39
|
return math.degrees(math.acos(float(np.clip(cosine, -1.0, 1.0))))
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
class CifReaderTests(unittest.TestCase):
|
|
43
|
+
def test_only_benign_fractional_rounding_warning_is_hidden(self):
|
|
44
|
+
structure = object()
|
|
45
|
+
|
|
46
|
+
class Structure:
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_file(cls, path):
|
|
49
|
+
warnings.warn(
|
|
50
|
+
"Issues encountered while parsing CIF: 4 fractional "
|
|
51
|
+
"coordinates rounded to ideal values to avoid issues "
|
|
52
|
+
"with finite precision.",
|
|
53
|
+
UserWarning,
|
|
54
|
+
)
|
|
55
|
+
warnings.warn("CIF composition mismatch", UserWarning)
|
|
56
|
+
return structure
|
|
57
|
+
|
|
58
|
+
pymatgen = types.ModuleType("pymatgen")
|
|
59
|
+
core = types.ModuleType("pymatgen.core")
|
|
60
|
+
core.Structure = Structure
|
|
61
|
+
pymatgen.core = core
|
|
62
|
+
with patch.dict(sys.modules, {
|
|
63
|
+
"pymatgen": pymatgen,
|
|
64
|
+
"pymatgen.core": core,
|
|
65
|
+
}), warnings.catch_warnings(record=True) as caught:
|
|
66
|
+
warnings.simplefilter("always")
|
|
67
|
+
result = _cifio.read_structure("input.cif")
|
|
68
|
+
|
|
69
|
+
self.assertIs(result, structure)
|
|
70
|
+
self.assertEqual([str(item.message) for item in caught], [
|
|
71
|
+
"CIF composition mismatch"
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
|
|
41
75
|
class LatticeGeometryTests(unittest.TestCase):
|
|
42
76
|
def test_gpumd_uses_pymatgen_lattice_row_vectors(self):
|
|
43
77
|
pymatgen = types.ModuleType("pymatgen")
|
|
@@ -169,13 +169,13 @@ class ReallocOccTests(unittest.TestCase):
|
|
|
169
169
|
occ=[0.25, 0.5, 0.75, 1.0],
|
|
170
170
|
max_supercell="2x2x2",
|
|
171
171
|
)
|
|
172
|
-
with patch.object(
|
|
172
|
+
with patch.object(realloc_occ.Path, "cwd", return_value=root), patch.object(
|
|
173
173
|
realloc_occ._oxidation,
|
|
174
174
|
"infer_oxidation_states",
|
|
175
175
|
return_value=OXIDATION,
|
|
176
176
|
):
|
|
177
177
|
self.assertEqual(realloc_occ.run(args), 0)
|
|
178
|
-
report = (
|
|
178
|
+
report = (root / "realloc-occ.txt").read_text(encoding="utf-8")
|
|
179
179
|
self.assertIn(
|
|
180
180
|
"Allowed occupancies: 0.250, 0.500, 0.750, 1.000",
|
|
181
181
|
report,
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
"""pmk dispatcher."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
import ast
|
|
7
|
-
import importlib
|
|
8
|
-
import pkgutil
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import Dict, Tuple
|
|
12
|
-
|
|
13
|
-
from . import __version__, commands
|
|
14
|
-
|
|
15
|
-
DESCRIPTION = "A python data processing toolkit for molecular dynamics and high-throughput calculations"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _read_meta(path: Path, modname: str) -> Tuple[str, str]:
|
|
19
|
-
command, help_text = modname, ""
|
|
20
|
-
try:
|
|
21
|
-
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
|
22
|
-
except (OSError, SyntaxError):
|
|
23
|
-
return command, help_text
|
|
24
|
-
for node in tree.body:
|
|
25
|
-
if isinstance(node, ast.Assign):
|
|
26
|
-
for target in node.targets:
|
|
27
|
-
if (isinstance(target, ast.Name)
|
|
28
|
-
and isinstance(node.value, ast.Constant)
|
|
29
|
-
and isinstance(node.value.value, str)):
|
|
30
|
-
if target.id == "COMMAND":
|
|
31
|
-
command = node.value.value
|
|
32
|
-
elif target.id == "HELP":
|
|
33
|
-
help_text = node.value.value
|
|
34
|
-
if not help_text:
|
|
35
|
-
doc = (ast.get_docstring(tree) or "").strip()
|
|
36
|
-
help_text = doc.splitlines()[0] if doc else ""
|
|
37
|
-
return command, help_text
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _discover() -> Dict[str, Tuple[str, str]]:
|
|
41
|
-
pkg_dir = Path(commands.__path__[0])
|
|
42
|
-
found: Dict[str, Tuple[str, str]] = {}
|
|
43
|
-
for info in pkgutil.iter_modules([str(pkg_dir)]):
|
|
44
|
-
if info.name.startswith("_"):
|
|
45
|
-
continue
|
|
46
|
-
command, help_text = _read_meta(pkg_dir / f"{info.name}.py", info.name)
|
|
47
|
-
found[command] = (info.name, help_text)
|
|
48
|
-
return found
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _add_help(parser: argparse.ArgumentParser) -> None:
|
|
52
|
-
parser.add_argument("-h", "-help", action="help",
|
|
53
|
-
help="show this help message and exit")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def print_main_help(cmds) -> None:
|
|
57
|
-
print("pmk")
|
|
58
|
-
print()
|
|
59
|
-
print(DESCRIPTION)
|
|
60
|
-
print()
|
|
61
|
-
print(f"Version {__version__}")
|
|
62
|
-
print()
|
|
63
|
-
print("Usage: pmk <command> -[options]")
|
|
64
|
-
print()
|
|
65
|
-
print("Available commands:")
|
|
66
|
-
print()
|
|
67
|
-
for name in sorted(cmds):
|
|
68
|
-
print(f" {name:<20} {cmds[name][1]}")
|
|
69
|
-
print()
|
|
70
|
-
print("Command details: pmk <command> -h")
|
|
71
|
-
print()
|
|
72
|
-
print("Options:")
|
|
73
|
-
print(" -h, -help show this help message and exit")
|
|
74
|
-
print(" -v, -version show program's version number and exit")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _build_top_parser(cmds: Dict[str, Tuple[str, str]]) -> argparse.ArgumentParser:
|
|
78
|
-
parser = argparse.ArgumentParser(add_help=False)
|
|
79
|
-
_add_help(parser)
|
|
80
|
-
parser.add_argument("-v", "-version", action="version", version=f"pmk {__version__}")
|
|
81
|
-
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
82
|
-
for name in sorted(cmds):
|
|
83
|
-
sub.add_parser(name, help=cmds[name][1], add_help=False)
|
|
84
|
-
return parser
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def main(argv=None) -> int:
|
|
88
|
-
argv = list(sys.argv[1:] if argv is None else argv)
|
|
89
|
-
cmds = _discover()
|
|
90
|
-
top = _build_top_parser(cmds)
|
|
91
|
-
|
|
92
|
-
if not argv:
|
|
93
|
-
print_main_help(cmds)
|
|
94
|
-
return 1
|
|
95
|
-
if argv[0] in ("-h", "-help"):
|
|
96
|
-
print_main_help(cmds)
|
|
97
|
-
return 0
|
|
98
|
-
if argv[0] in ("-v", "-version"):
|
|
99
|
-
top.parse_args(argv)
|
|
100
|
-
return 0
|
|
101
|
-
|
|
102
|
-
command = argv[0]
|
|
103
|
-
if command not in cmds:
|
|
104
|
-
print_main_help(cmds)
|
|
105
|
-
raise SystemExit(f"\nError: invalid command {command!r}")
|
|
106
|
-
|
|
107
|
-
modname = cmds[command][0]
|
|
108
|
-
module = importlib.import_module(f"{commands.__name__}.{modname}")
|
|
109
|
-
cmd_parser = argparse.ArgumentParser(
|
|
110
|
-
prog=f"pmk {command}",
|
|
111
|
-
description=getattr(module, "HELP", None) or (module.__doc__ or "").strip(),
|
|
112
|
-
add_help=False,
|
|
113
|
-
)
|
|
114
|
-
_add_help(cmd_parser)
|
|
115
|
-
if hasattr(module, "add_arguments"):
|
|
116
|
-
module.add_arguments(cmd_parser)
|
|
117
|
-
args = cmd_parser.parse_args(argv[1:])
|
|
118
|
-
result = module.run(args)
|
|
119
|
-
return 0 if result is None else int(result)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if __name__ == "__main__":
|
|
123
|
-
raise SystemExit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|