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.
Files changed (57) hide show
  1. MCP/server.py +770 -0
  2. polyscript/__init__.py +12 -0
  3. polyscript/brics/BRICSMod.py +1164 -0
  4. polyscript/brics/BRICS_utils.py +189 -0
  5. polyscript/brics/__init__.py +48 -0
  6. polyscript/classifier/__init__.py +270 -0
  7. polyscript/classifier/class_reference.py +951 -0
  8. polyscript/depolymerizer/__init__.py +576 -0
  9. polyscript/depolymerizer/mpi.py +390 -0
  10. polyscript/depolymerizer/reaction_reference.py +447 -0
  11. polyscript/polymerizer/__init__.py +22 -0
  12. polyscript/polymerizer/core.py +648 -0
  13. polyscript/polymerizer/mpi.py +860 -0
  14. polyscript/polymerizer/mpi_df.py +378 -0
  15. polyscript/polymerizer/utils.py +795 -0
  16. polyscript/utils/__init__.py +80 -0
  17. polyscript/utils/adapters.py +357 -0
  18. polyscript/utils/canon_analyze.py +193 -0
  19. polyscript/utils/executor.py +66 -0
  20. polyscript/utils/logger.py +331 -0
  21. polyscript/utils/mpi/__init__.py +33 -0
  22. polyscript/utils/mpi/checkpoint.py +263 -0
  23. polyscript/utils/mpi/logger.py +138 -0
  24. polyscript/utils/mpi/progress.py +134 -0
  25. polyscript/utils/mpi/protocol.py +113 -0
  26. polyscript/utils/mpi/resource.py +94 -0
  27. polyscript/utils/mpi/splitter.py +42 -0
  28. polyscript/utils/mpi_executor.py +167 -0
  29. polyscript/utils/mpi_validator.py +940 -0
  30. polyscript/utils/parsers.py +177 -0
  31. polyscript/utils/validators.py +201 -0
  32. polyscript_pypi-0.1.0.dist-info/METADATA +82 -0
  33. polyscript_pypi-0.1.0.dist-info/RECORD +57 -0
  34. polyscript_pypi-0.1.0.dist-info/WHEEL +5 -0
  35. polyscript_pypi-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. polyscript_pypi-0.1.0.dist-info/top_level.txt +4 -0
  37. runners/adapt_dataframe.py +105 -0
  38. runners/brics_decompose.py +28 -0
  39. runners/canonicalization.py +46 -0
  40. runners/filter_candidates.py +54 -0
  41. runners/mpi_depolymerize.py +51 -0
  42. runners/mpi_runner.py +66 -0
  43. runners/mpi_runner_df.py +49 -0
  44. runners/mpi_validator.py +45 -0
  45. tests/conftest.py +10 -0
  46. tests/sample_gen.py +10 -0
  47. tests/test_brics.py +275 -0
  48. tests/test_classifier.py +201 -0
  49. tests/test_mpi_polymerize.py +170 -0
  50. tests/test_polymerize.py +206 -0
  51. tests/utils/test_adaptors.py +182 -0
  52. tests/utils/test_canonicalization.py +62 -0
  53. tests/utils/test_executor.py +101 -0
  54. tests/utils/test_logger.py +426 -0
  55. tests/utils/test_mpi_validator.py +159 -0
  56. tests/utils/test_parsers.py +150 -0
  57. tests/utils/test_validator.py +105 -0
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ import pandas as pd
7
+ from polyscript.brics.BRICSMod import BRICSBuild, BRICSDecompose
8
+ from polyscript.utils.logger import INFO, PolyLogger
9
+ from rdkit import Chem
10
+
11
+
12
+ def read_df(df_path: str, column_name: str):
13
+ """Read a column of data from a CSV file.
14
+
15
+ Args:
16
+ df_path: Path to a CSV file.
17
+ column_name: Name of the column to extract.
18
+
19
+ Returns:
20
+ numpy.ndarray: Values from the specified column.
21
+
22
+ Raises:
23
+ ValueError: If the file is not a CSV or the column is missing.
24
+ """
25
+ if not df_path.split(".")[-1] == "csv":
26
+ raise ValueError("The given file path is not targeting a csv fil")
27
+ df = pd.read_csv(df_path)
28
+ if not column_name in df.columns:
29
+ raise ValueError("The given column is not in the df !")
30
+ data = df[column_name].values
31
+ return data
32
+
33
+
34
+ def write_data(data, file_path: str, new_line: bool = True):
35
+ """Write data to a text file.
36
+
37
+ Args:
38
+ data: A list of strings (written one per line) or a single
39
+ string (written as-is).
40
+ file_path: Destination file path.
41
+ new_line: If True (default) and *data* is a list, write each
42
+ element followed by a newline. Ignored for non-list data.
43
+
44
+ Returns:
45
+ bool: ``True`` on success.
46
+ """
47
+ with open(file_path, "w+") as f:
48
+ if type(data) == list:
49
+ if new_line:
50
+ for d in data:
51
+ f.write(d + "\n")
52
+ else:
53
+ f.write(str(data))
54
+ else:
55
+ f.write(data)
56
+ return True
57
+
58
+
59
+ def apply_BRICS(
60
+ data: list[str],
61
+ max_depth: int = 1,
62
+ output_dir: Optional[str] = None,
63
+ fmt: str = "csv",
64
+ is_decomposed: bool = False,
65
+ log_level: int | str = 2,
66
+ logger: Optional[PolyLogger] = None,
67
+ ) -> Optional[list[dict]]:
68
+ """Decompose and rebuild molecules via BRICS.
69
+
70
+ Parameters
71
+ ----------
72
+ data : list[str]
73
+ SMILES strings to process. May be full molecules (the default)
74
+ or already-decomposed BRICS fragments when *is_decomposed* is
75
+ ``True``.
76
+ max_depth : int
77
+ Maximum BRICS build depth (default 1).
78
+ output_dir : str, optional
79
+ Directory to save output files to. When provided, two files are
80
+ written: ``decomposed_unique.txt`` (fragments, one per line) and
81
+ ``brics_results.{csv,parquet}`` (table with ``product_smiles`` and
82
+ ``recipe`` columns). When ``None`` (default), no files are written
83
+ and the results are returned as a list of dicts instead.
84
+ fmt : str
85
+ Output table format: ``"csv"`` (default) or ``"parquet"``.
86
+ Ignored when *output_dir* is ``None``.
87
+ is_decomposed : bool
88
+ When ``False`` (default), *data* contains full molecules that will
89
+ be decomposed into BRICS fragments before building. When ``True``,
90
+ *data* already contains BRICS fragments (with wildcards like
91
+ ``[15*]C1CCCCC1``) — the decomposition step is skipped and the
92
+ fragments go directly to ``BRICSBuild``.
93
+ log_level : int or str
94
+ Verbosity threshold used when *logger* is not provided.
95
+ As an **int**: ``0`` = silent, ``1`` = warnings,
96
+ ``2`` = info (default), ``3`` = debug.
97
+ As a **str**: ``"SILENT"``, ``"WARNING"``, ``"INFO"``, ``"DEBUG"``.
98
+ logger : PolyLogger, optional
99
+ A pre-configured ``PolyLogger`` instance. When given, its
100
+ existing level and handlers are used as-is; *log_level* is
101
+ ignored. When ``None``, a console logger at *log_level* is
102
+ created automatically.
103
+
104
+ Returns
105
+ -------
106
+ list[dict] or None
107
+ When *output_dir* is ``None``, returns a list of dicts keyed by
108
+ ``"product_smiles"``, ``"recipe"``, and ``"mol"``. When
109
+ *output_dir* is given, returns ``None`` (results are written to
110
+ disk instead).
111
+ """
112
+ if logger is None:
113
+ logger = PolyLogger(name="brics", level=log_level)
114
+ logger.add_console(level=log_level)
115
+
116
+ # ── 1. Decompose (skip if already decomposed) ───────────────────────
117
+ if is_decomposed:
118
+ # Input is already BRICS fragments — use directly, deduplicate
119
+ uniques = list(set(data))
120
+ logger.info(
121
+ "Using %d pre-decomposed fragments (skipping decomposition)",
122
+ len(uniques),
123
+ )
124
+ else:
125
+ Decompositions = []
126
+ for smiles in data:
127
+ mol = Chem.MolFromSmiles(smiles)
128
+ if mol is None:
129
+ logger.warning(f"Invalid SMILES >> {smiles}")
130
+ if mol is not None:
131
+ res = BRICSDecompose(mol)
132
+ Decompositions += res
133
+
134
+ uniques = list(set(Decompositions))
135
+ logger.info("Unique blocks found -> %d", len(uniques))
136
+
137
+ # ── 2. Build ─────────────────────────────────────────────────────────
138
+ build_mols = [Chem.MolFromSmiles(smiles) for smiles in uniques]
139
+ brics_iter = list(BRICSBuild(build_mols, maxDepth=max_depth))
140
+
141
+ # ── 3. Collect results ───────────────────────────────────────────────
142
+ results: list[dict] = []
143
+ for b in brics_iter:
144
+ if isinstance(b, tuple):
145
+ recipe, mol = b
146
+ product_smiles = Chem.MolToSmiles(mol)
147
+ results.append({
148
+ "product_smiles": product_smiles,
149
+ "recipe": recipe,
150
+ "mol": mol,
151
+ })
152
+
153
+ logger.info("New blocks found -> %d", len(brics_iter))
154
+
155
+ # ── 4. Save or return ────────────────────────────────────────────────
156
+ if output_dir is not None:
157
+ os.makedirs(output_dir, exist_ok=True)
158
+
159
+ # Fragments — human-readable, one SMILES per line
160
+ write_data(uniques, os.path.join(output_dir, "decomposed_unique.txt"))
161
+
162
+ # Results table — dataframe-compatible (CSV or Parquet)
163
+ if results:
164
+ df_out = pd.DataFrame([
165
+ {"product_smiles": r["product_smiles"], "recipe": r["recipe"]}
166
+ for r in results
167
+ ])
168
+ else:
169
+ df_out = pd.DataFrame(columns=["product_smiles", "recipe"])
170
+
171
+ if fmt == "parquet":
172
+ out_path = os.path.join(output_dir, "brics_results.parquet")
173
+ df_out.to_parquet(out_path, index=False)
174
+ else:
175
+ out_path = os.path.join(output_dir, "brics_results.csv")
176
+ df_out.to_csv(out_path, index=False)
177
+
178
+ logger.info(
179
+ "Saved %d results to %s (decomposed_unique.txt, %s)",
180
+ len(results), output_dir, os.path.basename(out_path),
181
+ )
182
+ return None
183
+
184
+ return results
185
+
186
+
187
+ if __name__ == "__main__":
188
+ smiles = read_df("./source.csv", "SMILES")
189
+ apply_BRICS(smiles)
@@ -0,0 +1,48 @@
1
+ """
2
+ BRICS (Breaking of Retrosynthetically Interesting Chemical Substructures)
3
+ sub-package for PolyScript.
4
+
5
+ Provides fragment-based decomposition and recombination of molecules:
6
+
7
+ - :func:`FindBRICSBonds` — locate cleavable bonds in a molecule.
8
+ - :func:`BreakBRICSBonds` — break those bonds, returning labelled fragments.
9
+ - :func:`BRICSDecompose` — full recursive decomposition into BRICS fragments.
10
+ - :func:`BRICSBuild` — enumerate new molecules by recombining fragments.
11
+ - :func:`BRICSBuildDistribWrite` — build + persist results to disk.
12
+
13
+ High-level convenience (from ``BRICS_utils``):
14
+ - :func:`apply_BRICS` — end-to-end: decompose → deduplicate → rebuild.
15
+ - :func:`read_df` — load a SMILES column from a CSV.
16
+ - :func:`write_data` — write text / list data to a file.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ # ── Core BRICS operations ────────────────────────────────────────────────────
22
+ from polyscript.brics.BRICSMod import (
23
+ BRICSBuild,
24
+ BRICSBuildDistribWrite,
25
+ BRICSDecompose,
26
+ BreakBRICSBonds,
27
+ FindBRICSBonds,
28
+ )
29
+
30
+ # ── High-level utilities ─────────────────────────────────────────────────────
31
+ from polyscript.brics.BRICS_utils import (
32
+ apply_BRICS,
33
+ read_df,
34
+ write_data,
35
+ )
36
+
37
+ __all__ = [
38
+ # BRICSMod
39
+ "FindBRICSBonds",
40
+ "BreakBRICSBonds",
41
+ "BRICSDecompose",
42
+ "BRICSBuild",
43
+ "BRICSBuildDistribWrite",
44
+ # BRICS_utils
45
+ "apply_BRICS",
46
+ "read_df",
47
+ "write_data",
48
+ ]
@@ -0,0 +1,270 @@
1
+ """Polymer-type classifier based on SMiPoly functional-group logic.
2
+
3
+ This module provides ``PolyScriptClassifier``, which classifies monomer
4
+ SMILES strings into polymer-relevant functional-group categories (mono-
5
+ and poly-functional, as well as olefinic) using SMARTS patterns and
6
+ exclusion rules derived from SMiPoly, with additional classes from OMG.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pandas as pd
12
+ from polyscript.classifier.class_reference import exc_lst, mon_index_map, mon_lst, mon_type_index
13
+ from polyscript.utils.executor import BaseExecutor
14
+ from polyscript.utils.logger import INFO
15
+ from rdkit import Chem
16
+
17
+
18
+ class PolyScriptClassifier(BaseExecutor):
19
+ _log_level = INFO # class-level default, change via set_log_level()
20
+
21
+ def __init__(
22
+ self,
23
+ MinFG: int = 2,
24
+ MaxFG: int = 4,
25
+ include_carbonate: bool = True,
26
+ **kwargs,
27
+ ):
28
+ """Initialize the PolyScript classifier.
29
+
30
+ Args:
31
+ MinFG: Minimum number of functional groups for
32
+ poly-functional classification (default 2).
33
+ MaxFG: Maximum number of functional groups for
34
+ poly-functional classification (default 4).
35
+ include_carbonate: If True, append CO and HCHO rows
36
+ to enable carbonate monomer classification.
37
+ **kwargs: Passed to ``BaseExecutor.__init__``.
38
+ """
39
+ super().__init__(**kwargs)
40
+ self.MinFG = MinFG
41
+ self.MaxFG = MaxFG
42
+ self.mon_lst = mon_lst
43
+ self.exc_lst = exc_lst
44
+ self.mon_index_map = mon_index_map
45
+ self.mon_type_index = mon_type_index
46
+ self.include_carbonate = include_carbonate
47
+
48
+ self.logger.debug(
49
+ "PolyScriptClassifier initialised (MinFG=%d, MaxFG=%d)", MinFG, MaxFG
50
+ )
51
+
52
+ def _extract_FG(self, mol, patt):
53
+ """Count non-overlapping functional-group matches for a pattern.
54
+
55
+ Filters out consecutive duplicate matches that differ by a single
56
+ atom shift (symmetric difference of size 2), which typically
57
+ represent the same functional group seen from a different
58
+ orientation rather than distinct occurrences.
59
+
60
+ Args:
61
+ mol: An RDKit ``Mol`` object.
62
+ patt: An RDKit ``Mol`` object representing a SMARTS pattern.
63
+
64
+ Returns:
65
+ int: Number of distinct functional-group matches.
66
+ """
67
+ numFG = 0
68
+ matchs = mol.GetSubstructMatches(
69
+ patt
70
+ ) # finding the substructures based on the pattern
71
+ if len(matchs) >= 2: # if higher number of substructures are found
72
+ not_match = []
73
+ for i in range(0, len(matchs) - 1):
74
+ # Checks if the current and next match are NOT extensively overlapping by a
75
+ # single atom shift (i.e., symmetric difference of atom indices is not 2).
76
+ if len(set(matchs[i]) ^ set(matchs[i + 1])) != 2:
77
+ pass
78
+ else:
79
+ not_match.append(i + 1)
80
+ numFG = len(
81
+ [matchs[i] for i in range(0, len(matchs)) if i not in not_match]
82
+ )
83
+ else:
84
+ # if there are only 2 number of matches just add the length as number of functional group
85
+ numFG = len(matchs)
86
+ return numFG
87
+
88
+ def _get_mono_FG(self, mol, type_index):
89
+ """Check for mono-functional (single-monomer) classification.
90
+
91
+ Tests whether *mol* contains at least one substructure match
92
+ from the monomer SMARTS list for *type_index* (ignoring
93
+ duplicate overlaps). Also verifies that none of the exclusion
94
+ patterns for that type match.
95
+
96
+ Args:
97
+ mol: An RDKit ``Mol`` object, or ``None``.
98
+ type_index: String key into ``self.mon_lst`` and
99
+ ``self.exc_lst`` (e.g. ``"1"`` for vinyl).
100
+
101
+ Returns:
102
+ list: ``[bool, int]`` — the bool is ``True`` if the
103
+ monomer type is present with no exclusion violations;
104
+ the int is the total number of raw substructure matches.
105
+ """
106
+ if mol is None:
107
+ return [False, 0]
108
+ else:
109
+ chk_c = 0
110
+ fchk_c = 0
111
+ chk = []
112
+ if len(self.mon_lst[str(type_index)]) != 0:
113
+ for mon in self.mon_lst[str(type_index)]:
114
+ patt = Chem.MolFromSmarts(mon)
115
+ if mol.HasSubstructMatch(patt):
116
+ chk_c = len(mol.GetSubstructMatches(patt))
117
+ fchk_c = fchk_c + chk_c
118
+ chk_excl = []
119
+ for excl in self.exc_lst[str(type_index)]:
120
+ excl_patt = Chem.MolFromSmarts(excl)
121
+ if mol.HasSubstructMatch(excl_patt):
122
+ chk_excl.append(False)
123
+ else:
124
+ chk_excl.append(True)
125
+ if False in chk_excl:
126
+ chk.append(False)
127
+ else:
128
+ chk.append(True)
129
+ else:
130
+ chk.append(False)
131
+ if True in chk:
132
+ fchk = True
133
+ else:
134
+ fchk = False
135
+ else:
136
+ fchk = False
137
+ return [fchk, fchk_c]
138
+
139
+ def _get_poly_FG(self, mol, type_index):
140
+ """Check for poly-functional (multi-monomer) classification.
141
+
142
+ Counts all functional-group matches (using ``_extract_FG``) and
143
+ requires the total to fall within ``[MinFG, MaxFG]``. Also
144
+ validates that none of the exclusion patterns match.
145
+
146
+ Args:
147
+ mol: An RDKit ``Mol`` object, or ``None``.
148
+ type_index: String key into ``self.mon_lst`` and
149
+ ``self.exc_lst``.
150
+
151
+ Returns:
152
+ list: ``[bool, int]`` — the bool is ``True`` if the
153
+ monomer type is present within the FG count range and
154
+ has no exclusion violations; the int is the count of
155
+ functional-group matches.
156
+ """
157
+ if mol is None:
158
+ return [False, 0]
159
+ chk_c = 0 # check count
160
+ fchk_c = 0 # functional check count
161
+ if len(self.mon_lst[str(type_index)]) != 0:
162
+ for mon in self.mon_lst[str(type_index)]:
163
+ patt = Chem.MolFromSmarts(mon)
164
+ chk_c = self._extract_FG(mol, patt)
165
+ fchk_c = fchk_c + chk_c
166
+ if self.MinFG <= fchk_c <= self.MaxFG:
167
+ chk = []
168
+ for excl in self.exc_lst[str(type_index)]:
169
+ excl_patt = Chem.MolFromSmarts(excl)
170
+ if mol.HasSubstructMatch(excl_patt):
171
+ chk.append(False)
172
+ else:
173
+ chk.append(True)
174
+ if False in chk:
175
+ fchk = False
176
+ else:
177
+ fchk = True
178
+ else:
179
+ fchk = False
180
+ else:
181
+ fchk = False
182
+ return [fchk, fchk_c]
183
+
184
+ def _classify(self, df, ids, mapper_func):
185
+ """Apply a classification mapper to a group of monomer types.
186
+
187
+ Iterates over the type indices listed in ``self.mon_index_map[ids]``
188
+ and creates a new DataFrame column for each type name (from
189
+ ``self.mon_type_index``), populated by applying *mapper_func*
190
+ to the ``ROMol`` column.
191
+
192
+ Args:
193
+ df: A pandas DataFrame with a ``ROMol`` column.
194
+ ids: Index into ``self.mon_index_map`` (0 for mono, 1 for poly).
195
+ mapper_func: Callable ``(mol, type_index) -> [bool, int]``.
196
+ """
197
+ for i in self.mon_index_map[ids]:
198
+ df[self.mon_type_index[str(i)]] = [
199
+ e[0] for e in df["ROMol"].apply(mapper_func, type_index=i)
200
+ ]
201
+
202
+ def classify(self, df, col_name):
203
+ """Run full mono- and poly-functional-group classification.
204
+
205
+ Parses SMILES in *df[col_name]*, optionally appends CO / HCHO
206
+ rows for carbonate classification, runs mono-functional
207
+ (``_get_mono_FG``) and poly-functional (``_get_poly_FG``)
208
+ classifiers, and returns the enriched DataFrame.
209
+
210
+ Args:
211
+ df: Input DataFrame containing SMILES strings.
212
+ col_name: Column name holding SMILES strings.
213
+
214
+ Returns:
215
+ pandas.DataFrame: The input DataFrame with additional
216
+ classification columns (one per monomer type) and the
217
+ ``ROMol`` column dropped. Returns the original DataFrame
218
+ unchanged if *col_name* is not found.
219
+ """
220
+ # read source file
221
+ DF01 = df
222
+ n_rows = len(DF01)
223
+ self.logger.info(
224
+ "Starting classification (%d rows, column='%s')", n_rows, col_name
225
+ )
226
+
227
+ # optionally append CO and HCHO for carbonate classification
228
+ DF02 = DF01.copy()
229
+ if col_name in DF02.columns.to_list():
230
+ if self.include_carbonate:
231
+ # adding two rows for CO and HCHO that will later be used
232
+ # to form carbonate with other monomers
233
+ DFadd = pd.DataFrame(
234
+ [
235
+ ["[C-]#[O+]"], # CO
236
+ ["C=O"], # HCHO
237
+ ],
238
+ columns=[col_name],
239
+ )
240
+ DF02 = pd.concat([DF02, DFadd], ignore_index=True)
241
+ self.logger.debug(
242
+ "Appended CO / HCHO rows for carbonate classification"
243
+ )
244
+ else:
245
+ self.logger.error(
246
+ "Column '%s' not found in dataframe (available: %s)",
247
+ col_name,
248
+ DF02.columns.to_list(),
249
+ )
250
+ return df
251
+
252
+ # drop NA of smiles, and add chemical structure
253
+ DF02["ROMol"] = DF02[col_name].apply(lambda x: Chem.MolFromSmiles(x))
254
+ DF02[col_name] = DF02["ROMol"].apply(lambda x: Chem.MolToSmiles(x))
255
+
256
+ null_count = DF02["ROMol"].isna().sum()
257
+ if null_count:
258
+ self.logger.warning("%d SMILES could not be parsed", null_count)
259
+
260
+ # implementing mono func
261
+ self.logger.debug("Running mono-functional-group classification")
262
+ self._classify(DF02, 0, self._get_mono_FG)
263
+
264
+ # implementing poly func
265
+ self.logger.debug("Running poly-functional-group classification")
266
+ self._classify(DF02, 1, self._get_poly_FG)
267
+
268
+ DF02 = DF02.drop("ROMol", axis=1)
269
+ self.logger.info("Classification complete (%d rows)", len(DF02))
270
+ return DF02