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.
@@ -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"
@@ -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