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.
Files changed (91) hide show
  1. cdxml_toolkit/__init__.py +18 -0
  2. cdxml_toolkit/_jre/__init__.py +2 -0
  3. cdxml_toolkit/_jre/temurin-21-jre-win-x64.zip +0 -0
  4. cdxml_toolkit/analysis/__init__.py +35 -0
  5. cdxml_toolkit/analysis/deterministic/__init__.py +12 -0
  6. cdxml_toolkit/analysis/deterministic/discover_experiment_files.py +413 -0
  7. cdxml_toolkit/analysis/deterministic/lab_book_formatter.py +701 -0
  8. cdxml_toolkit/analysis/deterministic/lcms_file_categorizer.py +928 -0
  9. cdxml_toolkit/analysis/deterministic/lcms_identifier.py +598 -0
  10. cdxml_toolkit/analysis/deterministic/mass_resolver.py +654 -0
  11. cdxml_toolkit/analysis/deterministic/multi_lcms_analyzer.py +1412 -0
  12. cdxml_toolkit/analysis/deterministic/procedure_writer.py +446 -0
  13. cdxml_toolkit/analysis/extract_nmr.py +47 -0
  14. cdxml_toolkit/analysis/format_procedure_entry.py +479 -0
  15. cdxml_toolkit/analysis/lcms_analyzer.py +1299 -0
  16. cdxml_toolkit/analysis/parse_analysis_file.py +134 -0
  17. cdxml_toolkit/cdxml_builder.py +920 -0
  18. cdxml_toolkit/cdxml_utils.py +342 -0
  19. cdxml_toolkit/chemdraw/__init__.py +5 -0
  20. cdxml_toolkit/chemdraw/_chemscript_server.py +562 -0
  21. cdxml_toolkit/chemdraw/cdx_converter.py +527 -0
  22. cdxml_toolkit/chemdraw/cdxml_to_image.py +262 -0
  23. cdxml_toolkit/chemdraw/cdxml_to_image_rdkit.py +296 -0
  24. cdxml_toolkit/chemdraw/chemscript_bridge.py +901 -0
  25. cdxml_toolkit/constants.py +304 -0
  26. cdxml_toolkit/coord_normalizer.py +438 -0
  27. cdxml_toolkit/deterministic_pipeline/__init__.py +6 -0
  28. cdxml_toolkit/deterministic_pipeline/legacy/__init__.py +5 -0
  29. cdxml_toolkit/deterministic_pipeline/legacy/eln_cdx_cleanup.py +509 -0
  30. cdxml_toolkit/deterministic_pipeline/legacy/eln_enrichment.py +1394 -0
  31. cdxml_toolkit/deterministic_pipeline/legacy/scheme_aligner.py +428 -0
  32. cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher.py +1337 -0
  33. cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher_v2.py +1340 -0
  34. cdxml_toolkit/deterministic_pipeline/scheme_reader_audit.py +931 -0
  35. cdxml_toolkit/deterministic_pipeline/scheme_reader_verify.py +1160 -0
  36. cdxml_toolkit/image/__init__.py +15 -0
  37. cdxml_toolkit/image/reaction_from_image.py +2103 -0
  38. cdxml_toolkit/image/structure_from_image.py +1711 -0
  39. cdxml_toolkit/layout/__init__.py +5 -0
  40. cdxml_toolkit/layout/alignment.py +1642 -0
  41. cdxml_toolkit/layout/reaction_cleanup.py +1002 -0
  42. cdxml_toolkit/layout/scheme_merger.py +2260 -0
  43. cdxml_toolkit/mcp_server/__init__.py +0 -0
  44. cdxml_toolkit/mcp_server/__main__.py +5 -0
  45. cdxml_toolkit/mcp_server/server.py +1567 -0
  46. cdxml_toolkit/naming/__init__.py +6 -0
  47. cdxml_toolkit/naming/aligned_namer.py +2342 -0
  48. cdxml_toolkit/naming/mol_builder.py +3722 -0
  49. cdxml_toolkit/naming/name_decomposer.py +2843 -0
  50. cdxml_toolkit/naming/reactions_datamol.json +2414 -0
  51. cdxml_toolkit/office/__init__.py +5 -0
  52. cdxml_toolkit/office/doc_from_template.py +722 -0
  53. cdxml_toolkit/office/ole_embedder.py +808 -0
  54. cdxml_toolkit/office/ole_extractor.py +272 -0
  55. cdxml_toolkit/perception/__init__.py +10 -0
  56. cdxml_toolkit/perception/compound_search.py +229 -0
  57. cdxml_toolkit/perception/eln_csv_parser.py +240 -0
  58. cdxml_toolkit/perception/rdf_parser.py +664 -0
  59. cdxml_toolkit/perception/reactant_heuristic.py +1045 -0
  60. cdxml_toolkit/perception/reaction_parser.py +2150 -0
  61. cdxml_toolkit/perception/scheme_reader.py +2948 -0
  62. cdxml_toolkit/perception/scheme_refine.py +1404 -0
  63. cdxml_toolkit/perception/scheme_segmenter.py +619 -0
  64. cdxml_toolkit/perception/spatial_assignment.py +1013 -0
  65. cdxml_toolkit/rdkit_utils.py +605 -0
  66. cdxml_toolkit/render/__init__.py +17 -0
  67. cdxml_toolkit/render/auto_layout.py +229 -0
  68. cdxml_toolkit/render/compact_parser.py +632 -0
  69. cdxml_toolkit/render/parser.py +706 -0
  70. cdxml_toolkit/render/render_scheme.py +267 -0
  71. cdxml_toolkit/render/renderer.py +2387 -0
  72. cdxml_toolkit/render/schema.py +90 -0
  73. cdxml_toolkit/render/scheme_maker.py +1043 -0
  74. cdxml_toolkit/render/scheme_yaml_writer.py +1487 -0
  75. cdxml_toolkit/resolve/__init__.py +13 -0
  76. cdxml_toolkit/resolve/cas_resolver.py +430 -0
  77. cdxml_toolkit/resolve/chemscanner_abbreviations.json +28813 -0
  78. cdxml_toolkit/resolve/condensed_formula.py +493 -0
  79. cdxml_toolkit/resolve/jre_manager.py +195 -0
  80. cdxml_toolkit/resolve/reagent_abbreviations.json +1046 -0
  81. cdxml_toolkit/resolve/reagent_db.py +285 -0
  82. cdxml_toolkit/resolve/superatom_data.json +2856 -0
  83. cdxml_toolkit/resolve/superatom_table.py +146 -0
  84. cdxml_toolkit/text_formatting.py +298 -0
  85. cdxml_toolkit-0.5.0.dist-info/METADATA +318 -0
  86. cdxml_toolkit-0.5.0.dist-info/RECORD +91 -0
  87. cdxml_toolkit-0.5.0.dist-info/WHEEL +5 -0
  88. cdxml_toolkit-0.5.0.dist-info/entry_points.txt +17 -0
  89. cdxml_toolkit-0.5.0.dist-info/licenses/LICENSE +21 -0
  90. cdxml_toolkit-0.5.0.dist-info/licenses/NOTICE.md +37 -0
  91. 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()