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 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)