slabterminator 0.2.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.
- slabterminator/__init__.py +7 -0
- slabterminator/batch.py +224 -0
- slabterminator/core.py +1120 -0
- slabterminator/pipeline.py +263 -0
- slabterminator/utils.py +474 -0
- slabterminator-0.2.0.dist-info/METADATA +333 -0
- slabterminator-0.2.0.dist-info/RECORD +9 -0
- slabterminator-0.2.0.dist-info/WHEEL +4 -0
- slabterminator-0.2.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""SlabTerminator: enumerate the symmetrically unique slab terminations of a bulk crystal."""
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = version("slabterminator")
|
|
6
|
+
except PackageNotFoundError: # not installed (e.g. running from a source tree)
|
|
7
|
+
__version__ = "0.0.0"
|
slabterminator/batch.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""Parallel, multi-material orchestration for the slab pipeline.
|
|
3
|
+
|
|
4
|
+
run_batch fans build_slabs_for_material out across worker processes, supervises
|
|
5
|
+
them, and streams each finished MaterialResult to a caller-supplied sink. It is
|
|
6
|
+
scheduler- and output-agnostic: the caller provides the (id, structure) stream,
|
|
7
|
+
the SlabGenConfig, and an on_result callback (e.g. a CSV or database writer). See
|
|
8
|
+
scripts/build_slab_dataset_slurm.py for a SLURM entry point that wires in a CSV sink.
|
|
9
|
+
|
|
10
|
+
multiprocessing (not asyncio or a thread pool) because slab generation is
|
|
11
|
+
CPU-bound and, crucially, a single pathological material can spin far longer than
|
|
12
|
+
its share of the budget: only a separate OS process can be forcibly killed to
|
|
13
|
+
reclaim the slot, which futures / pool executors do not allow (future.cancel()
|
|
14
|
+
cannot interrupt a task already running). run_batch therefore manages raw
|
|
15
|
+
Process objects so it can terminate() a material that overruns max_material_seconds
|
|
16
|
+
and launch the next one in its place.
|
|
17
|
+
"""
|
|
18
|
+
import multiprocessing as mp
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
from multiprocessing import Manager
|
|
24
|
+
from queue import Empty as QueueEmpty
|
|
25
|
+
|
|
26
|
+
from pymatgen.core import Structure
|
|
27
|
+
|
|
28
|
+
from slabterminator.pipeline import MaterialResult, SlabGenConfig, build_slabs_for_material
|
|
29
|
+
|
|
30
|
+
# Hard per-material wall-clock budget (seconds): a worker running longer than this
|
|
31
|
+
# is killed and its material recorded as a failure. Guards against a material stuck
|
|
32
|
+
# in a pathological / non-terminating code path holding a worker slot forever.
|
|
33
|
+
_DEFAULT_MAX_MATERIAL_SECONDS = 3 * 60 * 60 # 3 hours
|
|
34
|
+
|
|
35
|
+
# Grace period (seconds) after a worker process is first seen not-alive before it is
|
|
36
|
+
# reaped as a crash. A worker that finishes normally exits right after enqueueing its
|
|
37
|
+
# result; this grace lets that in-flight result drain first, so a clean finish is
|
|
38
|
+
# never misreported as a crash.
|
|
39
|
+
_DEAD_WORKER_GRACE_SECONDS = 5.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _worker(material_id: str, structure: Structure | dict, config: SlabGenConfig,
|
|
43
|
+
log_queue, results_queue) -> None:
|
|
44
|
+
"""Worker-process entry point: build one material's slabs and enqueue the result.
|
|
45
|
+
|
|
46
|
+
Runs build_slabs_for_material for a single material and puts (material_id,
|
|
47
|
+
MaterialResult) on results_queue. Progress messages go to the parent-owned
|
|
48
|
+
log_queue rather than being printed, so concurrent workers never interleave
|
|
49
|
+
output and all file writes stay in the parent. build_slabs_for_material catches
|
|
50
|
+
its own per-material errors, so this normally enqueues a MaterialResult(ok=False)
|
|
51
|
+
rather than raising; a hard crash or hang is handled by the parent (see
|
|
52
|
+
run_batch).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
material_id (str): id for this material (stamped onto the result).
|
|
56
|
+
structure (Structure or dict): the bulk structure, or its as_dict() form
|
|
57
|
+
(rebuilt here, so callers can stream cheap dicts to workers).
|
|
58
|
+
config (SlabGenConfig): generation parameters.
|
|
59
|
+
log_queue: parent-owned queue for progress messages.
|
|
60
|
+
results_queue: parent-owned queue the (material_id, MaterialResult) is put on.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(structure, dict):
|
|
64
|
+
structure = Structure.from_dict(structure)
|
|
65
|
+
result = build_slabs_for_material(structure, config, material_id=material_id, log=log_queue.put)
|
|
66
|
+
results_queue.put((material_id, result))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _drain_log_queue(log_queue, log: Callable[[str], None]) -> None:
|
|
70
|
+
"""Forwards queued log messages to the log callback until a None sentinel.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
log_queue: queue of message strings (None ends the loop).
|
|
74
|
+
log (Callable[[str], None]): sink for each message (e.g. print).
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
while True:
|
|
78
|
+
msg = log_queue.get()
|
|
79
|
+
if msg is None:
|
|
80
|
+
break
|
|
81
|
+
log(msg)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_batch(materials: Iterable[tuple[str, Structure | dict]],
|
|
85
|
+
config: SlabGenConfig | None = None, *, n_workers: int | None = None,
|
|
86
|
+
max_material_seconds: float = _DEFAULT_MAX_MATERIAL_SECONDS,
|
|
87
|
+
on_result: Callable[[MaterialResult], None],
|
|
88
|
+
log: Callable[[str], None] = print) -> int:
|
|
89
|
+
"""Builds slabs for many materials in parallel, streaming each result to a sink.
|
|
90
|
+
|
|
91
|
+
Keeps up to n_workers worker processes busy; a slot frees (and the next material
|
|
92
|
+
launches) only once the finished material's result has been handed to on_result,
|
|
93
|
+
so on_result is called exactly once per material, one at a time in the parent
|
|
94
|
+
process -- concurrent completions can never interleave writes to a shared sink.
|
|
95
|
+
A material whose worker overruns max_material_seconds (or crashes) is recorded as
|
|
96
|
+
a MaterialResult(ok=False) and its slot reclaimed.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
materials (Iterable[tuple[str, Structure | dict]]): (id, structure) pairs.
|
|
100
|
+
id is used as the result's material_id and as the supervisor key, so it
|
|
101
|
+
must be unique across the batch (e.g. a Materials Project id); structure
|
|
102
|
+
may be a Structure or its as_dict() form.
|
|
103
|
+
config (SlabGenConfig or None): generation parameters; defaults to
|
|
104
|
+
SlabGenConfig().
|
|
105
|
+
n_workers (int or None): number of worker processes; defaults to os.cpu_count().
|
|
106
|
+
max_material_seconds (float): hard per-material wall-clock budget; a worker
|
|
107
|
+
running longer is killed and the material recorded as failed.
|
|
108
|
+
on_result (Callable[[MaterialResult], None]): called once per material with
|
|
109
|
+
its result (ok True or False). The sink decides what to persist.
|
|
110
|
+
log (Callable[[str], None]): progress-message sink; defaults to print. All
|
|
111
|
+
messages (workers' and the supervisor's) are serialized through one
|
|
112
|
+
drain thread, so the callback is only ever called from that thread.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
int: the number of materials processed (every material is counted exactly
|
|
116
|
+
once, whether it succeeded, failed, timed out, or crashed).
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
config = config or SlabGenConfig()
|
|
120
|
+
n_workers = n_workers or os.cpu_count() or 1
|
|
121
|
+
try:
|
|
122
|
+
total = len(materials) # a sized collection; a bare generator falls through
|
|
123
|
+
except TypeError:
|
|
124
|
+
total = None
|
|
125
|
+
|
|
126
|
+
manager = Manager()
|
|
127
|
+
log_queue = manager.Queue()
|
|
128
|
+
results_queue = manager.Queue()
|
|
129
|
+
drain_thread = threading.Thread(target=_drain_log_queue, args=(log_queue, log), daemon=True)
|
|
130
|
+
drain_thread.start()
|
|
131
|
+
|
|
132
|
+
mat_iter = iter(materials)
|
|
133
|
+
running: dict[str, tuple[mp.Process, float]] = {} # id -> (Process, start_time)
|
|
134
|
+
dead_since: dict[str, float] = {} # id -> first time its process was seen not-alive
|
|
135
|
+
completed = 0
|
|
136
|
+
|
|
137
|
+
def progress() -> str:
|
|
138
|
+
return (f" ({completed} of {total} materials complete)" if total is not None
|
|
139
|
+
else f" ({completed} materials complete)")
|
|
140
|
+
|
|
141
|
+
def launch_next() -> None:
|
|
142
|
+
try:
|
|
143
|
+
material_id, structure = next(mat_iter)
|
|
144
|
+
except StopIteration:
|
|
145
|
+
return
|
|
146
|
+
p = mp.Process(target=_worker,
|
|
147
|
+
args=(material_id, structure, config, log_queue, results_queue),
|
|
148
|
+
daemon=True)
|
|
149
|
+
p.start()
|
|
150
|
+
running[material_id] = (p, time.time())
|
|
151
|
+
|
|
152
|
+
for _ in range(n_workers):
|
|
153
|
+
launch_next()
|
|
154
|
+
|
|
155
|
+
while running:
|
|
156
|
+
# Drain every result that has arrived, without blocking. A slot only frees
|
|
157
|
+
# (and the next material only launches) once its result has been delivered to
|
|
158
|
+
# on_result, so results are handled one at a time here.
|
|
159
|
+
drained_any = False
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
material_id, result = results_queue.get_nowait()
|
|
163
|
+
except QueueEmpty:
|
|
164
|
+
break
|
|
165
|
+
drained_any = True
|
|
166
|
+
entry = running.pop(material_id, None)
|
|
167
|
+
dead_since.pop(material_id, None)
|
|
168
|
+
if entry is None:
|
|
169
|
+
# This slot was already reclaimed by the timeout/crash check (the
|
|
170
|
+
# worker finished right as it was being reaped); its result raced in
|
|
171
|
+
# afterward. Skip it -- it was already counted there.
|
|
172
|
+
log_queue.put(f"Note: late result for {material_id!r} arrived after its "
|
|
173
|
+
f"slot was reclaimed; ignoring (already counted).")
|
|
174
|
+
continue
|
|
175
|
+
p, _ = entry
|
|
176
|
+
p.join()
|
|
177
|
+
completed += 1
|
|
178
|
+
on_result(result)
|
|
179
|
+
log_queue.put(progress())
|
|
180
|
+
launch_next()
|
|
181
|
+
|
|
182
|
+
# Reclaim any worker that overran its budget or died without a result.
|
|
183
|
+
now = time.time()
|
|
184
|
+
for material_id, (p, start_time) in list(running.items()):
|
|
185
|
+
if now - start_time > max_material_seconds:
|
|
186
|
+
log_queue.put(f"TIMEOUT: material {material_id!r} exceeded "
|
|
187
|
+
f"{max_material_seconds:.0f}s; killing its worker and skipping it")
|
|
188
|
+
p.terminate()
|
|
189
|
+
p.join(10)
|
|
190
|
+
if p.is_alive():
|
|
191
|
+
p.kill()
|
|
192
|
+
p.join()
|
|
193
|
+
del running[material_id]
|
|
194
|
+
dead_since.pop(material_id, None)
|
|
195
|
+
completed += 1
|
|
196
|
+
on_result(MaterialResult(material_id=material_id, ok=False,
|
|
197
|
+
error=f"timeout after {max_material_seconds:.0f}s"))
|
|
198
|
+
log_queue.put(progress())
|
|
199
|
+
launch_next()
|
|
200
|
+
elif not p.is_alive():
|
|
201
|
+
# Exited without a result on the queue. Give a brief grace for an
|
|
202
|
+
# in-flight result to drain (a clean finish exits right after
|
|
203
|
+
# enqueueing); if none appears, treat it as a crash.
|
|
204
|
+
first_seen = dead_since.setdefault(material_id, now)
|
|
205
|
+
if now - first_seen > _DEAD_WORKER_GRACE_SECONDS:
|
|
206
|
+
log_queue.put(f"CRASH: worker for material {material_id!r} exited "
|
|
207
|
+
f"without producing a result; recording as failed")
|
|
208
|
+
p.join()
|
|
209
|
+
del running[material_id]
|
|
210
|
+
dead_since.pop(material_id, None)
|
|
211
|
+
completed += 1
|
|
212
|
+
on_result(MaterialResult(material_id=material_id, ok=False,
|
|
213
|
+
error="worker exited without producing a result"))
|
|
214
|
+
log_queue.put(progress())
|
|
215
|
+
launch_next()
|
|
216
|
+
else:
|
|
217
|
+
dead_since.pop(material_id, None)
|
|
218
|
+
|
|
219
|
+
if not drained_any:
|
|
220
|
+
time.sleep(1)
|
|
221
|
+
|
|
222
|
+
log_queue.put(None)
|
|
223
|
+
drain_thread.join()
|
|
224
|
+
return completed
|