assystant 0.1.0__py3-none-any.whl
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.
- assystant/__init__.py +38 -0
- assystant/cli.py +194 -0
- assystant/collect.py +182 -0
- assystant/config.py +608 -0
- assystant/data/EleBulk_MatProj_VorVol.csv +88 -0
- assystant/generate.py +378 -0
- assystant/incar.py +76 -0
- assystant/runner.py +105 -0
- assystant/status.py +125 -0
- assystant/submit.py +301 -0
- assystant-0.1.0.dist-info/METADATA +171 -0
- assystant-0.1.0.dist-info/RECORD +15 -0
- assystant-0.1.0.dist-info/WHEEL +4 -0
- assystant-0.1.0.dist-info/entry_points.txt +2 -0
- assystant-0.1.0.dist-info/licenses/LICENSE +29 -0
assystant/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""assystant -- run an ASSYST campaign end to end from one YAML file.
|
|
2
|
+
|
|
3
|
+
``gen`` generates PyXtal structures, ``run`` submits one SLURM job per
|
|
4
|
+
structure, ``status`` reports progress, ``collect`` concatenates the results
|
|
5
|
+
into a single training dataset.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .config import (
|
|
9
|
+
CampaignConfig,
|
|
10
|
+
ConfigError,
|
|
11
|
+
StaleConfigError,
|
|
12
|
+
system_name,
|
|
13
|
+
)
|
|
14
|
+
from .generate import (
|
|
15
|
+
enumerate_compositions,
|
|
16
|
+
parse_struct_dir,
|
|
17
|
+
poscar_hash,
|
|
18
|
+
struct_dir_name,
|
|
19
|
+
)
|
|
20
|
+
from .incar import get_incar_magmom_string, stack_element_string
|
|
21
|
+
from .submit import RESULT_FILENAME
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CampaignConfig",
|
|
27
|
+
"ConfigError",
|
|
28
|
+
"StaleConfigError",
|
|
29
|
+
"system_name",
|
|
30
|
+
"enumerate_compositions",
|
|
31
|
+
"parse_struct_dir",
|
|
32
|
+
"poscar_hash",
|
|
33
|
+
"struct_dir_name",
|
|
34
|
+
"get_incar_magmom_string",
|
|
35
|
+
"stack_element_string",
|
|
36
|
+
"RESULT_FILENAME",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
assystant/cli.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""``assystant`` command line: ``gen`` | ``run`` | ``status`` | ``collect``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .config import CampaignConfig, ConfigError, StaleConfigError
|
|
12
|
+
|
|
13
|
+
__all__ = ["build_parser", "main", "OVERRIDE_FLAGS"]
|
|
14
|
+
|
|
15
|
+
#: CLI flag -> dotted config path. Any scalar in the YAML is reachable this
|
|
16
|
+
#: way; ``--set a.b=c`` covers the ones without a dedicated flag.
|
|
17
|
+
OVERRIDE_FLAGS = {
|
|
18
|
+
"root": "root",
|
|
19
|
+
"ntasks": "slurm.ntasks",
|
|
20
|
+
"time": "slurm.time",
|
|
21
|
+
"partition": "slurm.partition",
|
|
22
|
+
"env": "slurm.env",
|
|
23
|
+
"vasp_binary": "vasp.binary",
|
|
24
|
+
"ionic_steps": "assyst.ionic_steps",
|
|
25
|
+
"n_rattle": "assyst.n_rattle",
|
|
26
|
+
"n_stretch": "assyst.n_stretch",
|
|
27
|
+
"min_dist_backend": "filters.min_dist_backend",
|
|
28
|
+
"seed": "system.seed",
|
|
29
|
+
"n_jobs": "system.n_jobs",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _add_override_flags(parser: argparse.ArgumentParser) -> None:
|
|
34
|
+
parser.add_argument("--root", help="Override `root` (campaign directory)")
|
|
35
|
+
parser.add_argument("--ntasks", type=int, help="Override `slurm.ntasks`")
|
|
36
|
+
parser.add_argument("--time", help="Override `slurm.time` (HH:MM:SS)")
|
|
37
|
+
parser.add_argument("--partition", help="Override `slurm.partition`")
|
|
38
|
+
parser.add_argument("--env", help="Override `slurm.env` (conda prefix for the runner)")
|
|
39
|
+
parser.add_argument("--vasp-binary", dest="vasp_binary", help="Override `vasp.binary`")
|
|
40
|
+
parser.add_argument("--ionic-steps", dest="ionic_steps", type=int,
|
|
41
|
+
help="Override `assyst.ionic_steps`")
|
|
42
|
+
parser.add_argument("--n-rattle", dest="n_rattle", type=int, help="Override `assyst.n_rattle`")
|
|
43
|
+
parser.add_argument("--n-stretch", dest="n_stretch", type=int,
|
|
44
|
+
help="Override `assyst.n_stretch`")
|
|
45
|
+
parser.add_argument("--min-dist-backend", dest="min_dist_backend",
|
|
46
|
+
choices=["neighbor_list", "mic"], help="Override `filters.min_dist_backend`")
|
|
47
|
+
parser.add_argument("--seed", type=int, help="Override `system.seed`")
|
|
48
|
+
parser.add_argument("--n-jobs", dest="n_jobs", type=int, help="Override `system.n_jobs`")
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--set", dest="set_", action="append", default=[], metavar="PATH=VALUE",
|
|
51
|
+
help="Override any config scalar by dotted path, e.g. --set assyst.rattle_strain=0.02",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def overrides_from_args(args) -> dict:
|
|
56
|
+
"""Collect dotted-path overrides from parsed CLI arguments.
|
|
57
|
+
|
|
58
|
+
Only flags the user actually passed appear, so precedence is
|
|
59
|
+
CLI flag > YAML > dataclass default.
|
|
60
|
+
"""
|
|
61
|
+
overrides = {}
|
|
62
|
+
for dest, dotted in OVERRIDE_FLAGS.items():
|
|
63
|
+
value = getattr(args, dest, None)
|
|
64
|
+
if value is not None:
|
|
65
|
+
overrides[dotted] = value
|
|
66
|
+
for item in getattr(args, "set_", []) or []:
|
|
67
|
+
if "=" not in item:
|
|
68
|
+
raise ConfigError(f"--set expects PATH=VALUE, got {item!r}")
|
|
69
|
+
path, _, raw = item.partition("=")
|
|
70
|
+
overrides[path.strip()] = yaml.safe_load(raw)
|
|
71
|
+
return overrides
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(args) -> CampaignConfig:
|
|
75
|
+
return CampaignConfig.from_yaml(args.config).resolve(overrides_from_args(args))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
prog="assystant",
|
|
81
|
+
description=(
|
|
82
|
+
"Run an ASSYST campaign end to end: PyXtal structure generation -> "
|
|
83
|
+
"per-structure DFT via SLURM -> collated training dataset."
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
87
|
+
|
|
88
|
+
gen = sub.add_parser("gen", help="PyXtal spacegroup x stoichiometry scan -> POSCARs + manifest")
|
|
89
|
+
gen.add_argument("config", help="Campaign YAML")
|
|
90
|
+
gen.add_argument("--no-progress", action="store_true", help="Suppress the progress bar")
|
|
91
|
+
_add_override_flags(gen)
|
|
92
|
+
|
|
93
|
+
run = sub.add_parser("run", help="Render and submit one SLURM job per structure")
|
|
94
|
+
run.add_argument("config", help="Campaign YAML")
|
|
95
|
+
run.add_argument("--limit", type=int, help="Process at most N structures")
|
|
96
|
+
run.add_argument("--dry-run", action="store_true",
|
|
97
|
+
help="Render sbatch scripts without submitting or querying the scheduler")
|
|
98
|
+
run.add_argument(
|
|
99
|
+
"--from-scratch", action="store_true",
|
|
100
|
+
help="Accept a changed config and DELETE each structure's run_dir in full, "
|
|
101
|
+
"including executorlib_cache",
|
|
102
|
+
)
|
|
103
|
+
_add_override_flags(run)
|
|
104
|
+
|
|
105
|
+
status = sub.add_parser("status", help="Per-system progress table")
|
|
106
|
+
status.add_argument("config", help="Campaign YAML")
|
|
107
|
+
status.add_argument("--csv", help="Append the table to a job_progress-shaped CSV")
|
|
108
|
+
status.add_argument("--watch", nargs="?", type=int, const=60, default=None,
|
|
109
|
+
metavar="SECONDS", help="Refresh every SECONDS (default 60)")
|
|
110
|
+
_add_override_flags(status)
|
|
111
|
+
|
|
112
|
+
collect = sub.add_parser("collect", help="Concatenate every df_ASSYST_jobs.pkl into one dataset")
|
|
113
|
+
collect.add_argument("config", help="Campaign YAML")
|
|
114
|
+
collect.add_argument("-o", "--output", required=True, help="Output .pkl (or .csv)")
|
|
115
|
+
collect.add_argument("--all-systems", action="store_true",
|
|
116
|
+
help="Scan every system directory under root, not only those in the config")
|
|
117
|
+
_add_override_flags(collect)
|
|
118
|
+
|
|
119
|
+
return parser
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cmd_gen(args) -> int:
|
|
123
|
+
from .generate import run_generation
|
|
124
|
+
|
|
125
|
+
cfg = load_config(args)
|
|
126
|
+
manifests = run_generation(cfg, progress=not args.no_progress)
|
|
127
|
+
for name, manifest in manifests.items():
|
|
128
|
+
print(f"{name}: {len(manifest)} structures -> {Path(cfg.root) / name}")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_run(args) -> int:
|
|
133
|
+
from .submit import submit_campaign
|
|
134
|
+
|
|
135
|
+
cfg = load_config(args)
|
|
136
|
+
stats = submit_campaign(
|
|
137
|
+
cfg, dry_run=args.dry_run, limit=args.limit, from_scratch=args.from_scratch
|
|
138
|
+
)
|
|
139
|
+
verb = "rendered" if args.dry_run else "submitted"
|
|
140
|
+
message = (
|
|
141
|
+
f"{verb} {len(stats['rendered']) if args.dry_run else stats['submitted']}, "
|
|
142
|
+
f"skipped {stats['skipped_queued']} queued / {stats['skipped_done']} finished"
|
|
143
|
+
)
|
|
144
|
+
if args.dry_run and args.from_scratch:
|
|
145
|
+
# A dry run never deletes: report what --from-scratch would wipe
|
|
146
|
+
# instead of silently omitting the most destructive part of what the
|
|
147
|
+
# real run would do.
|
|
148
|
+
message += (
|
|
149
|
+
f", would delete {stats['would_delete']} existing run_dir(s) "
|
|
150
|
+
"(not deleted -- dry run)"
|
|
151
|
+
)
|
|
152
|
+
print(message)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cmd_status(args) -> int:
|
|
157
|
+
from .status import append_csv, campaign_status, format_table, watch
|
|
158
|
+
|
|
159
|
+
cfg = load_config(args)
|
|
160
|
+
if args.watch:
|
|
161
|
+
watch(cfg, interval=args.watch, csv_path=args.csv)
|
|
162
|
+
return 0
|
|
163
|
+
rows = campaign_status(cfg)
|
|
164
|
+
print(format_table(rows))
|
|
165
|
+
if args.csv:
|
|
166
|
+
append_csv(rows, args.csv)
|
|
167
|
+
print(f"appended to {args.csv}")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_collect(args) -> int:
|
|
172
|
+
from .collect import collect_campaign
|
|
173
|
+
|
|
174
|
+
cfg = load_config(args)
|
|
175
|
+
systems = None if args.all_systems else cfg.system.systems
|
|
176
|
+
frame = collect_campaign(cfg.root, systems, args.output)
|
|
177
|
+
print(f"collected {len(frame)} rows -> {args.output}")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_COMMANDS = {"gen": _cmd_gen, "run": _cmd_run, "status": _cmd_status, "collect": _cmd_collect}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv=None) -> int:
|
|
185
|
+
args = build_parser().parse_args(argv)
|
|
186
|
+
try:
|
|
187
|
+
return _COMMANDS[args.command](args)
|
|
188
|
+
except (ConfigError, StaleConfigError) as exc:
|
|
189
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
190
|
+
return 2
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__": # pragma: no cover
|
|
194
|
+
sys.exit(main())
|
assystant/collect.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Walk a campaign root and concatenate every ``df_ASSYST_jobs.pkl`` into one dataset.
|
|
2
|
+
|
|
3
|
+
Two on-disk layouts are read:
|
|
4
|
+
|
|
5
|
+
``struct_<idx:05d>_<sha8>/``
|
|
6
|
+
written by this package; the hash column of ``manifest.csv`` ties each
|
|
7
|
+
result back to the structure that generated it.
|
|
8
|
+
|
|
9
|
+
``struct_pyxtal_<idx>/``
|
|
10
|
+
the pre-2026 layout. Existing campaign data is readable without migration,
|
|
11
|
+
which is why ``collect`` matches on layout rather than assuming one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from .config import CampaignConfig, system_name
|
|
22
|
+
from .generate import MANIFEST_DTYPES, MANIFEST_NAME, parse_struct_dir
|
|
23
|
+
from .submit import RESULT_FILENAME
|
|
24
|
+
|
|
25
|
+
__all__ = ["ResultDir", "iter_result_dirs", "collect_campaign", "load_manifests"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ResultDir:
|
|
30
|
+
path: Path
|
|
31
|
+
system: str
|
|
32
|
+
index: int
|
|
33
|
+
structure_hash: str | None
|
|
34
|
+
layout: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scan_system_dir(sys_dir: Path, system: str):
|
|
38
|
+
if not sys_dir.is_dir():
|
|
39
|
+
return
|
|
40
|
+
for entry in sorted(sys_dir.iterdir()):
|
|
41
|
+
if not entry.is_dir():
|
|
42
|
+
continue
|
|
43
|
+
parsed = parse_struct_dir(entry.name)
|
|
44
|
+
if parsed is None:
|
|
45
|
+
continue
|
|
46
|
+
if not (entry / RESULT_FILENAME).is_file():
|
|
47
|
+
continue
|
|
48
|
+
index, sha8, layout = parsed
|
|
49
|
+
yield ResultDir(entry, system, index, sha8, layout)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def iter_result_dirs(root, systems=None):
|
|
53
|
+
"""Yield every finished structure directory under ``root``.
|
|
54
|
+
|
|
55
|
+
Scans one level below ``root`` (``<root>/<system>/struct_*``) plus ``root``
|
|
56
|
+
itself (``<root>/struct_*``, for a campaign root that *is* one system).
|
|
57
|
+
Bounded on purpose: a full ``rglob`` over a campaign with tens of thousands
|
|
58
|
+
of structure directories, each with calculation subdirectories, is far
|
|
59
|
+
slower for no additional coverage.
|
|
60
|
+
"""
|
|
61
|
+
root = Path(root)
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
return
|
|
64
|
+
yield from _scan_system_dir(root, root.name)
|
|
65
|
+
names = (
|
|
66
|
+
[system_name(list(s)) for s in systems]
|
|
67
|
+
if systems
|
|
68
|
+
else sorted(e.name for e in root.iterdir() if e.is_dir())
|
|
69
|
+
)
|
|
70
|
+
for name in names:
|
|
71
|
+
yield from _scan_system_dir(root / name, name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_manifests(root, systems=None) -> pd.DataFrame:
|
|
75
|
+
"""Concatenate every available ``manifest.csv`` under ``root``."""
|
|
76
|
+
root = Path(root)
|
|
77
|
+
frames = []
|
|
78
|
+
candidates = []
|
|
79
|
+
if (root / MANIFEST_NAME).is_file():
|
|
80
|
+
candidates.append((root.name, root / MANIFEST_NAME))
|
|
81
|
+
names = (
|
|
82
|
+
[system_name(list(s)) for s in systems]
|
|
83
|
+
if systems
|
|
84
|
+
else sorted(e.name for e in root.iterdir() if e.is_dir())
|
|
85
|
+
if root.is_dir()
|
|
86
|
+
else []
|
|
87
|
+
)
|
|
88
|
+
for name in names:
|
|
89
|
+
path = root / name / MANIFEST_NAME
|
|
90
|
+
if path.is_file():
|
|
91
|
+
candidates.append((name, path))
|
|
92
|
+
for name, path in candidates:
|
|
93
|
+
frame = pd.read_csv(path, dtype=MANIFEST_DTYPES)
|
|
94
|
+
if "system" not in frame.columns:
|
|
95
|
+
frame["system"] = name
|
|
96
|
+
frames.append(frame)
|
|
97
|
+
if not frames:
|
|
98
|
+
return pd.DataFrame(columns=["index", "system", "dirname", "hash"])
|
|
99
|
+
return pd.concat(frames, ignore_index=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def collect_campaign(root, systems=None, output=None) -> pd.DataFrame:
|
|
103
|
+
"""Concatenate all results under ``root``, joined to the manifest.
|
|
104
|
+
|
|
105
|
+
Hashed directories join on ``(system, structure_hash)``; legacy
|
|
106
|
+
``struct_pyxtal_<idx>`` directories have no hash on disk, so they join on
|
|
107
|
+
``(system, struct_index)`` when a manifest exists and simply carry no
|
|
108
|
+
provenance columns when it does not.
|
|
109
|
+
"""
|
|
110
|
+
root = Path(root)
|
|
111
|
+
results = list(iter_result_dirs(root, systems))
|
|
112
|
+
frames = []
|
|
113
|
+
for result in results:
|
|
114
|
+
try:
|
|
115
|
+
frame = pd.read_pickle(result.path / RESULT_FILENAME)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
print(f"Warning: could not read {result.path / RESULT_FILENAME}: {exc}")
|
|
118
|
+
continue
|
|
119
|
+
frame = frame.copy()
|
|
120
|
+
frame["system"] = result.system
|
|
121
|
+
frame["struct_index"] = result.index
|
|
122
|
+
frame["structure_hash"] = result.structure_hash
|
|
123
|
+
frame["layout"] = result.layout
|
|
124
|
+
frame["run_dir"] = str(result.path)
|
|
125
|
+
frames.append(frame)
|
|
126
|
+
|
|
127
|
+
if not frames:
|
|
128
|
+
combined = pd.DataFrame(
|
|
129
|
+
columns=["system", "struct_index", "structure_hash", "layout", "run_dir"]
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
combined = pd.concat(frames, ignore_index=True)
|
|
133
|
+
|
|
134
|
+
manifest = load_manifests(root, systems)
|
|
135
|
+
if len(manifest) and len(combined):
|
|
136
|
+
combined = _join_manifest(combined, manifest)
|
|
137
|
+
|
|
138
|
+
if output is not None:
|
|
139
|
+
output = Path(output)
|
|
140
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
if output.suffix in (".csv",):
|
|
142
|
+
combined.to_csv(output, index=False)
|
|
143
|
+
else:
|
|
144
|
+
combined.to_pickle(output)
|
|
145
|
+
return combined
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_MANIFEST_COLUMNS = ["spacegroup", "composition", "formula", "n_atoms", "volume_per_atom"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _join_manifest(combined: pd.DataFrame, manifest: pd.DataFrame) -> pd.DataFrame:
|
|
152
|
+
keep = [c for c in _MANIFEST_COLUMNS if c in manifest.columns]
|
|
153
|
+
hashed = combined["layout"] == "hashed"
|
|
154
|
+
|
|
155
|
+
out = combined.copy()
|
|
156
|
+
for column in keep:
|
|
157
|
+
out[column] = None
|
|
158
|
+
|
|
159
|
+
if "hash" in manifest.columns:
|
|
160
|
+
by_hash = manifest.drop_duplicates(subset=["system", "hash"]).set_index(
|
|
161
|
+
["system", "hash"]
|
|
162
|
+
)[keep]
|
|
163
|
+
keys = list(zip(out.loc[hashed, "system"], out.loc[hashed, "structure_hash"]))
|
|
164
|
+
if keys:
|
|
165
|
+
joined = by_hash.reindex(keys)
|
|
166
|
+
for column in keep:
|
|
167
|
+
out.loc[hashed, column] = joined[column].to_numpy()
|
|
168
|
+
|
|
169
|
+
if "index" in manifest.columns and (~hashed).any():
|
|
170
|
+
by_index = manifest.drop_duplicates(subset=["system", "index"]).set_index(
|
|
171
|
+
["system", "index"]
|
|
172
|
+
)[keep]
|
|
173
|
+
keys = list(zip(out.loc[~hashed, "system"], out.loc[~hashed, "struct_index"]))
|
|
174
|
+
if keys:
|
|
175
|
+
joined = by_index.reindex(keys)
|
|
176
|
+
for column in keep:
|
|
177
|
+
out.loc[~hashed, column] = joined[column].to_numpy()
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def collect_from_config(cfg: CampaignConfig, output=None) -> pd.DataFrame:
|
|
182
|
+
return collect_campaign(cfg.root, cfg.system.systems, output)
|