saddleflow 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.
- saddleflow/__init__.py +14 -0
- saddleflow/cli.py +59 -0
- saddleflow/data/__init__.py +31 -0
- saddleflow/data/convert_to_db.py +124 -0
- saddleflow/data/core.py +200 -0
- saddleflow/data/db_dataset.py +80 -0
- saddleflow/data/materials_saddles_dataset.py +430 -0
- saddleflow/data/traj_dataset.py +123 -0
- saddleflow/data/transforms.py +100 -0
- saddleflow/flow/__init__.py +17 -0
- saddleflow/flow/matching.py +1096 -0
- saddleflow/flow/sampler.py +416 -0
- saddleflow/models/__init__.py +11 -0
- saddleflow/models/eigenmode_head.py +56 -0
- saddleflow/models/global_attn.py +96 -0
- saddleflow/models/time_film.py +118 -0
- saddleflow/models/time_filmed_backbone.py +300 -0
- saddleflow/models/velocity_head.py +483 -0
- saddleflow/pretrained.py +210 -0
- saddleflow/search.py +146 -0
- saddleflow/utils/__init__.py +34 -0
- saddleflow/utils/backbone.py +138 -0
- saddleflow/utils/checkpointing.py +69 -0
- saddleflow/utils/eval.py +416 -0
- saddleflow/utils/forces.py +167 -0
- saddleflow/utils/training.py +686 -0
- saddleflow-0.1.0.dist-info/METADATA +233 -0
- saddleflow-0.1.0.dist-info/RECORD +31 -0
- saddleflow-0.1.0.dist-info/WHEEL +4 -0
- saddleflow-0.1.0.dist-info/entry_points.txt +2 -0
- saddleflow-0.1.0.dist-info/licenses/LICENSE +21 -0
saddleflow/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
__all__ = ["SaddleFlow", "SaddleFlowSearch", "find_saddles"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name):
|
|
7
|
+
# Lazy, so `import saddleflow` stays light for the training code paths.
|
|
8
|
+
if name == "SaddleFlow":
|
|
9
|
+
from .pretrained import SaddleFlow
|
|
10
|
+
return SaddleFlow
|
|
11
|
+
if name in ("SaddleFlowSearch", "find_saddles"):
|
|
12
|
+
from . import search
|
|
13
|
+
return getattr(search, name)
|
|
14
|
+
raise AttributeError(f"module 'saddleflow' has no attribute {name!r}")
|
saddleflow/cli.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Command line: `saddleflow propose` and `saddleflow search`.
|
|
2
|
+
|
|
3
|
+
saddleflow propose reactant.traj -o proposals.traj --n 8 --seed 0
|
|
4
|
+
saddleflow search reactant.traj -o saddles.traj --n 8 --seed 0 --task omat
|
|
5
|
+
|
|
6
|
+
`propose` writes the flow endpoints; `search` also converges them with Sella on UMA-S-1.2 (fairchem's
|
|
7
|
+
calculator, gated access to facebook/UMA) and keeps the distinct first-order saddles.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
from ase.io import read, write
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
p = argparse.ArgumentParser(prog="saddleflow", description=__doc__.split("\n")[0])
|
|
19
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
20
|
+
for name in ("propose", "search"):
|
|
21
|
+
s = sub.add_parser(name)
|
|
22
|
+
s.add_argument("input", help="ASE-readable structure file (every frame is used)")
|
|
23
|
+
s.add_argument("-o", "--output", required=True)
|
|
24
|
+
s.add_argument("--n", type=int, default=8, help="noise draws per structure")
|
|
25
|
+
s.add_argument("--seed", type=int, default=None)
|
|
26
|
+
s.add_argument("--sigma", type=float, default=None, help="start noise (A), at most the training 0.3")
|
|
27
|
+
s.add_argument("--task", default=None, help="omat (bulk), oc20 or oc22 (surfaces); default omat")
|
|
28
|
+
s.add_argument("--model", default="SciLM/SaddleFlow", help="Hub repository or local directory")
|
|
29
|
+
s.add_argument("--device", default=None)
|
|
30
|
+
if name == "search":
|
|
31
|
+
s.add_argument("--uma", default="uma-s-1p2", help="UMA model for the Sella refinement")
|
|
32
|
+
s.add_argument("--fmax", type=float, default=0.01)
|
|
33
|
+
s.add_argument("--steps", type=int, default=300)
|
|
34
|
+
a = p.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
from .pretrained import SaddleFlow
|
|
37
|
+
model = SaddleFlow.from_pretrained(a.model, device=a.device)
|
|
38
|
+
frames = read(a.input, index=":")
|
|
39
|
+
out = []
|
|
40
|
+
if a.cmd == "propose":
|
|
41
|
+
for props in model.propose(frames, n=a.n, seed=a.seed, sigma=a.sigma, task_name=a.task):
|
|
42
|
+
out.extend(props)
|
|
43
|
+
else:
|
|
44
|
+
from fairchem.core import FAIRChemCalculator
|
|
45
|
+
from fairchem.core.calculate.pretrained_mlip import get_predict_unit
|
|
46
|
+
|
|
47
|
+
from .search import find_saddles
|
|
48
|
+
unit = get_predict_unit(a.uma, device=model.device.type) # fairchem takes "cuda" or "cpu"
|
|
49
|
+
for frame in frames:
|
|
50
|
+
task = a.task or frame.info.get("task_name") or "omat"
|
|
51
|
+
calc = FAIRChemCalculator(unit, task_name=task)
|
|
52
|
+
out.extend(find_saddles(frame, calc, n=a.n, model=model, seed=a.seed, fmax=a.fmax, steps=a.steps,
|
|
53
|
+
sigma=a.sigma, task_name=task))
|
|
54
|
+
write(a.output, out)
|
|
55
|
+
print(f"wrote {len(out)} structures to {a.output}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
atoms_to_sample_dict,
|
|
3
|
+
extract_fixed_mask,
|
|
4
|
+
iter_triplets_from_traj_paths,
|
|
5
|
+
load_validated_triplets,
|
|
6
|
+
mic_unwrap,
|
|
7
|
+
resolve_paths,
|
|
8
|
+
triplet_to_pair_records,
|
|
9
|
+
validate_triplet,
|
|
10
|
+
)
|
|
11
|
+
from .db_dataset import AseDbSaddleDataset
|
|
12
|
+
from .materials_saddles_dataset import MaterialsSaddlesDataset
|
|
13
|
+
from .traj_dataset import TrajTripletDataset
|
|
14
|
+
from .transforms import gaussian_perturbation, mic_displacement, wrap_positions
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AseDbSaddleDataset",
|
|
18
|
+
"MaterialsSaddlesDataset",
|
|
19
|
+
"TrajTripletDataset",
|
|
20
|
+
"atoms_to_sample_dict",
|
|
21
|
+
"extract_fixed_mask",
|
|
22
|
+
"gaussian_perturbation",
|
|
23
|
+
"iter_triplets_from_traj_paths",
|
|
24
|
+
"load_validated_triplets",
|
|
25
|
+
"mic_displacement",
|
|
26
|
+
"mic_unwrap",
|
|
27
|
+
"resolve_paths",
|
|
28
|
+
"triplet_to_pair_records",
|
|
29
|
+
"validate_triplet",
|
|
30
|
+
"wrap_positions",
|
|
31
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
(Backend B) Convert .traj triplets [R, S, P, R, S, P, ...] into a single ASE-DB.
|
|
3
|
+
|
|
4
|
+
Output backend is chosen by file extension:
|
|
5
|
+
*.db / *.sqlite → ASE's default SQLite backend (good for debugging, moderate scale)
|
|
6
|
+
*.aselmdb → LMDB-backed ASE-DB (fairchem's production format; fast at 30M+ scale)
|
|
7
|
+
|
|
8
|
+
Each triplet writes two rows, one per (start, saddle) pair, keyed by role ∈ {"R2S", "P2S"}.
|
|
9
|
+
Saddle positions are stored minimum-image-unwrapped relative to their start.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python -m saddleflow.data.convert_to_db \\
|
|
13
|
+
--traj 'data/raw/*.traj' \\
|
|
14
|
+
--out data/processed/li_c.aselmdb \\
|
|
15
|
+
--default-task-name omat
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from ase import Atoms
|
|
24
|
+
from ase.constraints import FixAtoms
|
|
25
|
+
from ase.db import connect
|
|
26
|
+
from tqdm import tqdm
|
|
27
|
+
|
|
28
|
+
from .core import iter_triplets_from_traj_paths, resolve_paths, triplet_to_pair_records
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _start_atoms(record: dict) -> Atoms:
|
|
32
|
+
atoms = Atoms(
|
|
33
|
+
positions=record["start_pos"],
|
|
34
|
+
numbers=record["Z"],
|
|
35
|
+
cell=record["cell"],
|
|
36
|
+
pbc=True,
|
|
37
|
+
)
|
|
38
|
+
fixed_idx = np.where(record["fixed"])[0]
|
|
39
|
+
if len(fixed_idx):
|
|
40
|
+
atoms.set_constraint(FixAtoms(indices=fixed_idx.tolist()))
|
|
41
|
+
return atoms
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def convert(
|
|
45
|
+
traj_paths,
|
|
46
|
+
out_path: str,
|
|
47
|
+
default_task_name: str = "omat",
|
|
48
|
+
default_charge: int = 0,
|
|
49
|
+
default_spin: int = 0,
|
|
50
|
+
progress: bool = True,
|
|
51
|
+
) -> dict:
|
|
52
|
+
paths = resolve_paths(traj_paths)
|
|
53
|
+
out = Path(out_path)
|
|
54
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
db = connect(str(out))
|
|
57
|
+
deltas: list[float] = []
|
|
58
|
+
triplet_id = 0
|
|
59
|
+
it = iter_triplets_from_traj_paths(paths)
|
|
60
|
+
if progress:
|
|
61
|
+
it = tqdm(it, desc=f"converting → {out.name}")
|
|
62
|
+
|
|
63
|
+
for R, S, P in it:
|
|
64
|
+
for r in triplet_to_pair_records(
|
|
65
|
+
R, S, P, triplet_id,
|
|
66
|
+
default_task_name, default_charge, default_spin,
|
|
67
|
+
):
|
|
68
|
+
db.write(
|
|
69
|
+
_start_atoms(r),
|
|
70
|
+
key_value_pairs={
|
|
71
|
+
"role": r["role"],
|
|
72
|
+
"task_name": r["task_name"],
|
|
73
|
+
"triplet_id": int(r["triplet_id"]),
|
|
74
|
+
"charge": int(r["charge"]),
|
|
75
|
+
"spin": int(r["spin"]),
|
|
76
|
+
"delta_norm": float(r["delta_norm"]),
|
|
77
|
+
},
|
|
78
|
+
data={
|
|
79
|
+
"saddle_un_pos": r["saddle_un_pos"],
|
|
80
|
+
"partner_un_pos": r["partner_un_pos"],
|
|
81
|
+
"metadata": r["metadata"],
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
deltas.append(float(r["delta_norm"]))
|
|
85
|
+
triplet_id += 1
|
|
86
|
+
|
|
87
|
+
stats = {
|
|
88
|
+
"num_triplets": triplet_id,
|
|
89
|
+
"num_records": 2 * triplet_id,
|
|
90
|
+
"delta_norm_mean": float(np.mean(deltas)) if deltas else 0.0,
|
|
91
|
+
"delta_norm_std": float(np.std(deltas)) if deltas else 0.0,
|
|
92
|
+
"source_paths": paths,
|
|
93
|
+
"out_path": str(out),
|
|
94
|
+
}
|
|
95
|
+
Path(str(out) + ".stats.json").write_text(json.dumps(stats, indent=2))
|
|
96
|
+
return stats
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
101
|
+
p.add_argument("--traj", required=True,
|
|
102
|
+
help="Glob, directory, single path, or comma-separated list of .traj paths")
|
|
103
|
+
p.add_argument("--out", required=True,
|
|
104
|
+
help="Output ASE-DB path (*.db, *.sqlite, or *.aselmdb)")
|
|
105
|
+
p.add_argument("--default-task-name", default="omat")
|
|
106
|
+
p.add_argument("--default-charge", type=int, default=0)
|
|
107
|
+
p.add_argument("--default-spin", type=int, default=0)
|
|
108
|
+
p.add_argument("--no-progress", action="store_true")
|
|
109
|
+
args = p.parse_args()
|
|
110
|
+
|
|
111
|
+
traj_arg = args.traj.split(",") if "," in args.traj else args.traj
|
|
112
|
+
stats = convert(
|
|
113
|
+
traj_arg, args.out,
|
|
114
|
+
default_task_name=args.default_task_name,
|
|
115
|
+
default_charge=args.default_charge,
|
|
116
|
+
default_spin=args.default_spin,
|
|
117
|
+
progress=not args.no_progress,
|
|
118
|
+
)
|
|
119
|
+
print(f"Wrote {stats['num_records']} records from {stats['num_triplets']} triplets → {args.out}")
|
|
120
|
+
print(f"<||Δ||> = {stats['delta_norm_mean']:.4f} Å (std {stats['delta_norm_std']:.4f} Å)")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
saddleflow/data/core.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared helpers for both data backends.
|
|
3
|
+
|
|
4
|
+
A *triplet* is a contiguous (reactant, saddle, product) block of ASE Atoms, as
|
|
5
|
+
found in the source .traj files (with `atoms.info['side']` ∈ {-1, 0, 1}).
|
|
6
|
+
|
|
7
|
+
A *pair record* is one training example: a start structure (either R or P) and
|
|
8
|
+
its MIC-unwrapped saddle. Every triplet produces two pair records (R→S and
|
|
9
|
+
P→S) by microscopic reversibility — the saddle is the same saddle whether
|
|
10
|
+
approached from R or P; only the MIC image choice differs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from glob import glob as _glob
|
|
15
|
+
from typing import Iterator
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import torch
|
|
19
|
+
from ase import Atoms
|
|
20
|
+
from ase.constraints import FixAtoms
|
|
21
|
+
from ase.io import Trajectory
|
|
22
|
+
|
|
23
|
+
SIDE_REACTANT = -1
|
|
24
|
+
SIDE_SADDLE = 0
|
|
25
|
+
SIDE_PRODUCT = 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mic_unwrap(start_pos: np.ndarray, target_pos: np.ndarray, cell: np.ndarray) -> np.ndarray:
|
|
29
|
+
"""Return `target_pos` minimum-image-unwrapped relative to `start_pos`.
|
|
30
|
+
|
|
31
|
+
Result may lie outside the unit cell — that is intentional; the flow
|
|
32
|
+
interpolation is done in unwrapped space and wrapped only for UMA forward.
|
|
33
|
+
"""
|
|
34
|
+
cell = np.asarray(cell, dtype=np.float64)
|
|
35
|
+
delta = np.asarray(target_pos, dtype=np.float64) - np.asarray(start_pos, dtype=np.float64)
|
|
36
|
+
frac = delta @ np.linalg.inv(cell)
|
|
37
|
+
frac -= np.round(frac)
|
|
38
|
+
return np.asarray(start_pos, dtype=np.float64) + frac @ cell
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_fixed_mask(atoms: Atoms) -> np.ndarray:
|
|
42
|
+
mask = np.zeros(len(atoms), dtype=bool)
|
|
43
|
+
for c in getattr(atoms, "constraints", []) or []:
|
|
44
|
+
if isinstance(c, FixAtoms):
|
|
45
|
+
mask[c.index] = True
|
|
46
|
+
return mask
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_triplet(R: Atoms, S: Atoms, P: Atoms) -> None:
|
|
50
|
+
assert np.array_equal(R.numbers, S.numbers) and np.array_equal(S.numbers, P.numbers), \
|
|
51
|
+
"atom ordering differs across triplet frames"
|
|
52
|
+
assert np.allclose(np.asarray(R.cell), np.asarray(S.cell)) \
|
|
53
|
+
and np.allclose(np.asarray(S.cell), np.asarray(P.cell)), \
|
|
54
|
+
"cell differs across triplet frames"
|
|
55
|
+
assert all(R.pbc) and all(S.pbc) and all(P.pbc), \
|
|
56
|
+
"triplet is not 3D-periodic"
|
|
57
|
+
fR, fS, fP = extract_fixed_mask(R), extract_fixed_mask(S), extract_fixed_mask(P)
|
|
58
|
+
assert np.array_equal(fR, fS) and np.array_equal(fS, fP), \
|
|
59
|
+
"FixAtoms mask differs across triplet frames"
|
|
60
|
+
sides = [R.info.get("side"), S.info.get("side"), P.info.get("side")]
|
|
61
|
+
if all(s is not None for s in sides):
|
|
62
|
+
assert sides == [SIDE_REACTANT, SIDE_SADDLE, SIDE_PRODUCT], \
|
|
63
|
+
f"unexpected info['side'] ordering {sides}, want [-1, 0, 1]"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sanitize_info(info: dict) -> dict:
|
|
67
|
+
"""Drop non-serializable entries from an ASE info dict so ASE-DB can store it."""
|
|
68
|
+
out = {}
|
|
69
|
+
for k, v in info.items():
|
|
70
|
+
if isinstance(v, (str, int, float, bool, type(None))):
|
|
71
|
+
out[k] = v
|
|
72
|
+
elif isinstance(v, np.ndarray):
|
|
73
|
+
out[k] = v
|
|
74
|
+
elif isinstance(v, (list, tuple)):
|
|
75
|
+
try:
|
|
76
|
+
out[k] = np.asarray(v)
|
|
77
|
+
except Exception:
|
|
78
|
+
continue
|
|
79
|
+
elif isinstance(v, dict):
|
|
80
|
+
out[k] = _sanitize_info(v)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def triplet_to_pair_records(
|
|
85
|
+
R: Atoms,
|
|
86
|
+
S: Atoms,
|
|
87
|
+
P: Atoms,
|
|
88
|
+
triplet_id: int,
|
|
89
|
+
default_task_name: str = "omat",
|
|
90
|
+
default_charge: int = 0,
|
|
91
|
+
default_spin: int = 0,
|
|
92
|
+
) -> list[dict]:
|
|
93
|
+
validate_triplet(R, S, P)
|
|
94
|
+
Z = R.numbers.astype(np.int32)
|
|
95
|
+
cell = np.asarray(R.cell[:], dtype=np.float32)
|
|
96
|
+
fixed = extract_fixed_mask(R)
|
|
97
|
+
task_name = R.info.get("task_name", default_task_name)
|
|
98
|
+
charge = int(R.info.get("charge", default_charge))
|
|
99
|
+
spin = int(R.info.get("spin", default_spin))
|
|
100
|
+
metadata = {
|
|
101
|
+
"reactant_info": _sanitize_info(R.info),
|
|
102
|
+
"saddle_info": _sanitize_info(S.info),
|
|
103
|
+
"product_info": _sanitize_info(P.info),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
S_un_from_R = mic_unwrap(R.positions, S.positions, cell).astype(np.float32)
|
|
107
|
+
S_un_from_P = mic_unwrap(P.positions, S.positions, cell).astype(np.float32)
|
|
108
|
+
|
|
109
|
+
base = dict(Z=Z, cell=cell, fixed=fixed, task_name=task_name,
|
|
110
|
+
charge=charge, spin=spin, metadata=metadata,
|
|
111
|
+
triplet_id=int(triplet_id))
|
|
112
|
+
|
|
113
|
+
# Mode-1 partner: the OTHER local minimum (the one we are flowing AWAY from).
|
|
114
|
+
# Stored MIC-unwrapped relative to start so it lives in the same frame as
|
|
115
|
+
# `saddle_un_pos`. Frozen atoms have identical positions in R and P, so on
|
|
116
|
+
# those atoms `partner_un_pos == start_pos`, i.e. delta_partner = 0 — the
|
|
117
|
+
# head sees no spurious frozen-atom signal.
|
|
118
|
+
P_un_from_R = mic_unwrap(R.positions, P.positions, cell).astype(np.float32)
|
|
119
|
+
R_un_from_P = mic_unwrap(P.positions, R.positions, cell).astype(np.float32)
|
|
120
|
+
|
|
121
|
+
return [
|
|
122
|
+
dict(base,
|
|
123
|
+
start_pos=R.positions.astype(np.float32),
|
|
124
|
+
saddle_un_pos=S_un_from_R,
|
|
125
|
+
partner_un_pos=P_un_from_R,
|
|
126
|
+
delta_norm=np.float32(np.linalg.norm(S_un_from_R - R.positions)),
|
|
127
|
+
role="R2S"),
|
|
128
|
+
dict(base,
|
|
129
|
+
start_pos=P.positions.astype(np.float32),
|
|
130
|
+
saddle_un_pos=S_un_from_P,
|
|
131
|
+
partner_un_pos=R_un_from_P,
|
|
132
|
+
delta_norm=np.float32(np.linalg.norm(S_un_from_P - P.positions)),
|
|
133
|
+
role="P2S"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def iter_triplets_from_traj_paths(paths: list[str]) -> Iterator[tuple[Atoms, Atoms, Atoms]]:
|
|
138
|
+
for path in paths:
|
|
139
|
+
t = Trajectory(path, "r")
|
|
140
|
+
n = len(t)
|
|
141
|
+
assert n % 3 == 0, f"{path}: {n} frames, not a multiple of 3"
|
|
142
|
+
for i in range(0, n, 3):
|
|
143
|
+
yield t[i], t[i + 1], t[i + 2]
|
|
144
|
+
t.close()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_paths(pattern_or_list) -> list[str]:
|
|
148
|
+
"""Accept a single path, a glob pattern, a directory, or a list of any of those."""
|
|
149
|
+
if isinstance(pattern_or_list, (list, tuple)):
|
|
150
|
+
out = []
|
|
151
|
+
for p in pattern_or_list:
|
|
152
|
+
out.extend(resolve_paths(p))
|
|
153
|
+
return sorted(set(out))
|
|
154
|
+
p = str(pattern_or_list)
|
|
155
|
+
if any(c in p for c in "*?["):
|
|
156
|
+
return sorted(_glob(p))
|
|
157
|
+
if os.path.isdir(p):
|
|
158
|
+
return sorted(_glob(os.path.join(p, "*.traj")))
|
|
159
|
+
if os.path.isfile(p):
|
|
160
|
+
return [p]
|
|
161
|
+
raise FileNotFoundError(f"no .traj files matched {pattern_or_list!r}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_validated_triplets(paths) -> list[tuple[Atoms, Atoms, Atoms]]:
|
|
165
|
+
"""Read one or more .traj files and return the validated list of (R, S, P) tuples.
|
|
166
|
+
|
|
167
|
+
Convenience wrapper over `iter_triplets_from_traj_paths` + `validate_triplet`
|
|
168
|
+
for callers that need all triplets up-front (e.g. evaluation scripts that
|
|
169
|
+
group by reactant).
|
|
170
|
+
"""
|
|
171
|
+
triplets = list(iter_triplets_from_traj_paths(resolve_paths(paths)))
|
|
172
|
+
for R, S, P in triplets:
|
|
173
|
+
validate_triplet(R, S, P)
|
|
174
|
+
return triplets
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def atoms_to_sample_dict(
|
|
178
|
+
atoms: Atoms,
|
|
179
|
+
default_task_name: str = "omat",
|
|
180
|
+
default_charge: int = 0,
|
|
181
|
+
default_spin: int = 0,
|
|
182
|
+
) -> dict:
|
|
183
|
+
"""Build an inference-ready sample dict from a single ASE `Atoms`.
|
|
184
|
+
|
|
185
|
+
Matches the subset of fields that `sample_saddles` and
|
|
186
|
+
`FlowMatchingLoss.build_atomic_data` consume (no `saddle_un_pos` /
|
|
187
|
+
`delta_norm` / `metadata`, which are training-only). `task_name`,
|
|
188
|
+
`charge`, and `spin` are pulled from `atoms.info` if present, else from
|
|
189
|
+
the `default_*` arguments.
|
|
190
|
+
"""
|
|
191
|
+
fixed = extract_fixed_mask(atoms)
|
|
192
|
+
return {
|
|
193
|
+
"start_pos": torch.tensor(atoms.get_positions(), dtype=torch.float32),
|
|
194
|
+
"Z": torch.tensor(atoms.numbers, dtype=torch.long),
|
|
195
|
+
"cell": torch.tensor(np.asarray(atoms.cell[:]), dtype=torch.float32),
|
|
196
|
+
"fixed": torch.tensor(fixed, dtype=torch.bool),
|
|
197
|
+
"task_name": atoms.info.get("task_name", default_task_name),
|
|
198
|
+
"charge": int(atoms.info.get("charge", default_charge)),
|
|
199
|
+
"spin": int(atoms.info.get("spin", default_spin)),
|
|
200
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
(Backend B) Read (start, saddle) pair records from an ASE-DB written by
|
|
3
|
+
`convert_to_db`. Yields the same dict schema as `TrajTripletDataset` so
|
|
4
|
+
downstream training/inference code is backend-agnostic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import torch
|
|
13
|
+
from ase.db import connect
|
|
14
|
+
from torch.utils.data import Dataset
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AseDbSaddleDataset(Dataset):
|
|
18
|
+
def __init__(self, path: str, select_args: dict | None = None):
|
|
19
|
+
self._path = str(path)
|
|
20
|
+
self._select_args = dict(select_args or {})
|
|
21
|
+
|
|
22
|
+
db = connect(self._path)
|
|
23
|
+
rows = list(db.select(**self._select_args))
|
|
24
|
+
self._ids = [row.id for row in rows]
|
|
25
|
+
self.delta_norm_mean = float(np.mean([row.delta_norm for row in rows])) if rows else 0.0
|
|
26
|
+
del rows
|
|
27
|
+
db.close()
|
|
28
|
+
|
|
29
|
+
stats_file = Path(self._path + ".stats.json")
|
|
30
|
+
if stats_file.is_file():
|
|
31
|
+
stats = json.loads(stats_file.read_text())
|
|
32
|
+
self.delta_norm_mean = float(stats.get("delta_norm_mean", self.delta_norm_mean))
|
|
33
|
+
|
|
34
|
+
self._db_cache: dict[int, "object"] = {}
|
|
35
|
+
|
|
36
|
+
def _get_db(self):
|
|
37
|
+
key = os.getpid()
|
|
38
|
+
d = self._db_cache.get(key)
|
|
39
|
+
if d is None:
|
|
40
|
+
d = connect(self._path)
|
|
41
|
+
self._db_cache[key] = d
|
|
42
|
+
return d
|
|
43
|
+
|
|
44
|
+
def __len__(self) -> int:
|
|
45
|
+
return len(self._ids)
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, idx: int) -> dict:
|
|
48
|
+
row = self._get_db().get(id=self._ids[idx])
|
|
49
|
+
atoms = row.toatoms()
|
|
50
|
+
kvp = row.key_value_pairs
|
|
51
|
+
data = row.data
|
|
52
|
+
|
|
53
|
+
fixed = np.zeros(len(atoms), dtype=bool)
|
|
54
|
+
for c in atoms.constraints or []:
|
|
55
|
+
if type(c).__name__ == "FixAtoms":
|
|
56
|
+
fixed[c.index] = True
|
|
57
|
+
|
|
58
|
+
# `partner_un_pos` is required by the released training scheme.
|
|
59
|
+
# Older DBs without it fall back to a copy of `start_pos`
|
|
60
|
+
# (delta_partner = 0), but those DBs cannot be used for training.
|
|
61
|
+
if "partner_un_pos" in data:
|
|
62
|
+
partner_un = torch.tensor(np.asarray(data["partner_un_pos"]), dtype=torch.float32)
|
|
63
|
+
else:
|
|
64
|
+
partner_un = torch.tensor(atoms.positions, dtype=torch.float32)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"start_pos": torch.tensor(atoms.positions, dtype=torch.float32),
|
|
68
|
+
"saddle_un_pos": torch.tensor(np.asarray(data["saddle_un_pos"]), dtype=torch.float32),
|
|
69
|
+
"partner_un_pos": partner_un,
|
|
70
|
+
"Z": torch.tensor(atoms.numbers, dtype=torch.long),
|
|
71
|
+
"cell": torch.tensor(np.asarray(atoms.cell), dtype=torch.float32),
|
|
72
|
+
"fixed": torch.tensor(fixed, dtype=torch.bool),
|
|
73
|
+
"task_name": str(kvp["task_name"]),
|
|
74
|
+
"charge": int(kvp["charge"]),
|
|
75
|
+
"spin": int(kvp["spin"]),
|
|
76
|
+
"delta_norm": torch.tensor(float(kvp["delta_norm"])),
|
|
77
|
+
"role": str(kvp["role"]),
|
|
78
|
+
"triplet_id": int(kvp["triplet_id"]),
|
|
79
|
+
"metadata": data.get("metadata", {}),
|
|
80
|
+
}
|