polyscript-pypi 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.
- MCP/server.py +770 -0
- polyscript/__init__.py +12 -0
- polyscript/brics/BRICSMod.py +1164 -0
- polyscript/brics/BRICS_utils.py +189 -0
- polyscript/brics/__init__.py +48 -0
- polyscript/classifier/__init__.py +270 -0
- polyscript/classifier/class_reference.py +951 -0
- polyscript/depolymerizer/__init__.py +576 -0
- polyscript/depolymerizer/mpi.py +390 -0
- polyscript/depolymerizer/reaction_reference.py +447 -0
- polyscript/polymerizer/__init__.py +22 -0
- polyscript/polymerizer/core.py +648 -0
- polyscript/polymerizer/mpi.py +860 -0
- polyscript/polymerizer/mpi_df.py +378 -0
- polyscript/polymerizer/utils.py +795 -0
- polyscript/utils/__init__.py +80 -0
- polyscript/utils/adapters.py +357 -0
- polyscript/utils/canon_analyze.py +193 -0
- polyscript/utils/executor.py +66 -0
- polyscript/utils/logger.py +331 -0
- polyscript/utils/mpi/__init__.py +33 -0
- polyscript/utils/mpi/checkpoint.py +263 -0
- polyscript/utils/mpi/logger.py +138 -0
- polyscript/utils/mpi/progress.py +134 -0
- polyscript/utils/mpi/protocol.py +113 -0
- polyscript/utils/mpi/resource.py +94 -0
- polyscript/utils/mpi/splitter.py +42 -0
- polyscript/utils/mpi_executor.py +167 -0
- polyscript/utils/mpi_validator.py +940 -0
- polyscript/utils/parsers.py +177 -0
- polyscript/utils/validators.py +201 -0
- polyscript_pypi-0.1.0.dist-info/METADATA +82 -0
- polyscript_pypi-0.1.0.dist-info/RECORD +57 -0
- polyscript_pypi-0.1.0.dist-info/WHEEL +5 -0
- polyscript_pypi-0.1.0.dist-info/licenses/LICENSE +21 -0
- polyscript_pypi-0.1.0.dist-info/top_level.txt +4 -0
- runners/adapt_dataframe.py +105 -0
- runners/brics_decompose.py +28 -0
- runners/canonicalization.py +46 -0
- runners/filter_candidates.py +54 -0
- runners/mpi_depolymerize.py +51 -0
- runners/mpi_runner.py +66 -0
- runners/mpi_runner_df.py +49 -0
- runners/mpi_validator.py +45 -0
- tests/conftest.py +10 -0
- tests/sample_gen.py +10 -0
- tests/test_brics.py +275 -0
- tests/test_classifier.py +201 -0
- tests/test_mpi_polymerize.py +170 -0
- tests/test_polymerize.py +206 -0
- tests/utils/test_adaptors.py +182 -0
- tests/utils/test_canonicalization.py +62 -0
- tests/utils/test_executor.py +101 -0
- tests/utils/test_logger.py +426 -0
- tests/utils/test_mpi_validator.py +159 -0
- tests/utils/test_parsers.py +150 -0
- tests/utils/test_validator.py +105 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MPI-parallel depolymerizer.
|
|
3
|
+
|
|
4
|
+
``MpiDepolymerizer`` is a distributed batch-depolymerization runner that
|
|
5
|
+
composes from ``polyscript.utils.mpi`` components (MpiLogger, MpiProtocol,
|
|
6
|
+
WorkSplitter, ProgressTracker) to scatter sequences across MPI ranks,
|
|
7
|
+
run ``PolyScriptDepolymerizer``, and gather per-class statistics.
|
|
8
|
+
|
|
9
|
+
Usage (launch with mpirun)::
|
|
10
|
+
|
|
11
|
+
from polyscript.depolymerizer.mpi import MpiDepolymerizer
|
|
12
|
+
|
|
13
|
+
runner = MpiDepolymerizer(
|
|
14
|
+
input_path="benchmark.parquet",
|
|
15
|
+
mode="validate",
|
|
16
|
+
)
|
|
17
|
+
stats = runner.run()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from polyscript.depolymerizer import PolyScriptDepolymerizer
|
|
29
|
+
from polyscript.utils.mpi import (
|
|
30
|
+
MpiLogger,
|
|
31
|
+
MpiProtocol,
|
|
32
|
+
ProgressTracker,
|
|
33
|
+
WorkSplitter,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MpiDepolymerizer:
|
|
38
|
+
"""Distributed depolymerization runner.
|
|
39
|
+
|
|
40
|
+
Components:
|
|
41
|
+
* ``self.logger`` — MpiLogger (direct-stderr + file)
|
|
42
|
+
* ``self.mpi`` — MpiProtocol (scatter/gather)
|
|
43
|
+
* ``self.splitter`` — WorkSplitter (round-robin)
|
|
44
|
+
* ``self.progress`` — ProgressTracker (elapsed, rate, ETA)
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
input_path : str
|
|
49
|
+
Path to a parquet or CSV file containing polymer sequences.
|
|
50
|
+
mode : str
|
|
51
|
+
``"validate"`` (PolyScript sequences) or ``"convert"`` (raw PSMILES).
|
|
52
|
+
smiles_col : str, optional
|
|
53
|
+
Column name for SMILES strings (auto-detected when ``None``).
|
|
54
|
+
output_dir : str
|
|
55
|
+
Directory for results and logs.
|
|
56
|
+
log_filepath : str
|
|
57
|
+
Log filename written inside *output_dir*.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
input_path: str,
|
|
63
|
+
*,
|
|
64
|
+
mode: str = "validate",
|
|
65
|
+
smiles_col: Optional[str] = None,
|
|
66
|
+
output_dir: str = ".",
|
|
67
|
+
log_filepath: str = "depolymerizer.log",
|
|
68
|
+
log_interval: int = 100,
|
|
69
|
+
):
|
|
70
|
+
# ── MPI init ──────────────────────────────────────────────────
|
|
71
|
+
from mpi4py import MPI as _MPI # noqa: N813
|
|
72
|
+
|
|
73
|
+
comm = _MPI.COMM_WORLD
|
|
74
|
+
self._rank = comm.Get_rank()
|
|
75
|
+
self._size = comm.Get_size()
|
|
76
|
+
self._is_master = self._rank == 0
|
|
77
|
+
|
|
78
|
+
if self._size < 2:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"MpiDepolymerizer needs at least 2 MPI ranks. "
|
|
81
|
+
f"Got {self._size}. Launch with mpirun."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# ── Components ────────────────────────────────────────────────
|
|
85
|
+
self.mpi = MpiProtocol(comm, self._rank, self._size, self._is_master)
|
|
86
|
+
self.splitter = WorkSplitter()
|
|
87
|
+
self._log_interval = max(log_interval, 1)
|
|
88
|
+
|
|
89
|
+
self._input_path = str(Path(input_path).resolve())
|
|
90
|
+
self._mode = mode
|
|
91
|
+
self._smiles_col = smiles_col
|
|
92
|
+
self._output_dir = Path(output_dir).resolve()
|
|
93
|
+
|
|
94
|
+
if self._is_master:
|
|
95
|
+
self.logger = MpiLogger(
|
|
96
|
+
name="depolymerizer",
|
|
97
|
+
log_filepath=str(self._output_dir / log_filepath),
|
|
98
|
+
progress_file=str(self._output_dir / "progress.txt"),
|
|
99
|
+
use_rich=False,
|
|
100
|
+
use_direct_stderr=True,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
import logging
|
|
104
|
+
self.logger = logging.getLogger(__name__ + f".rank{self._rank}")
|
|
105
|
+
self.logger.setLevel(logging.WARNING)
|
|
106
|
+
|
|
107
|
+
self.mpi.barrier()
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Lifecycle
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def close(self):
|
|
114
|
+
"""Flush and close the logger if present."""
|
|
115
|
+
if hasattr(self, "logger"):
|
|
116
|
+
try:
|
|
117
|
+
self.logger.flush()
|
|
118
|
+
self.logger.close()
|
|
119
|
+
except AttributeError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def __enter__(self):
|
|
123
|
+
"""Support context-manager usage (``with MpiDepolymerizer(...) as``)."""
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, *args):
|
|
127
|
+
"""Clean up on context-manager exit."""
|
|
128
|
+
self.close()
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Master: read input & scatter
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _load_sequences(self) -> tuple:
|
|
135
|
+
"""Read the input file and return (sequences, indices, classes)."""
|
|
136
|
+
ip = Path(self._input_path)
|
|
137
|
+
if ip.suffix == ".parquet":
|
|
138
|
+
df = pd.read_parquet(ip)
|
|
139
|
+
else:
|
|
140
|
+
df = pd.read_csv(ip)
|
|
141
|
+
|
|
142
|
+
if self._mode == "convert":
|
|
143
|
+
col = self._smiles_col
|
|
144
|
+
if col is None:
|
|
145
|
+
for candidate in ["smiles", "canonical_smiles", "psmiles", "polym"]:
|
|
146
|
+
if candidate in df.columns:
|
|
147
|
+
col = candidate
|
|
148
|
+
break
|
|
149
|
+
if col is None:
|
|
150
|
+
raise KeyError("No SMILES column found. Pass --smiles_col.")
|
|
151
|
+
sequences = df[col].dropna().tolist()
|
|
152
|
+
classes = ["unknown"] * len(sequences)
|
|
153
|
+
else:
|
|
154
|
+
sequences = df["polym"].tolist()
|
|
155
|
+
classes = (
|
|
156
|
+
df["polymer_class"].tolist()
|
|
157
|
+
if "polymer_class" in df.columns
|
|
158
|
+
else ["unknown"] * len(sequences)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
indices = list(range(len(sequences)))
|
|
162
|
+
return sequences, indices, classes
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Worker: process one sequence
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _process_sequence(
|
|
170
|
+
dp: PolyScriptDepolymerizer,
|
|
171
|
+
seq: str,
|
|
172
|
+
mode: str,
|
|
173
|
+
) -> list:
|
|
174
|
+
"""Run depolymerization on a single sequence. Returns pathway list."""
|
|
175
|
+
if mode == "convert":
|
|
176
|
+
return dp.convert_psmiles(seq)
|
|
177
|
+
return dp.validate(seq)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# run
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def run(self) -> Dict:
|
|
184
|
+
"""Run the distributed depolymerization job.
|
|
185
|
+
|
|
186
|
+
On the master rank (0), loads sequences, scatters them to
|
|
187
|
+
workers, processes its own chunk, gathers results, and returns
|
|
188
|
+
summary statistics. Worker ranks process their chunk and return
|
|
189
|
+
an empty dict.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
dict: Summary stats with keys ``total``, ``success``,
|
|
193
|
+
``failed``, ``pathways`` (master only; workers return ``{}``).
|
|
194
|
+
"""
|
|
195
|
+
if self._is_master:
|
|
196
|
+
return self._run_master()
|
|
197
|
+
else:
|
|
198
|
+
self._run_worker()
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# Master orchestration
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def _run_master(self) -> Dict:
|
|
206
|
+
"""Master orchestration: load, scatter, process, gather, summarize.
|
|
207
|
+
|
|
208
|
+
Loads the input file, distributes sequences across MPI ranks
|
|
209
|
+
via round-robin, processes the master's own chunk, gathers
|
|
210
|
+
results from all ranks, logs per-class statistics, and saves
|
|
211
|
+
results/failures to parquet files.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
dict: Summary stats (``total``, ``success``, ``failed``,
|
|
215
|
+
``pathways``).
|
|
216
|
+
"""
|
|
217
|
+
comm = self.mpi.comm
|
|
218
|
+
t_start = time.monotonic()
|
|
219
|
+
|
|
220
|
+
# ── Load & scatter ────────────────────────────────────────────
|
|
221
|
+
sequences, indices, classes = self._load_sequences()
|
|
222
|
+
total = len(sequences)
|
|
223
|
+
|
|
224
|
+
seq_chunks = self.splitter.round_robin(sequences, self._size)
|
|
225
|
+
idx_chunks = self.splitter.round_robin(indices, self._size)
|
|
226
|
+
cls_chunks = self.splitter.round_robin(classes, self._size)
|
|
227
|
+
|
|
228
|
+
self.logger.info(
|
|
229
|
+
"Loaded %d sequences, distributing across %d ranks", total, self._size,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
comm.scatter(seq_chunks, root=0)
|
|
233
|
+
comm.scatter(idx_chunks, root=0)
|
|
234
|
+
comm.scatter(cls_chunks, root=0)
|
|
235
|
+
|
|
236
|
+
# ── Process master's own chunk ────────────────────────────────
|
|
237
|
+
my_seqs = seq_chunks[0]
|
|
238
|
+
my_idxs = idx_chunks[0]
|
|
239
|
+
my_clss = cls_chunks[0]
|
|
240
|
+
|
|
241
|
+
results, fails, ok, bad, per_class = self._process_chunk(
|
|
242
|
+
my_seqs, my_idxs, my_clss, t_start,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# ── Gather from workers ───────────────────────────────────────
|
|
246
|
+
all_results = comm.gather(results, root=0)
|
|
247
|
+
all_fails = comm.gather(fails, root=0)
|
|
248
|
+
all_per_class = comm.gather(per_class, root=0)
|
|
249
|
+
|
|
250
|
+
# ── Summarise ─────────────────────────────────────────────────
|
|
251
|
+
aggregated: dict[str, int] = {}
|
|
252
|
+
for pc_dict in all_per_class:
|
|
253
|
+
for pclass, count in pc_dict.items():
|
|
254
|
+
aggregated[pclass] = aggregated.get(pclass, 0) + count
|
|
255
|
+
|
|
256
|
+
# Include master's own results
|
|
257
|
+
flat_results = [r for chunk in all_results for r in chunk]
|
|
258
|
+
flat_fails = [f for chunk in all_fails for f in chunk]
|
|
259
|
+
|
|
260
|
+
seq_ok = {r.seq_index for r in flat_results}
|
|
261
|
+
seq_fail_idx = {f["seq_index"] for f in flat_fails}
|
|
262
|
+
n_total = len(seq_ok) + len(seq_fail_idx)
|
|
263
|
+
|
|
264
|
+
self.logger.info("=" * 60)
|
|
265
|
+
self.logger.info("DEPOLYMERIZATION SUMMARY")
|
|
266
|
+
self.logger.info("=" * 60)
|
|
267
|
+
self.logger.info(" MPI ranks: %d", self._size)
|
|
268
|
+
self.logger.info(" Total sequences: %d", n_total)
|
|
269
|
+
self.logger.info(" Success: %d", len(seq_ok))
|
|
270
|
+
self.logger.info(" Failed: %d", len(seq_fail_idx))
|
|
271
|
+
self.logger.info(" Total pathways: %d", len(flat_results))
|
|
272
|
+
|
|
273
|
+
for pclass in sorted(aggregated):
|
|
274
|
+
pclass_fails = sum(
|
|
275
|
+
1 for f in flat_fails if f["polymer_class"] == pclass
|
|
276
|
+
)
|
|
277
|
+
total_class = aggregated[pclass] + pclass_fails
|
|
278
|
+
pct = 100 * aggregated[pclass] / max(1, total_class)
|
|
279
|
+
self.logger.info(
|
|
280
|
+
" %-20s %4d/%d (%.0f%%)",
|
|
281
|
+
pclass, aggregated[pclass], total_class, pct,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# ── Save results ──────────────────────────────────────────────
|
|
285
|
+
if flat_results:
|
|
286
|
+
out_df = pd.DataFrame([
|
|
287
|
+
{
|
|
288
|
+
"seq_index": r.seq_index,
|
|
289
|
+
"input_monomers": r.input_monomers,
|
|
290
|
+
"output_monomers": r.monomers,
|
|
291
|
+
"exact_match": r.is_exact_match,
|
|
292
|
+
"reaction": r.reaction,
|
|
293
|
+
"polymer": r.polymer,
|
|
294
|
+
"polymer_pattern": r.polymer_pattern,
|
|
295
|
+
}
|
|
296
|
+
for r in flat_results
|
|
297
|
+
])
|
|
298
|
+
results_path = self._output_dir / "depolymerize_results.parquet"
|
|
299
|
+
out_df.to_parquet(results_path)
|
|
300
|
+
self.logger.info(" Saved %d pathways to %s", len(out_df), results_path)
|
|
301
|
+
|
|
302
|
+
if flat_fails:
|
|
303
|
+
fail_df = pd.DataFrame(flat_fails)
|
|
304
|
+
fail_path = self._output_dir / "depolymerize_fails.parquet"
|
|
305
|
+
fail_df.to_parquet(fail_path)
|
|
306
|
+
self.logger.info(" Saved %d fail cases to %s", len(fail_df), fail_path)
|
|
307
|
+
|
|
308
|
+
self.mpi.shutdown_workers()
|
|
309
|
+
self.logger.flush()
|
|
310
|
+
return {
|
|
311
|
+
"total": n_total,
|
|
312
|
+
"success": len(seq_ok),
|
|
313
|
+
"failed": len(seq_fail_idx),
|
|
314
|
+
"pathways": len(flat_results),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
# Shared: process a chunk of sequences
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def _process_chunk(
|
|
322
|
+
self,
|
|
323
|
+
sequences: list,
|
|
324
|
+
indices: list,
|
|
325
|
+
classes: list,
|
|
326
|
+
t_start: float,
|
|
327
|
+
) -> tuple:
|
|
328
|
+
"""Run depolymerization on a local chunk. Returns results tuple."""
|
|
329
|
+
dp = PolyScriptDepolymerizer()
|
|
330
|
+
results = []
|
|
331
|
+
fails = []
|
|
332
|
+
ok = 0
|
|
333
|
+
bad = 0
|
|
334
|
+
per_class: dict[str, int] = {}
|
|
335
|
+
n = len(sequences)
|
|
336
|
+
|
|
337
|
+
progress = ProgressTracker(total=n, log_interval=self._log_interval)
|
|
338
|
+
|
|
339
|
+
for i, (seq, gidx, pclass) in enumerate(zip(sequences, indices, classes)):
|
|
340
|
+
seq_results = self._process_sequence(dp, seq, self._mode)
|
|
341
|
+
|
|
342
|
+
if seq_results:
|
|
343
|
+
ok += 1
|
|
344
|
+
per_class[pclass] = per_class.get(pclass, 0) + 1
|
|
345
|
+
for r in seq_results:
|
|
346
|
+
r.seq_index = gidx
|
|
347
|
+
results.append(r)
|
|
348
|
+
else:
|
|
349
|
+
bad += 1
|
|
350
|
+
fails.append(
|
|
351
|
+
{"seq_index": gidx, "sequence": seq, "polymer_class": pclass}
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
progress.tick(ok=bool(seq_results))
|
|
355
|
+
|
|
356
|
+
if progress.should_log() or i == n - 1:
|
|
357
|
+
self.logger.info(
|
|
358
|
+
"rank %2d: %s",
|
|
359
|
+
self._rank, progress.format_line(),
|
|
360
|
+
)
|
|
361
|
+
progress.mark_logged()
|
|
362
|
+
|
|
363
|
+
self.logger.info(
|
|
364
|
+
"rank %2d: DONE ok=%d fail=%d out of %d (%.1fs)",
|
|
365
|
+
self._rank, ok, bad, n, progress.elapsed,
|
|
366
|
+
)
|
|
367
|
+
return results, fails, ok, bad, per_class
|
|
368
|
+
|
|
369
|
+
# ------------------------------------------------------------------
|
|
370
|
+
# Worker loop
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def _run_worker(self):
|
|
374
|
+
"""Worker loop: receive chunk from master, process, send back results."""
|
|
375
|
+
comm = self.mpi.comm
|
|
376
|
+
|
|
377
|
+
# Receive scattered data from master
|
|
378
|
+
my_sequences = comm.scatter(None, root=0)
|
|
379
|
+
my_indices = comm.scatter(None, root=0)
|
|
380
|
+
my_classes = comm.scatter(None, root=0)
|
|
381
|
+
|
|
382
|
+
t_start = time.monotonic()
|
|
383
|
+
results, fails, ok, bad, per_class = self._process_chunk(
|
|
384
|
+
my_sequences, my_indices, my_classes, t_start,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Send results back
|
|
388
|
+
comm.gather(results, root=0)
|
|
389
|
+
comm.gather(fails, root=0)
|
|
390
|
+
comm.gather(per_class, root=0)
|