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 +40 -0
- atombatch/backends.py +178 -0
- atombatch/scheduler.py +296 -0
- atombatch/symmetrix.py +265 -0
- atombatch/tasks.py +303 -0
- atombatch-0.1.1.dist-info/METADATA +124 -0
- atombatch-0.1.1.dist-info/RECORD +9 -0
- atombatch-0.1.1.dist-info/WHEEL +4 -0
- atombatch-0.1.1.dist-info/licenses/LICENSE +675 -0
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
|