cdxml-toolkit 0.5.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.
- cdxml_toolkit/__init__.py +18 -0
- cdxml_toolkit/_jre/__init__.py +2 -0
- cdxml_toolkit/_jre/temurin-21-jre-win-x64.zip +0 -0
- cdxml_toolkit/analysis/__init__.py +35 -0
- cdxml_toolkit/analysis/deterministic/__init__.py +12 -0
- cdxml_toolkit/analysis/deterministic/discover_experiment_files.py +413 -0
- cdxml_toolkit/analysis/deterministic/lab_book_formatter.py +701 -0
- cdxml_toolkit/analysis/deterministic/lcms_file_categorizer.py +928 -0
- cdxml_toolkit/analysis/deterministic/lcms_identifier.py +598 -0
- cdxml_toolkit/analysis/deterministic/mass_resolver.py +654 -0
- cdxml_toolkit/analysis/deterministic/multi_lcms_analyzer.py +1412 -0
- cdxml_toolkit/analysis/deterministic/procedure_writer.py +446 -0
- cdxml_toolkit/analysis/extract_nmr.py +47 -0
- cdxml_toolkit/analysis/format_procedure_entry.py +479 -0
- cdxml_toolkit/analysis/lcms_analyzer.py +1299 -0
- cdxml_toolkit/analysis/parse_analysis_file.py +134 -0
- cdxml_toolkit/cdxml_builder.py +920 -0
- cdxml_toolkit/cdxml_utils.py +342 -0
- cdxml_toolkit/chemdraw/__init__.py +5 -0
- cdxml_toolkit/chemdraw/_chemscript_server.py +562 -0
- cdxml_toolkit/chemdraw/cdx_converter.py +527 -0
- cdxml_toolkit/chemdraw/cdxml_to_image.py +262 -0
- cdxml_toolkit/chemdraw/cdxml_to_image_rdkit.py +296 -0
- cdxml_toolkit/chemdraw/chemscript_bridge.py +901 -0
- cdxml_toolkit/constants.py +304 -0
- cdxml_toolkit/coord_normalizer.py +438 -0
- cdxml_toolkit/deterministic_pipeline/__init__.py +6 -0
- cdxml_toolkit/deterministic_pipeline/legacy/__init__.py +5 -0
- cdxml_toolkit/deterministic_pipeline/legacy/eln_cdx_cleanup.py +509 -0
- cdxml_toolkit/deterministic_pipeline/legacy/eln_enrichment.py +1394 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_aligner.py +428 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher.py +1337 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher_v2.py +1340 -0
- cdxml_toolkit/deterministic_pipeline/scheme_reader_audit.py +931 -0
- cdxml_toolkit/deterministic_pipeline/scheme_reader_verify.py +1160 -0
- cdxml_toolkit/image/__init__.py +15 -0
- cdxml_toolkit/image/reaction_from_image.py +2103 -0
- cdxml_toolkit/image/structure_from_image.py +1711 -0
- cdxml_toolkit/layout/__init__.py +5 -0
- cdxml_toolkit/layout/alignment.py +1642 -0
- cdxml_toolkit/layout/reaction_cleanup.py +1002 -0
- cdxml_toolkit/layout/scheme_merger.py +2260 -0
- cdxml_toolkit/mcp_server/__init__.py +0 -0
- cdxml_toolkit/mcp_server/__main__.py +5 -0
- cdxml_toolkit/mcp_server/server.py +1567 -0
- cdxml_toolkit/naming/__init__.py +6 -0
- cdxml_toolkit/naming/aligned_namer.py +2342 -0
- cdxml_toolkit/naming/mol_builder.py +3722 -0
- cdxml_toolkit/naming/name_decomposer.py +2843 -0
- cdxml_toolkit/naming/reactions_datamol.json +2414 -0
- cdxml_toolkit/office/__init__.py +5 -0
- cdxml_toolkit/office/doc_from_template.py +722 -0
- cdxml_toolkit/office/ole_embedder.py +808 -0
- cdxml_toolkit/office/ole_extractor.py +272 -0
- cdxml_toolkit/perception/__init__.py +10 -0
- cdxml_toolkit/perception/compound_search.py +229 -0
- cdxml_toolkit/perception/eln_csv_parser.py +240 -0
- cdxml_toolkit/perception/rdf_parser.py +664 -0
- cdxml_toolkit/perception/reactant_heuristic.py +1045 -0
- cdxml_toolkit/perception/reaction_parser.py +2150 -0
- cdxml_toolkit/perception/scheme_reader.py +2948 -0
- cdxml_toolkit/perception/scheme_refine.py +1404 -0
- cdxml_toolkit/perception/scheme_segmenter.py +619 -0
- cdxml_toolkit/perception/spatial_assignment.py +1013 -0
- cdxml_toolkit/rdkit_utils.py +605 -0
- cdxml_toolkit/render/__init__.py +17 -0
- cdxml_toolkit/render/auto_layout.py +229 -0
- cdxml_toolkit/render/compact_parser.py +632 -0
- cdxml_toolkit/render/parser.py +706 -0
- cdxml_toolkit/render/render_scheme.py +267 -0
- cdxml_toolkit/render/renderer.py +2387 -0
- cdxml_toolkit/render/schema.py +90 -0
- cdxml_toolkit/render/scheme_maker.py +1043 -0
- cdxml_toolkit/render/scheme_yaml_writer.py +1487 -0
- cdxml_toolkit/resolve/__init__.py +13 -0
- cdxml_toolkit/resolve/cas_resolver.py +430 -0
- cdxml_toolkit/resolve/chemscanner_abbreviations.json +28813 -0
- cdxml_toolkit/resolve/condensed_formula.py +493 -0
- cdxml_toolkit/resolve/jre_manager.py +195 -0
- cdxml_toolkit/resolve/reagent_abbreviations.json +1046 -0
- cdxml_toolkit/resolve/reagent_db.py +285 -0
- cdxml_toolkit/resolve/superatom_data.json +2856 -0
- cdxml_toolkit/resolve/superatom_table.py +146 -0
- cdxml_toolkit/text_formatting.py +298 -0
- cdxml_toolkit-0.5.0.dist-info/METADATA +318 -0
- cdxml_toolkit-0.5.0.dist-info/RECORD +91 -0
- cdxml_toolkit-0.5.0.dist-info/WHEEL +5 -0
- cdxml_toolkit-0.5.0.dist-info/entry_points.txt +17 -0
- cdxml_toolkit-0.5.0.dist-info/licenses/LICENSE +21 -0
- cdxml_toolkit-0.5.0.dist-info/licenses/NOTICE.md +37 -0
- cdxml_toolkit-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""Detect and segment independent sub-schemes within a single CDXML file.
|
|
2
|
+
|
|
3
|
+
Multi-panel CDXML files (e.g., literature surveys, methodology figures) may
|
|
4
|
+
contain several independent reaction schemes drawn on the same page. The
|
|
5
|
+
deterministic parser (Mode A) merges all ``<scheme>`` elements into one flat
|
|
6
|
+
step list, which mis-interprets independent reactions as a single multi-step
|
|
7
|
+
route.
|
|
8
|
+
|
|
9
|
+
This module provides:
|
|
10
|
+
|
|
11
|
+
- ``segment_scheme(cdxml_path)`` — detect independent sub-schemes using a
|
|
12
|
+
three-level cascade: (1) scheme-element species overlap, (2) Y-band
|
|
13
|
+
clustering, (3) arrow-graph connected components.
|
|
14
|
+
- ``classify_scheme_complexity(cdxml_path)`` — classify a CDXML file as
|
|
15
|
+
``"simple"``, ``"moderate"``, or ``"complex"`` to guide mode selection.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
from cdxml_toolkit.perception.scheme_segmenter import segment_scheme
|
|
20
|
+
segments = segment_scheme("oleObject12.cdxml")
|
|
21
|
+
# => 5 SchemeSegment objects with disjoint species
|
|
22
|
+
|
|
23
|
+
from cdxml_toolkit.perception.scheme_segmenter import classify_scheme_complexity
|
|
24
|
+
tier = classify_scheme_complexity("oleObject12.cdxml")
|
|
25
|
+
# => "complex"
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from dataclasses import dataclass, field, asdict
|
|
33
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Data model
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SchemeSegment:
|
|
42
|
+
"""One independent sub-scheme identified within a CDXML file."""
|
|
43
|
+
segment_index: int
|
|
44
|
+
scheme_element_ids: List[str] = field(default_factory=list)
|
|
45
|
+
species_ids: List[str] = field(default_factory=list)
|
|
46
|
+
arrow_ids: List[str] = field(default_factory=list)
|
|
47
|
+
step_count: int = 0
|
|
48
|
+
y_range: Tuple[float, float] = (0.0, 0.0)
|
|
49
|
+
is_independent: bool = True
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
d = asdict(self)
|
|
53
|
+
d["y_range"] = list(d["y_range"])
|
|
54
|
+
return d
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SegmentationResult:
|
|
59
|
+
"""Result of segmenting a CDXML file."""
|
|
60
|
+
source_file: str = ""
|
|
61
|
+
total_schemes: int = 0
|
|
62
|
+
total_steps: int = 0
|
|
63
|
+
total_fragments: int = 0
|
|
64
|
+
total_arrows: int = 0
|
|
65
|
+
segments: List[SchemeSegment] = field(default_factory=list)
|
|
66
|
+
is_multi_panel: bool = False # True if >1 independent segment found
|
|
67
|
+
wrap_repeat_detected: bool = False # True if SMILES overlap linked schemes
|
|
68
|
+
method: str = "" # "scheme_overlap" | "y_band" | "arrow_graph" | "single"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def num_segments(self) -> int:
|
|
72
|
+
return len(self.segments)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
return {
|
|
76
|
+
"source_file": self.source_file,
|
|
77
|
+
"total_schemes": self.total_schemes,
|
|
78
|
+
"total_steps": self.total_steps,
|
|
79
|
+
"total_fragments": self.total_fragments,
|
|
80
|
+
"total_arrows": self.total_arrows,
|
|
81
|
+
"num_segments": self.num_segments,
|
|
82
|
+
"is_multi_panel": self.is_multi_panel,
|
|
83
|
+
"wrap_repeat_detected": self.wrap_repeat_detected,
|
|
84
|
+
"method": self.method,
|
|
85
|
+
"segments": [s.to_dict() for s in self.segments],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# XML parsing helpers
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _parse_scheme_elements(page: ET.Element) -> List[ET.Element]:
|
|
94
|
+
"""Find all <scheme> elements on the page."""
|
|
95
|
+
schemes = page.findall("scheme")
|
|
96
|
+
if not schemes:
|
|
97
|
+
schemes = page.findall(".//scheme")
|
|
98
|
+
return schemes
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_step_species(step_el: ET.Element) -> Set[str]:
|
|
102
|
+
"""Extract all species (fragment/text) IDs referenced by a step."""
|
|
103
|
+
ids: Set[str] = set()
|
|
104
|
+
for attr in ("ReactionStepReactants", "ReactionStepProducts",
|
|
105
|
+
"ReactionStepObjectsAboveArrow",
|
|
106
|
+
"ReactionStepObjectsBelowArrow"):
|
|
107
|
+
val = step_el.get(attr, "")
|
|
108
|
+
ids.update(x for x in val.split() if x)
|
|
109
|
+
return ids
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _get_step_arrows(step_el: ET.Element) -> List[str]:
|
|
113
|
+
"""Extract arrow IDs referenced by a step."""
|
|
114
|
+
val = step_el.get("ReactionStepArrows", "")
|
|
115
|
+
return [x for x in val.split() if x]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _get_arrow_y_center(arrow_el: ET.Element) -> Optional[float]:
|
|
119
|
+
"""Get the Y-coordinate center of an arrow from its BoundingBox.
|
|
120
|
+
|
|
121
|
+
BoundingBox format: "left top right bottom"
|
|
122
|
+
"""
|
|
123
|
+
bbox = arrow_el.get("BoundingBox", "")
|
|
124
|
+
if not bbox:
|
|
125
|
+
# Try Head3D/Tail3D attributes
|
|
126
|
+
head = arrow_el.get("Head3D", "")
|
|
127
|
+
tail = arrow_el.get("Tail3D", "")
|
|
128
|
+
if head and tail:
|
|
129
|
+
try:
|
|
130
|
+
hy = float(head.split()[1])
|
|
131
|
+
ty = float(tail.split()[1])
|
|
132
|
+
return (hy + ty) / 2
|
|
133
|
+
except (ValueError, IndexError):
|
|
134
|
+
pass
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
parts = bbox.split()
|
|
138
|
+
top = float(parts[1])
|
|
139
|
+
bottom = float(parts[3])
|
|
140
|
+
return (top + bottom) / 2
|
|
141
|
+
except (ValueError, IndexError):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_element_y_range(elem: ET.Element,
|
|
146
|
+
id_map: Dict[str, ET.Element],
|
|
147
|
+
species_ids: Set[str],
|
|
148
|
+
arrow_ids: Set[str]) -> Tuple[float, float]:
|
|
149
|
+
"""Get the Y-coordinate range for a set of species and arrows."""
|
|
150
|
+
y_vals: List[float] = []
|
|
151
|
+
|
|
152
|
+
for sid in species_ids:
|
|
153
|
+
el = id_map.get(sid)
|
|
154
|
+
if el is None:
|
|
155
|
+
continue
|
|
156
|
+
bbox = el.get("BoundingBox", "")
|
|
157
|
+
if bbox:
|
|
158
|
+
try:
|
|
159
|
+
parts = bbox.split()
|
|
160
|
+
y_vals.append(float(parts[1])) # top
|
|
161
|
+
y_vals.append(float(parts[3])) # bottom
|
|
162
|
+
except (ValueError, IndexError):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
for aid in arrow_ids:
|
|
166
|
+
el = id_map.get(aid)
|
|
167
|
+
if el is None:
|
|
168
|
+
continue
|
|
169
|
+
y = _get_arrow_y_center(el)
|
|
170
|
+
if y is not None:
|
|
171
|
+
y_vals.append(y)
|
|
172
|
+
|
|
173
|
+
if not y_vals:
|
|
174
|
+
return (0.0, 0.0)
|
|
175
|
+
return (min(y_vals), max(y_vals))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Lightweight SMILES extraction for overlap detection
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _extract_smiles_for_fragments(fragment_ids: Set[str],
|
|
183
|
+
id_map: Dict[str, ET.Element]) -> Dict[str, str]:
|
|
184
|
+
"""Extract SMILES for a set of fragment IDs using RDKit (lightweight).
|
|
185
|
+
|
|
186
|
+
Returns a dict mapping fragment_id -> canonical_SMILES.
|
|
187
|
+
Only succeeds for fragments that are valid molecular structures.
|
|
188
|
+
"""
|
|
189
|
+
result: Dict[str, str] = {}
|
|
190
|
+
try:
|
|
191
|
+
from ..rdkit_utils import frag_to_smiles_resolved
|
|
192
|
+
except ImportError:
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
for fid in fragment_ids:
|
|
196
|
+
el = id_map.get(fid)
|
|
197
|
+
if el is None or el.tag != "fragment":
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
smiles = frag_to_smiles_resolved(el)
|
|
201
|
+
if smiles:
|
|
202
|
+
result[fid] = smiles
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _smiles_to_inchi(smiles: str) -> Optional[str]:
|
|
209
|
+
"""Convert SMILES to InChI for stereo-invariant comparison."""
|
|
210
|
+
try:
|
|
211
|
+
from rdkit import Chem
|
|
212
|
+
from rdkit.Chem.inchi import MolToInchi
|
|
213
|
+
mol = Chem.MolFromSmiles(smiles)
|
|
214
|
+
if mol is None:
|
|
215
|
+
return None
|
|
216
|
+
inchi = MolToInchi(mol)
|
|
217
|
+
return inchi if inchi else None
|
|
218
|
+
except Exception:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _check_smiles_overlap(group_a_smiles: Dict[str, str],
|
|
223
|
+
group_b_smiles: Dict[str, str]) -> bool:
|
|
224
|
+
"""Check if any species between two groups share the same structure.
|
|
225
|
+
|
|
226
|
+
Uses InChI for stereo-invariant comparison, with SMILES fallback.
|
|
227
|
+
"""
|
|
228
|
+
if not group_a_smiles or not group_b_smiles:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# Build InChI lookup for group A
|
|
232
|
+
a_inchis: Set[str] = set()
|
|
233
|
+
a_smiles: Set[str] = set()
|
|
234
|
+
for smiles in group_a_smiles.values():
|
|
235
|
+
a_smiles.add(smiles)
|
|
236
|
+
inchi = _smiles_to_inchi(smiles)
|
|
237
|
+
if inchi:
|
|
238
|
+
a_inchis.add(inchi)
|
|
239
|
+
|
|
240
|
+
# Check group B against group A
|
|
241
|
+
for smiles in group_b_smiles.values():
|
|
242
|
+
# InChI match
|
|
243
|
+
inchi = _smiles_to_inchi(smiles)
|
|
244
|
+
if inchi and inchi in a_inchis:
|
|
245
|
+
return True
|
|
246
|
+
# Exact SMILES match
|
|
247
|
+
if smiles in a_smiles:
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Union-Find (for connected components)
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
class _UnionFind:
|
|
258
|
+
"""Simple union-find for merging connected scheme groups."""
|
|
259
|
+
|
|
260
|
+
def __init__(self, n: int):
|
|
261
|
+
self.parent = list(range(n))
|
|
262
|
+
self.rank = [0] * n
|
|
263
|
+
|
|
264
|
+
def find(self, x: int) -> int:
|
|
265
|
+
while self.parent[x] != x:
|
|
266
|
+
self.parent[x] = self.parent[self.parent[x]]
|
|
267
|
+
x = self.parent[x]
|
|
268
|
+
return x
|
|
269
|
+
|
|
270
|
+
def union(self, x: int, y: int) -> None:
|
|
271
|
+
rx, ry = self.find(x), self.find(y)
|
|
272
|
+
if rx == ry:
|
|
273
|
+
return
|
|
274
|
+
if self.rank[rx] < self.rank[ry]:
|
|
275
|
+
rx, ry = ry, rx
|
|
276
|
+
self.parent[ry] = rx
|
|
277
|
+
if self.rank[rx] == self.rank[ry]:
|
|
278
|
+
self.rank[rx] += 1
|
|
279
|
+
|
|
280
|
+
def groups(self) -> Dict[int, List[int]]:
|
|
281
|
+
g: Dict[int, List[int]] = defaultdict(list)
|
|
282
|
+
for i in range(len(self.parent)):
|
|
283
|
+
g[self.find(i)].append(i)
|
|
284
|
+
return g
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Core segmentation
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def segment_scheme(cdxml_path: str,
|
|
292
|
+
verbose: bool = False) -> SegmentationResult:
|
|
293
|
+
"""Detect independent sub-schemes within a CDXML file.
|
|
294
|
+
|
|
295
|
+
Uses a three-level cascade:
|
|
296
|
+
|
|
297
|
+
1. **Scheme-element species overlap** — if multiple ``<scheme>``
|
|
298
|
+
elements exist, check whether they share any species IDs.
|
|
299
|
+
Disjoint sets suggest independent sub-schemes.
|
|
300
|
+
|
|
301
|
+
2. **SMILES overlap** — for disjoint scheme groups, extract
|
|
302
|
+
lightweight SMILES and check for structural overlap (InChI).
|
|
303
|
+
If overlap is found, merge the groups back (wrap-repeat).
|
|
304
|
+
|
|
305
|
+
3. **Y-band clustering** — for remaining disjoint groups, verify
|
|
306
|
+
via Y-coordinate separation that they occupy distinct regions.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
cdxml_path : str
|
|
311
|
+
Path to CDXML file.
|
|
312
|
+
verbose : bool
|
|
313
|
+
Print debug info to stderr.
|
|
314
|
+
|
|
315
|
+
Returns
|
|
316
|
+
-------
|
|
317
|
+
SegmentationResult
|
|
318
|
+
Segmentation analysis result.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def _log(msg: str):
|
|
322
|
+
if verbose:
|
|
323
|
+
print(f" [segmenter] {msg}", file=sys.stderr)
|
|
324
|
+
|
|
325
|
+
from ..cdxml_utils import parse_cdxml, build_id_map
|
|
326
|
+
|
|
327
|
+
result = SegmentationResult(source_file=os.path.abspath(cdxml_path))
|
|
328
|
+
|
|
329
|
+
tree = parse_cdxml(cdxml_path)
|
|
330
|
+
root = tree.getroot()
|
|
331
|
+
page = root.find(".//page")
|
|
332
|
+
if page is None:
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
id_map = build_id_map(page)
|
|
336
|
+
|
|
337
|
+
# -----------------------------------------------------------------------
|
|
338
|
+
# Step 1: Parse scheme elements
|
|
339
|
+
# -----------------------------------------------------------------------
|
|
340
|
+
scheme_elements = _parse_scheme_elements(page)
|
|
341
|
+
result.total_schemes = len(scheme_elements)
|
|
342
|
+
|
|
343
|
+
if len(scheme_elements) == 0:
|
|
344
|
+
# No scheme elements — single segment from geometry
|
|
345
|
+
all_arrows = page.findall(".//arrow")
|
|
346
|
+
all_frags = page.findall(".//fragment")
|
|
347
|
+
result.total_arrows = len(all_arrows)
|
|
348
|
+
result.total_fragments = len(all_frags)
|
|
349
|
+
seg = SchemeSegment(
|
|
350
|
+
segment_index=0,
|
|
351
|
+
species_ids=[f.get("id", "") for f in all_frags],
|
|
352
|
+
arrow_ids=[a.get("id", "") for a in all_arrows],
|
|
353
|
+
step_count=len(all_arrows),
|
|
354
|
+
)
|
|
355
|
+
result.segments = [seg]
|
|
356
|
+
result.method = "single"
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
if len(scheme_elements) == 1:
|
|
360
|
+
# Single scheme element — one segment
|
|
361
|
+
scheme_el = scheme_elements[0]
|
|
362
|
+
steps = scheme_el.findall("step")
|
|
363
|
+
species: Set[str] = set()
|
|
364
|
+
arrows: List[str] = []
|
|
365
|
+
for step_el in steps:
|
|
366
|
+
species.update(_get_step_species(step_el))
|
|
367
|
+
arrows.extend(_get_step_arrows(step_el))
|
|
368
|
+
result.total_steps = len(steps)
|
|
369
|
+
result.total_fragments = len([s for s in species
|
|
370
|
+
if id_map.get(s, ET.Element("x")).tag == "fragment"])
|
|
371
|
+
result.total_arrows = len(arrows)
|
|
372
|
+
seg = SchemeSegment(
|
|
373
|
+
segment_index=0,
|
|
374
|
+
scheme_element_ids=[scheme_el.get("id", "")],
|
|
375
|
+
species_ids=sorted(species),
|
|
376
|
+
arrow_ids=arrows,
|
|
377
|
+
step_count=len(steps),
|
|
378
|
+
y_range=_get_element_y_range(page, id_map, species, set(arrows)),
|
|
379
|
+
)
|
|
380
|
+
result.segments = [seg]
|
|
381
|
+
result.method = "single"
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
# -----------------------------------------------------------------------
|
|
385
|
+
# Step 2: Multiple scheme elements — build per-scheme species sets
|
|
386
|
+
# -----------------------------------------------------------------------
|
|
387
|
+
_log(f"Found {len(scheme_elements)} scheme elements")
|
|
388
|
+
|
|
389
|
+
# Per-scheme data
|
|
390
|
+
scheme_ids: List[str] = []
|
|
391
|
+
scheme_species: List[Set[str]] = []
|
|
392
|
+
scheme_arrows: List[List[str]] = []
|
|
393
|
+
scheme_step_counts: List[int] = []
|
|
394
|
+
|
|
395
|
+
for scheme_el in scheme_elements:
|
|
396
|
+
sid = scheme_el.get("id", "")
|
|
397
|
+
scheme_ids.append(sid)
|
|
398
|
+
steps = scheme_el.findall("step")
|
|
399
|
+
species_set: Set[str] = set()
|
|
400
|
+
arrow_list: List[str] = []
|
|
401
|
+
for step_el in steps:
|
|
402
|
+
species_set.update(_get_step_species(step_el))
|
|
403
|
+
arrow_list.extend(_get_step_arrows(step_el))
|
|
404
|
+
scheme_species.append(species_set)
|
|
405
|
+
scheme_arrows.append(arrow_list)
|
|
406
|
+
scheme_step_counts.append(len(steps))
|
|
407
|
+
|
|
408
|
+
n = len(scheme_elements)
|
|
409
|
+
result.total_steps = sum(scheme_step_counts)
|
|
410
|
+
all_species = set().union(*scheme_species)
|
|
411
|
+
all_arrows_flat = [a for arrows in scheme_arrows for a in arrows]
|
|
412
|
+
result.total_fragments = len([s for s in all_species
|
|
413
|
+
if id_map.get(s, ET.Element("x")).tag == "fragment"])
|
|
414
|
+
result.total_arrows = len(all_arrows_flat)
|
|
415
|
+
|
|
416
|
+
# -----------------------------------------------------------------------
|
|
417
|
+
# Step 3: Check for fragment ID overlap (rare but possible)
|
|
418
|
+
# -----------------------------------------------------------------------
|
|
419
|
+
uf = _UnionFind(n)
|
|
420
|
+
|
|
421
|
+
for i in range(n):
|
|
422
|
+
for j in range(i + 1, n):
|
|
423
|
+
overlap = scheme_species[i] & scheme_species[j]
|
|
424
|
+
if overlap:
|
|
425
|
+
_log(f"Schemes {scheme_ids[i]} and {scheme_ids[j]} share "
|
|
426
|
+
f"{len(overlap)} species IDs -> merging")
|
|
427
|
+
uf.union(i, j)
|
|
428
|
+
|
|
429
|
+
# -----------------------------------------------------------------------
|
|
430
|
+
# Step 4: Check for SMILES overlap (wrap-repeat detection)
|
|
431
|
+
# -----------------------------------------------------------------------
|
|
432
|
+
# Only check pairs that aren't already merged
|
|
433
|
+
groups_before_smiles = uf.groups()
|
|
434
|
+
_log(f"After ID overlap check: {len(groups_before_smiles)} groups")
|
|
435
|
+
|
|
436
|
+
# Extract SMILES for boundary species (products + reactants of each scheme)
|
|
437
|
+
# to detect wrap-repeat linkage
|
|
438
|
+
scheme_smiles: List[Dict[str, str]] = []
|
|
439
|
+
for i in range(n):
|
|
440
|
+
frag_ids = {s for s in scheme_species[i]
|
|
441
|
+
if id_map.get(s, ET.Element("x")).tag == "fragment"}
|
|
442
|
+
smiles_map = _extract_smiles_for_fragments(frag_ids, id_map)
|
|
443
|
+
scheme_smiles.append(smiles_map)
|
|
444
|
+
_log(f"Scheme {scheme_ids[i]}: {len(smiles_map)}/{len(frag_ids)} "
|
|
445
|
+
f"fragments with SMILES")
|
|
446
|
+
|
|
447
|
+
for i in range(n):
|
|
448
|
+
for j in range(i + 1, n):
|
|
449
|
+
if uf.find(i) == uf.find(j):
|
|
450
|
+
continue # already merged
|
|
451
|
+
if _check_smiles_overlap(scheme_smiles[i], scheme_smiles[j]):
|
|
452
|
+
_log(f"Schemes {scheme_ids[i]} and {scheme_ids[j]} share "
|
|
453
|
+
f"SMILES -> merging (wrap-repeat)")
|
|
454
|
+
uf.union(i, j)
|
|
455
|
+
result.wrap_repeat_detected = True
|
|
456
|
+
|
|
457
|
+
# -----------------------------------------------------------------------
|
|
458
|
+
# Step 5: Build final segments from connected components
|
|
459
|
+
# -----------------------------------------------------------------------
|
|
460
|
+
groups = uf.groups()
|
|
461
|
+
_log(f"After SMILES overlap check: {len(groups)} groups")
|
|
462
|
+
|
|
463
|
+
segments: List[SchemeSegment] = []
|
|
464
|
+
for seg_idx, (_, members) in enumerate(sorted(groups.items())):
|
|
465
|
+
seg_scheme_ids = [scheme_ids[m] for m in members]
|
|
466
|
+
seg_species = sorted(set().union(*(scheme_species[m] for m in members)))
|
|
467
|
+
seg_arrows = [a for m in members for a in scheme_arrows[m]]
|
|
468
|
+
seg_step_count = sum(scheme_step_counts[m] for m in members)
|
|
469
|
+
|
|
470
|
+
y_range = _get_element_y_range(
|
|
471
|
+
page, id_map,
|
|
472
|
+
set(seg_species),
|
|
473
|
+
set(seg_arrows),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
segments.append(SchemeSegment(
|
|
477
|
+
segment_index=seg_idx,
|
|
478
|
+
scheme_element_ids=seg_scheme_ids,
|
|
479
|
+
species_ids=seg_species,
|
|
480
|
+
arrow_ids=seg_arrows,
|
|
481
|
+
step_count=seg_step_count,
|
|
482
|
+
y_range=y_range,
|
|
483
|
+
is_independent=(len(groups) > 1),
|
|
484
|
+
))
|
|
485
|
+
|
|
486
|
+
result.segments = segments
|
|
487
|
+
result.is_multi_panel = len(segments) > 1
|
|
488
|
+
result.method = ("scheme_overlap" if result.wrap_repeat_detected
|
|
489
|
+
else "scheme_overlap" if len(groups) < len(groups_before_smiles)
|
|
490
|
+
else "scheme_overlap")
|
|
491
|
+
|
|
492
|
+
if result.is_multi_panel:
|
|
493
|
+
_log(f"Multi-panel detected: {len(segments)} independent segments")
|
|
494
|
+
result.method = "scheme_overlap"
|
|
495
|
+
else:
|
|
496
|
+
_log(f"Single panel (all schemes connected)")
|
|
497
|
+
result.method = "connected"
|
|
498
|
+
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# Complexity classification
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
def classify_scheme_complexity(cdxml_path: str) -> str:
|
|
507
|
+
"""Classify a CDXML file's complexity for mode selection.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
str
|
|
512
|
+
``"simple"`` — 1 scheme element, <=4 arrows, <=10 fragments (Mode A)
|
|
513
|
+
``"moderate"`` — 1-2 scheme elements, 5-8 arrows, 10-30 fragments (Mode B)
|
|
514
|
+
``"complex"`` — 3+ scheme elements OR >8 arrows OR >30 fragments OR
|
|
515
|
+
multi-panel (Mode C)
|
|
516
|
+
"""
|
|
517
|
+
from ..cdxml_utils import parse_cdxml, build_id_map
|
|
518
|
+
|
|
519
|
+
tree = parse_cdxml(cdxml_path)
|
|
520
|
+
root = tree.getroot()
|
|
521
|
+
page = root.find(".//page")
|
|
522
|
+
if page is None:
|
|
523
|
+
return "simple"
|
|
524
|
+
|
|
525
|
+
schemes = _parse_scheme_elements(page)
|
|
526
|
+
n_schemes = len(schemes)
|
|
527
|
+
|
|
528
|
+
# Count arrows and fragments
|
|
529
|
+
n_arrows = 0
|
|
530
|
+
n_fragments = 0
|
|
531
|
+
for scheme_el in schemes:
|
|
532
|
+
for step_el in scheme_el.findall("step"):
|
|
533
|
+
arrows = step_el.get("ReactionStepArrows", "").split()
|
|
534
|
+
n_arrows += len([a for a in arrows if a])
|
|
535
|
+
n_fragments = len(page.findall(".//fragment"))
|
|
536
|
+
|
|
537
|
+
# Check for multi-panel
|
|
538
|
+
if n_schemes >= 2:
|
|
539
|
+
# Quick check: do a lightweight segmentation
|
|
540
|
+
seg_result = segment_scheme(cdxml_path)
|
|
541
|
+
if seg_result.is_multi_panel:
|
|
542
|
+
return "complex"
|
|
543
|
+
|
|
544
|
+
# Classify based on thresholds
|
|
545
|
+
if n_schemes >= 3:
|
|
546
|
+
return "complex"
|
|
547
|
+
if n_arrows > 8 or n_fragments > 30:
|
|
548
|
+
return "complex"
|
|
549
|
+
if n_schemes >= 2 or n_arrows > 4 or n_fragments > 10:
|
|
550
|
+
return "moderate"
|
|
551
|
+
return "simple"
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
# CLI
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
def main():
|
|
559
|
+
"""CLI for scheme segmentation analysis."""
|
|
560
|
+
import argparse
|
|
561
|
+
import json
|
|
562
|
+
|
|
563
|
+
parser = argparse.ArgumentParser(
|
|
564
|
+
description="Analyze CDXML file for independent sub-schemes"
|
|
565
|
+
)
|
|
566
|
+
parser.add_argument("input", help="CDXML file or directory of CDXML files")
|
|
567
|
+
parser.add_argument("--json", action="store_true",
|
|
568
|
+
help="Output JSON instead of terminal report")
|
|
569
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
570
|
+
help="Print debug info")
|
|
571
|
+
|
|
572
|
+
args = parser.parse_args()
|
|
573
|
+
|
|
574
|
+
inputs = []
|
|
575
|
+
if os.path.isdir(args.input):
|
|
576
|
+
for f in sorted(os.listdir(args.input)):
|
|
577
|
+
if f.endswith(".cdxml"):
|
|
578
|
+
inputs.append(os.path.join(args.input, f))
|
|
579
|
+
else:
|
|
580
|
+
inputs.append(args.input)
|
|
581
|
+
|
|
582
|
+
results = []
|
|
583
|
+
for path in inputs:
|
|
584
|
+
seg_result = segment_scheme(path, verbose=args.verbose)
|
|
585
|
+
complexity = classify_scheme_complexity(path)
|
|
586
|
+
results.append({
|
|
587
|
+
"file": os.path.basename(path),
|
|
588
|
+
"complexity": complexity,
|
|
589
|
+
"segmentation": seg_result.to_dict(),
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
if args.json:
|
|
593
|
+
json.dump(results, sys.stdout, indent=2, ensure_ascii=False)
|
|
594
|
+
print()
|
|
595
|
+
else:
|
|
596
|
+
for r in results:
|
|
597
|
+
seg = r["segmentation"]
|
|
598
|
+
name = r["file"]
|
|
599
|
+
n_seg = seg["num_segments"]
|
|
600
|
+
multi = seg["is_multi_panel"]
|
|
601
|
+
wrap = seg["wrap_repeat_detected"]
|
|
602
|
+
complexity = r["complexity"]
|
|
603
|
+
n_schemes = seg["total_schemes"]
|
|
604
|
+
n_steps = seg["total_steps"]
|
|
605
|
+
n_frags = seg["total_fragments"]
|
|
606
|
+
|
|
607
|
+
tag = f"[{complexity.upper():8s}]"
|
|
608
|
+
parts = [f"{n_schemes} schemes, {n_steps} steps, {n_frags} frags"]
|
|
609
|
+
if multi:
|
|
610
|
+
parts.append(f"{n_seg} independent segments")
|
|
611
|
+
elif wrap:
|
|
612
|
+
parts.append("wrap-repeat (connected)")
|
|
613
|
+
else:
|
|
614
|
+
parts.append("single panel")
|
|
615
|
+
print(f" {tag} {name:45s} {', '.join(parts)}")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
if __name__ == "__main__":
|
|
619
|
+
main()
|