atombatch 0.1.1__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.
atombatch/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Batch energy and force evaluations from many independent ASE tasks."""
2
+
3
+ from .backends import (
4
+ Backend,
5
+ FunctionBackend,
6
+ SequentialBackend,
7
+ SymmetrixBackend,
8
+ TorchSimBackend,
9
+ )
10
+ from .scheduler import BatchCalculator, Scheduler, TaskContext, TaskResult
11
+ from .tasks import (
12
+ displacement_forces,
13
+ equation_of_state,
14
+ molecular_dynamics,
15
+ neb,
16
+ relax,
17
+ single_point,
18
+ swap_monte_carlo,
19
+ )
20
+
21
+ __version__ = "0.1.1"
22
+
23
+ __all__ = [
24
+ "Backend",
25
+ "BatchCalculator",
26
+ "FunctionBackend",
27
+ "Scheduler",
28
+ "SequentialBackend",
29
+ "SymmetrixBackend",
30
+ "TaskContext",
31
+ "TaskResult",
32
+ "TorchSimBackend",
33
+ "displacement_forces",
34
+ "equation_of_state",
35
+ "molecular_dynamics",
36
+ "neb",
37
+ "relax",
38
+ "single_point",
39
+ "swap_monte_carlo",
40
+ ]
atombatch/backends.py ADDED
@@ -0,0 +1,178 @@
1
+ """Evaluation backends: map a list of structures to a list of results."""
2
+
3
+ import copy
4
+ import sys
5
+
6
+ import numpy as np
7
+ from ase.calculators.calculator import all_changes
8
+ from ase.stress import full_3x3_to_voigt_6_stress
9
+
10
+ from .symmetrix import GraphBatcher, supports_graph_batching
11
+
12
+
13
+ class Backend:
14
+ """Evaluate a list of ASE structures with one model.
15
+
16
+ ``evaluate`` returns one result dictionary per structure with at least
17
+ ``energy`` and ``forces``, plus ``stress`` (ASE Voigt order) when
18
+ requested. ``max_atoms`` bounds the number of atoms in one call; ``None``
19
+ means unbounded.
20
+ """
21
+
22
+ max_atoms = None
23
+
24
+ def evaluate(self, structures, properties):
25
+ raise NotImplementedError
26
+
27
+
28
+ class SequentialBackend(Backend):
29
+ """Evaluate structures one at a time with any ASE calculator.
30
+
31
+ This provides scheduling for calculators without batched evaluation; it
32
+ does not reduce the per-call cost.
33
+ """
34
+
35
+ def __init__(self, calculator, max_atoms=None):
36
+ self.calculator = calculator
37
+ self.max_atoms = max_atoms
38
+
39
+ def evaluate(self, structures, properties):
40
+ results = []
41
+ for atoms in structures:
42
+ self.calculator.calculate(atoms.copy(), list(properties), all_changes)
43
+ results.append(
44
+ {
45
+ key: np.array(value, copy=True)
46
+ if isinstance(value, np.ndarray)
47
+ else copy.copy(value)
48
+ for key, value in self.calculator.results.items()
49
+ }
50
+ )
51
+ return results
52
+
53
+
54
+ class SymmetrixBackend(Backend):
55
+ """Evaluate structures with a Symmetrix calculator in one native call.
56
+
57
+ Releases with ``Symmetrix.calculate_batch`` evaluate the batch themselves.
58
+ For earlier releases (from Symmetrix-XL 0.1.1), atombatch joins the
59
+ structures into one disconnected neighbor graph and calls the evaluator
60
+ directly (:mod:`atombatch.symmetrix`). Models with dispersion corrections
61
+ or electric-field coupling fall back to one ASE evaluation per structure.
62
+ """
63
+
64
+ def __init__(self, calculator, max_atoms=None, batch_mode="native"):
65
+ self.calculator = calculator
66
+ self.max_atoms = max_atoms
67
+ self.batch_mode = batch_mode
68
+ self._graph = None
69
+ if callable(getattr(calculator, "calculate_batch", None)):
70
+ return
71
+ if not supports_graph_batching(calculator):
72
+ raise TypeError(
73
+ "SymmetrixBackend requires a Symmetrix calculator "
74
+ "(Symmetrix-XL 0.1.1 or later)."
75
+ )
76
+ self._graph = GraphBatcher(calculator)
77
+
78
+ def evaluate(self, structures, properties):
79
+ if self._graph is not None:
80
+ return self._graph.evaluate(structures, properties)
81
+ return self.calculator.calculate_batch(
82
+ structures, properties=tuple(properties), batch_mode=self.batch_mode
83
+ )
84
+
85
+
86
+ class TorchSimBackend(Backend):
87
+ """Evaluate structures in one call of a TorchSim ``ModelInterface``.
88
+
89
+ TorchSim provides batched interfaces for MACE, fairchem, SevenNet, ORB,
90
+ MatterSim, NequIP, Nequix, metatomic, and classical potentials. The
91
+ structures are joined into one ``SimState`` on the model's device and
92
+ dtype, and the outputs are split back per structure. Stress requires a
93
+ model constructed with ``compute_stress=True``.
94
+ """
95
+
96
+ def __init__(self, model, max_atoms=None):
97
+ self.model = model
98
+ self.max_atoms = max_atoms
99
+
100
+ def evaluate(self, structures, properties):
101
+ import torch_sim
102
+
103
+ if "stress" in properties and not self.model.compute_stress:
104
+ raise ValueError(
105
+ "Stress was requested from a TorchSim model constructed "
106
+ "without compute_stress=True."
107
+ )
108
+ state = torch_sim.io.atoms_to_state(
109
+ list(structures), device=self.model.device, dtype=self.model.dtype
110
+ )
111
+ output = self.model(state)
112
+ energies = _numpy(output["energy"]).reshape(-1)
113
+ forces = _numpy(output["forces"])
114
+ stresses = _numpy(output["stress"]) if "stress" in properties else None
115
+ results, start = [], 0
116
+ for index, atoms in enumerate(structures):
117
+ stop = start + len(atoms)
118
+ result = {
119
+ "energy": float(energies[index]),
120
+ "forces": forces[start:stop].copy(),
121
+ }
122
+ if stresses is not None:
123
+ result["stress"] = full_3x3_to_voigt_6_stress(stresses[index])
124
+ results.append(result)
125
+ start = stop
126
+ return results
127
+
128
+
129
+ class FunctionBackend(Backend):
130
+ """Evaluate structures with a user function of the backend signature.
131
+
132
+ ``function(structures, properties)`` returns one result dictionary per
133
+ structure, as :meth:`Backend.evaluate` does. This connects any model with
134
+ its own batched evaluation without subclassing :class:`Backend`.
135
+ """
136
+
137
+ def __init__(self, function, max_atoms=None):
138
+ self.function = function
139
+ self.max_atoms = max_atoms
140
+
141
+ def evaluate(self, structures, properties):
142
+ return self.function(structures, properties)
143
+
144
+
145
+ def _numpy(tensor):
146
+ return tensor.detach().cpu().numpy()
147
+
148
+
149
+ def _is_torch_sim_model(model):
150
+ # Checked only when TorchSim is already imported, so that ASE-only runs
151
+ # do not import torch.
152
+ interface = sys.modules.get("torch_sim.models.interface")
153
+ return interface is not None and isinstance(model, interface.ModelInterface)
154
+
155
+
156
+ def as_backend(model, max_atoms=None):
157
+ """Return ``model`` as a backend.
158
+
159
+ Backends are used as given. Symmetrix calculators and TorchSim models
160
+ are evaluated in batched calls. Other ASE calculators
161
+ are evaluated one structure at a time, and plain functions are wrapped in
162
+ :class:`FunctionBackend`.
163
+ """
164
+ if isinstance(model, Backend):
165
+ if max_atoms is not None:
166
+ model.max_atoms = max_atoms
167
+ return model
168
+ if callable(getattr(model, "calculate_batch", None)) or supports_graph_batching(
169
+ model
170
+ ):
171
+ return SymmetrixBackend(model, max_atoms=max_atoms)
172
+ if _is_torch_sim_model(model):
173
+ return TorchSimBackend(model, max_atoms=max_atoms)
174
+ if callable(getattr(model, "calculate", None)):
175
+ return SequentialBackend(model, max_atoms=max_atoms)
176
+ if callable(model):
177
+ return FunctionBackend(model, max_atoms=max_atoms)
178
+ raise TypeError(f"Cannot use {type(model).__name__} as a model backend.")
atombatch/scheduler.py ADDED
@@ -0,0 +1,296 @@
1
+ """Run many ASE tasks and batch their energy and force requests per model."""
2
+
3
+ import threading
4
+ from collections import deque
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from ase.calculators.calculator import Calculator, all_changes
10
+
11
+ from .backends import as_backend
12
+
13
+ _REQUIRED = ("energy", "forces")
14
+
15
+
16
+ @dataclass
17
+ class TaskResult:
18
+ """Outcome of one task: its return value, or the exception it raised."""
19
+
20
+ name: str
21
+ value: Any = None
22
+ error: BaseException | None = None
23
+
24
+ @property
25
+ def ok(self):
26
+ return self.error is None
27
+
28
+
29
+ @dataclass
30
+ class _Request:
31
+ model: str
32
+ atoms: Any
33
+ properties: tuple
34
+ result: dict | None = None
35
+ error: BaseException | None = None
36
+
37
+
38
+ @dataclass
39
+ class _Call:
40
+ """Requests one task thread waits on together."""
41
+
42
+ requests: list
43
+ event: threading.Event = field(default_factory=threading.Event)
44
+
45
+
46
+ class BatchCalculator(Calculator):
47
+ """ASE calculator whose evaluations are batched by a :class:`Scheduler`.
48
+
49
+ Each instance belongs to one task and one model. ``calculate`` submits a
50
+ request and blocks the task's thread until the scheduler has evaluated
51
+ it together with the requests of the other tasks.
52
+
53
+ Calculators created together with :meth:`TaskContext.linked_calculators`
54
+ form a group: a request from one member also evaluates every other member
55
+ whose structure changed, so a task that queries several structures in
56
+ turn, such as an NEB band, submits them as one request.
57
+ """
58
+
59
+ implemented_properties = [ # noqa: RUF012 (ASE calculator convention)
60
+ "energy",
61
+ "free_energy",
62
+ "energies",
63
+ "forces",
64
+ "stress",
65
+ ]
66
+
67
+ def __init__(self, scheduler, model, properties=_REQUIRED):
68
+ super().__init__()
69
+ self._scheduler = scheduler
70
+ self._model = model
71
+ self._properties = tuple(dict.fromkeys((*_REQUIRED, *properties)))
72
+ self._group = None
73
+
74
+ def calculate(self, atoms=None, properties=("energy",), system_changes=all_changes):
75
+ super().calculate(atoms, properties, system_changes)
76
+ requested = tuple(dict.fromkeys((*self._properties, *properties)))
77
+ members = [(self, atoms)]
78
+ for calculator, other in self._group or ():
79
+ if calculator is self or other.calc is not calculator:
80
+ continue
81
+ stale = calculator.check_state(other) or any(
82
+ prop not in calculator.results for prop in requested
83
+ )
84
+ if stale:
85
+ members.append((calculator, other))
86
+ results = self._scheduler._evaluate(
87
+ [(calculator._model, other, requested) for calculator, other in members]
88
+ )
89
+ for (calculator, other), result in zip(members, results):
90
+ if calculator is not self:
91
+ calculator.atoms = other.copy()
92
+ calculator.results = result
93
+
94
+
95
+ class TaskContext:
96
+ """Handle given to a task function to request batched evaluations."""
97
+
98
+ def __init__(self, scheduler, model):
99
+ self._scheduler = scheduler
100
+ self.model = model
101
+
102
+ def calculator(self, model=None, properties=_REQUIRED):
103
+ """Return a new batched calculator for ``model`` (default: the task's).
104
+
105
+ Every request from the calculator includes ``properties``, so a task
106
+ that reads several properties at each state (for example forces and
107
+ stress under a cell filter) is evaluated once per state.
108
+ """
109
+ model = self.model if model is None else model
110
+ if model not in self._scheduler._backends:
111
+ raise KeyError(f"Unknown model {model!r}.")
112
+ return BatchCalculator(self._scheduler, model, properties)
113
+
114
+ def evaluate(self, structures, model=None, properties=_REQUIRED):
115
+ """Evaluate independent structures in one request and return the results.
116
+
117
+ Use this when a task needs many evaluations that do not depend on each
118
+ other, such as displaced supercells or trial configurations: they join
119
+ the same batched call instead of one call per round. Each result is a
120
+ dictionary with ``energy``, ``free_energy``, ``forces``, and any further
121
+ ``properties``.
122
+ """
123
+ model = self.model if model is None else model
124
+ if model not in self._scheduler._backends:
125
+ raise KeyError(f"Unknown model {model!r}.")
126
+ if not structures:
127
+ return []
128
+ requested = tuple(dict.fromkeys((*_REQUIRED, *properties)))
129
+ return self._scheduler._evaluate(
130
+ [(model, atoms, requested) for atoms in structures]
131
+ )
132
+
133
+ def linked_calculators(self, structures, model=None, properties=_REQUIRED):
134
+ """Attach one grouped calculator to each structure and return them."""
135
+ calculators = [self.calculator(model, properties) for _ in structures]
136
+ group = list(zip(calculators, structures))
137
+ for calculator, atoms in group:
138
+ calculator._group = group
139
+ atoms.calc = calculator
140
+ return calculators
141
+
142
+
143
+ @dataclass
144
+ class _Task:
145
+ name: str
146
+ function: Callable
147
+ model: str
148
+ thread: threading.Thread | None = None
149
+ result: TaskResult | None = None
150
+
151
+
152
+ class Scheduler:
153
+ """Run ASE tasks in worker threads and batch their model evaluations.
154
+
155
+ Each task runs ordinary ASE code with :class:`BatchCalculator` instances.
156
+ When every running task is waiting for a result, the scheduler evaluates
157
+ all pending requests in batched calls, one or more per model, and resumes
158
+ the tasks. At most ``max_active`` tasks run at once; finished tasks are
159
+ replaced from the queue so that batches stay full.
160
+ """
161
+
162
+ def __init__(self, max_active=None):
163
+ self.max_active = max_active
164
+ self._backends = {}
165
+ self._queue = deque()
166
+ self._tasks = []
167
+ self._active = set()
168
+ self._pending = []
169
+ self._waiting = 0
170
+ self._condition = threading.Condition()
171
+ self.batch_log = []
172
+
173
+ def add_model(self, name, model, max_atoms=None):
174
+ """Register a model under ``name``.
175
+
176
+ ``model`` is a :class:`~atombatch.Backend`, a Symmetrix calculator with
177
+ ``calculate_batch``, a TorchSim model, a function with the
178
+ :meth:`Backend.evaluate` signature, or any other ASE calculator, which
179
+ is evaluated one structure at a time. ``max_atoms`` bounds the atoms in
180
+ one batched call.
181
+ """
182
+ self._backends[name] = as_backend(model, max_atoms=max_atoms)
183
+
184
+ def submit(self, function, model, name=None):
185
+ """Queue ``function(context)``; its return value becomes the result."""
186
+ if model not in self._backends:
187
+ raise KeyError(f"Unknown model {model!r}; register it with add_model.")
188
+ task = _Task(name or f"task-{len(self._tasks)}", function, model)
189
+ self._tasks.append(task)
190
+ self._queue.append(task)
191
+ return task.name
192
+
193
+ def run(self):
194
+ """Run all queued tasks and return their results in submission order."""
195
+ tasks = list(self._queue)
196
+ self._admit()
197
+ while True:
198
+ with self._condition:
199
+ self._condition.wait_for(lambda: len(self._active) == self._waiting)
200
+ if not self._active and not self._queue:
201
+ break
202
+ calls, self._pending = self._pending, []
203
+ if calls:
204
+ self._serve(calls)
205
+ self._admit()
206
+ return [task.result for task in tasks]
207
+
208
+ def _admit(self):
209
+ with self._condition:
210
+ while self._queue and (
211
+ self.max_active is None or len(self._active) < self.max_active
212
+ ):
213
+ task = self._queue.popleft()
214
+ self._active.add(task.name)
215
+ task.thread = threading.Thread(
216
+ target=self._run_task, args=(task,), name=task.name, daemon=True
217
+ )
218
+ task.thread.start()
219
+
220
+ def _run_task(self, task):
221
+ result = TaskResult(task.name)
222
+ task.result = result
223
+ try:
224
+ result.value = task.function(TaskContext(self, task.model))
225
+ except Exception as error: # noqa: BLE001 (reported in the task result)
226
+ result.error = error
227
+ finally:
228
+ with self._condition:
229
+ self._active.discard(task.name)
230
+ self._condition.notify_all()
231
+
232
+ def _evaluate(self, items):
233
+ call = _Call(
234
+ [
235
+ _Request(model, atoms.copy(), properties)
236
+ for model, atoms, properties in items
237
+ ]
238
+ )
239
+ with self._condition:
240
+ self._pending.append(call)
241
+ self._waiting += 1
242
+ self._condition.notify_all()
243
+ call.event.wait()
244
+ for request in call.requests:
245
+ if request.error is not None:
246
+ raise request.error
247
+ return [request.result for request in call.requests]
248
+
249
+ def _serve(self, calls):
250
+ requests = [request for call in calls for request in call.requests]
251
+ groups = {}
252
+ for request in requests:
253
+ stress = "stress" in request.properties
254
+ groups.setdefault((request.model, stress), []).append(request)
255
+ for (model, stress), group in groups.items():
256
+ backend = self._backends[model]
257
+ properties = (*_REQUIRED, "stress") if stress else _REQUIRED
258
+ for chunk in _chunks(group, backend.max_atoms):
259
+ self.batch_log.append(
260
+ (model, len(chunk), sum(len(r.atoms) for r in chunk))
261
+ )
262
+ try:
263
+ results = backend.evaluate([r.atoms for r in chunk], properties)
264
+ if len(results) != len(chunk):
265
+ raise RuntimeError(
266
+ f"Backend {model!r} returned {len(results)} results "
267
+ f"for {len(chunk)} structures."
268
+ )
269
+ except Exception as error: # noqa: BLE001 (raised in each task)
270
+ for request in chunk:
271
+ request.error = error
272
+ continue
273
+ for request, result in zip(chunk, results):
274
+ result = dict(result)
275
+ result.setdefault("free_energy", result["energy"])
276
+ request.result = result
277
+ with self._condition:
278
+ self._waiting -= len(calls)
279
+ for call in calls:
280
+ call.event.set()
281
+
282
+
283
+ def _chunks(requests, max_atoms):
284
+ if max_atoms is None:
285
+ yield requests
286
+ return
287
+ chunk, atoms = [], 0
288
+ for request in requests:
289
+ size = len(request.atoms)
290
+ if chunk and atoms + size > max_atoms:
291
+ yield chunk
292
+ chunk, atoms = [], 0
293
+ chunk.append(request)
294
+ atoms += size
295
+ if chunk:
296
+ yield chunk