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 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()
@@ -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
+ }