ArraySplitter 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,726 @@
1
+ """
2
+ Anchor Graph for monomer decomposition.
3
+
4
+ Key idea: Build a graph where:
5
+ - Nodes = conserved parts (anchors) from FS-tree hints
6
+ - Edges = transitions between anchors with support (copy number)
7
+
8
+ This solves two problems:
9
+ 1. Multiple conserved parts per monomer (overcutting)
10
+ 2. Mutations in anchor (missing monomers) - handled by using shorter anchors
11
+ where longer ones are absent
12
+
13
+ The graph structure reveals the monomer architecture.
14
+ """
15
+
16
+ from collections import defaultdict, Counter
17
+ from typing import List, Tuple, Dict, Set, Optional
18
+ from dataclasses import dataclass, field
19
+ from statistics import median
20
+
21
+
22
+ @dataclass
23
+ class AnchorHit:
24
+ """Single occurrence of an anchor in sequence."""
25
+ anchor: str
26
+ position: int
27
+ length: int
28
+
29
+ def __lt__(self, other):
30
+ return self.position < other.position
31
+
32
+
33
+ def get_top_candidates(candidates: List[Dict], top_k: int = 10) -> List[Dict]:
34
+ """
35
+ Get top-k candidates from compute_cuts output.
36
+
37
+ Candidates are already scored by compute_cuts logic.
38
+ """
39
+ # Sort by adjusted_score (or base_score if adjusted not available)
40
+ sorted_candidates = sorted(
41
+ candidates,
42
+ key=lambda x: x.get('adjusted_score', x.get('base_score', 0)),
43
+ reverse=True
44
+ )
45
+ return sorted_candidates[:top_k]
46
+
47
+
48
+ # =============================================================================
49
+ # Distance Clustering Functions
50
+ # =============================================================================
51
+
52
+ def simple_kmeans(data: List[float], k: int, max_iter: int = 100) -> Tuple[List[List[float]], List[float]]:
53
+ """
54
+ Simple k-means clustering without external dependencies.
55
+
56
+ Args:
57
+ data: List of values to cluster
58
+ k: Number of clusters
59
+ max_iter: Maximum iterations
60
+
61
+ Returns:
62
+ (clusters, centers) - list of clusters and their centers
63
+ """
64
+ if not data or k <= 0:
65
+ return [[]], [0]
66
+
67
+ if k == 1:
68
+ return [data], [sum(data) / len(data)]
69
+
70
+ # Initialize centers evenly across the data range
71
+ data_sorted = sorted(data)
72
+ n = len(data_sorted)
73
+ centers = [data_sorted[min(i * n // k, n - 1)] for i in range(k)]
74
+
75
+ for _ in range(max_iter):
76
+ # Assign points to nearest center
77
+ clusters = [[] for _ in range(k)]
78
+ for x in data:
79
+ nearest = min(range(k), key=lambda i: abs(x - centers[i]))
80
+ clusters[nearest].append(x)
81
+
82
+ # Update centers
83
+ new_centers = []
84
+ for i, c in enumerate(clusters):
85
+ if c:
86
+ new_centers.append(sum(c) / len(c))
87
+ else:
88
+ # Empty cluster - keep old center
89
+ new_centers.append(centers[i])
90
+
91
+ if new_centers == centers:
92
+ break
93
+ centers = new_centers
94
+
95
+ return clusters, centers
96
+
97
+
98
+ def find_optimal_clusters(data: List[float], max_k: int = 4) -> Tuple[int, List[List[float]], List[float]]:
99
+ """
100
+ Find optimal number of clusters (1 to max_k).
101
+
102
+ Criteria:
103
+ - All clusters must be significant (>15% of data)
104
+ - Cluster centers must be well separated (gap > 20% of max center)
105
+
106
+ Returns:
107
+ (n_clusters, clusters, centers)
108
+ """
109
+ if len(data) < 4:
110
+ return 1, [data], [median(data)]
111
+
112
+ best_k = 1
113
+ best_score = 0
114
+ best_clusters = [data]
115
+ best_centers = [median(data)]
116
+
117
+ for k in range(2, min(max_k + 1, len(data) // 2 + 1)):
118
+ clusters, centers = simple_kmeans(data, k)
119
+
120
+ # Filter out empty clusters
121
+ non_empty = [(c, ctr) for c, ctr in zip(clusters, centers) if c]
122
+ if len(non_empty) < k:
123
+ continue
124
+
125
+ clusters = [x[0] for x in non_empty]
126
+ centers = [x[1] for x in non_empty]
127
+
128
+ # Check that all clusters are significant (>15% of data)
129
+ min_size = min(len(c) for c in clusters)
130
+ if min_size < len(data) * 0.15:
131
+ continue
132
+
133
+ # Check that centers are well separated
134
+ centers_sorted = sorted(centers)
135
+ if len(centers_sorted) < 2:
136
+ continue
137
+
138
+ min_gap = min(centers_sorted[i + 1] - centers_sorted[i]
139
+ for i in range(len(centers_sorted) - 1))
140
+ max_center = max(centers_sorted)
141
+
142
+ if min_gap < 0.2 * max_center:
143
+ continue
144
+
145
+ # Score: separation quality
146
+ score = min_gap / max_center
147
+ if score > best_score:
148
+ best_score = score
149
+ best_k = k
150
+ best_clusters = clusters
151
+ best_centers = centers
152
+
153
+ return best_k, best_clusters, best_centers
154
+
155
+
156
+ def analyze_distance_distribution(distances: List[float], verbose: bool = False) -> Tuple[int, List[float], float]:
157
+ """
158
+ Analyze distance distribution to detect multi-region patterns.
159
+
160
+ If anchor appears N times per monomer, distances will have N clusters.
161
+ True period = sum of cluster centers.
162
+
163
+ Args:
164
+ distances: List of distances between consecutive anchor occurrences
165
+ verbose: Print debug info
166
+
167
+ Returns:
168
+ (n_clusters, cluster_centers, true_period)
169
+ """
170
+ if len(distances) < 4:
171
+ med = median(distances) if distances else 0
172
+ return 1, [med], med
173
+
174
+ n_clusters, clusters, centers = find_optimal_clusters(distances, max_k=4)
175
+
176
+ # Sort centers by value for consistent ordering
177
+ centers_sorted = sorted(centers)
178
+ true_period = sum(centers_sorted)
179
+
180
+ if verbose and n_clusters > 1:
181
+ print(f" Distance clustering: {n_clusters} clusters")
182
+ for i, (c, ctr) in enumerate(sorted(zip(clusters, centers), key=lambda x: x[1])):
183
+ print(f" Cluster {i}: center={ctr:.0f}, count={len(c)}")
184
+ print(f" True period: {true_period:.0f}")
185
+
186
+ return n_clusters, centers_sorted, true_period
187
+
188
+
189
+ def classify_positions_by_distance(
190
+ positions: List[int],
191
+ centers: List[float],
192
+ verbose: bool = False
193
+ ) -> List[int]:
194
+ """
195
+ Classify each position by the cluster of its distance to the next position.
196
+
197
+ Args:
198
+ positions: Sorted list of anchor positions
199
+ centers: Cluster centers from analyze_distance_distribution
200
+
201
+ Returns:
202
+ List of cluster labels (0, 1, 2, ...) for each position.
203
+ Last position gets label -1 (no next position).
204
+ """
205
+ if len(positions) < 2:
206
+ return [-1] * len(positions)
207
+
208
+ labels = []
209
+ for i in range(len(positions) - 1):
210
+ dist = positions[i + 1] - positions[i]
211
+ # Find nearest cluster center
212
+ nearest = min(range(len(centers)), key=lambda j: abs(dist - centers[j]))
213
+ labels.append(nearest)
214
+
215
+ # Last position has no "next", mark with -1
216
+ labels.append(-1)
217
+
218
+ if verbose:
219
+ label_counts = Counter(l for l in labels if l >= 0)
220
+ print(f" Position labels: {dict(label_counts)}")
221
+
222
+ return labels
223
+
224
+
225
+ def select_cut_positions_by_cluster(
226
+ positions: List[int],
227
+ labels: List[int],
228
+ verbose: bool = False
229
+ ) -> List[int]:
230
+ """
231
+ Select cut positions based on cluster analysis.
232
+
233
+ Strategy: Cut at positions with the same label as the first position.
234
+ This preserves the monomer structure starting from the left flank.
235
+
236
+ Args:
237
+ positions: Sorted list of anchor positions
238
+ labels: Cluster labels for each position
239
+
240
+ Returns:
241
+ List of positions to cut at
242
+ """
243
+ if not positions or not labels:
244
+ return positions
245
+
246
+ # Use the label of the first position as the "start" label
247
+ # This defines which region (A, B, C...) is the monomer start
248
+ start_label = labels[0]
249
+
250
+ if start_label < 0:
251
+ # Only one position, return it
252
+ return positions
253
+
254
+ # Select all positions with the start label
255
+ cut_positions = [pos for pos, label in zip(positions, labels) if label == start_label]
256
+
257
+ # Also include the last position if it wasn't included
258
+ # (to capture the final partial monomer)
259
+ if positions[-1] not in cut_positions:
260
+ cut_positions.append(positions[-1])
261
+
262
+ if verbose:
263
+ print(f" Start label: {start_label}")
264
+ print(f" Cut positions: {len(cut_positions)} of {len(positions)} total")
265
+
266
+ return sorted(cut_positions)
267
+
268
+
269
+ def find_anchor_hits_hierarchical(
270
+ sequence: str,
271
+ anchors: List[str],
272
+ verbose: bool = False
273
+ ) -> List[AnchorHit]:
274
+ """
275
+ Find all anchor occurrences, using longer anchors first.
276
+
277
+ Logic:
278
+ 1. Sort anchors by length (longest first)
279
+ 2. Find all positions of longest anchor
280
+ 3. For shorter anchors - only find in regions NOT covered by longer ones
281
+ 4. This handles mutations: if long anchor has mutation, short variant will be found
282
+
283
+ Args:
284
+ sequence: The array sequence
285
+ anchors: List of anchor sequences (will be sorted by length)
286
+ verbose: Print debug info
287
+
288
+ Returns:
289
+ List of AnchorHit sorted by position
290
+ """
291
+ if not anchors:
292
+ return []
293
+
294
+ # Sort by length descending
295
+ sorted_anchors = sorted(anchors, key=len, reverse=True)
296
+
297
+ # Track covered positions
298
+ covered = set()
299
+ all_hits = []
300
+
301
+ for anchor in sorted_anchors:
302
+ anchor_len = len(anchor)
303
+
304
+ # Find all occurrences
305
+ start = 0
306
+ hits_for_anchor = 0
307
+
308
+ while True:
309
+ pos = sequence.find(anchor, start)
310
+ if pos == -1:
311
+ break
312
+
313
+ # Check if this position is already covered by a longer anchor
314
+ # A position is "covered" if ANY part of this anchor overlaps with covered region
315
+ is_covered = any(p in covered for p in range(pos, pos + anchor_len))
316
+
317
+ if not is_covered:
318
+ all_hits.append(AnchorHit(anchor=anchor, position=pos, length=anchor_len))
319
+ # Mark positions as covered
320
+ for p in range(pos, pos + anchor_len):
321
+ covered.add(p)
322
+ hits_for_anchor += 1
323
+
324
+ start = pos + 1
325
+
326
+ if verbose and hits_for_anchor > 0:
327
+ print(f" '{anchor}' (len={anchor_len}): {hits_for_anchor} hits")
328
+
329
+ # Sort by position
330
+ all_hits.sort()
331
+
332
+ return all_hits
333
+
334
+
335
+ def build_transition_graph(hits: List[AnchorHit]) -> Dict:
336
+ """
337
+ Build graph of transitions between consecutive anchor hits.
338
+
339
+ Returns dict with:
340
+ - edges: {(from_anchor, to_anchor): count}
341
+ - distances: {(from_anchor, to_anchor): [list of distances]}
342
+ - sequences: {(from_anchor, to_anchor): [list of sequences between]}
343
+ """
344
+ edges = Counter()
345
+ distances = defaultdict(list)
346
+
347
+ for i in range(len(hits) - 1):
348
+ curr = hits[i]
349
+ next_hit = hits[i + 1]
350
+
351
+ edge = (curr.anchor, next_hit.anchor)
352
+ distance = next_hit.position - curr.position
353
+
354
+ edges[edge] += 1
355
+ distances[edge].append(distance)
356
+
357
+ return {
358
+ "edges": dict(edges),
359
+ "distances": dict(distances),
360
+ }
361
+
362
+
363
+ def find_monomer_cycle(graph: Dict, hits: List[AnchorHit], sequence: str, verbose: bool = False) -> Tuple[List[str], float, float, int, List[float]]:
364
+ """
365
+ Find the best anchor for decomposition using distance clustering.
366
+
367
+ Strategy:
368
+ 1. For each anchor with self-loop, analyze distance distribution
369
+ 2. Cluster distances to detect multi-region patterns (A-B, A-B-C, etc.)
370
+ 3. True period = sum of cluster centers
371
+ 4. Select anchor with lowest CV after cluster-based correction
372
+
373
+ Returns:
374
+ Tuple of (cycle, mean_period, cv, n_clusters, cluster_centers)
375
+ """
376
+ edges = graph["edges"]
377
+ distances = graph.get("distances", {})
378
+
379
+ if not edges:
380
+ return [], 0, float('inf'), 1, []
381
+
382
+ # Collect candidate anchors with cluster analysis
383
+ # Format: (cycle, cv, mean_period, count, n_clusters, centers)
384
+ candidates = []
385
+
386
+ # Get unique anchors from graph with self-loops
387
+ anchors_to_try = set()
388
+ for (from_a, to_a), count in edges.items():
389
+ if count >= 3: # Need enough occurrences
390
+ anchors_to_try.add(from_a)
391
+ if from_a != to_a:
392
+ anchors_to_try.add(to_a)
393
+
394
+ for anchor in anchors_to_try:
395
+ # Find ALL positions for this anchor (not just self-loop)
396
+ positions = [hit.position for hit in hits if hit.anchor == anchor]
397
+ if len(positions) < 3:
398
+ continue
399
+ positions.sort()
400
+
401
+ # Calculate distances between ALL consecutive positions of this anchor
402
+ # This captures the full pattern even if other anchors appear in between
403
+ dist_list = []
404
+ for i in range(len(positions) - 1):
405
+ dist_list.append(positions[i + 1] - positions[i])
406
+
407
+ if len(dist_list) < 3:
408
+ continue
409
+
410
+ # Analyze distance distribution - detect multi-region patterns
411
+ n_clusters, centers, true_period = analyze_distance_distribution(dist_list, verbose=False)
412
+
413
+ # Classify positions and get cut positions
414
+ labels = classify_positions_by_distance(positions, centers)
415
+ cut_positions = select_cut_positions_by_cluster(positions, labels)
416
+
417
+ if len(cut_positions) < 2:
418
+ continue
419
+
420
+ # Calculate CV of resulting monomer lengths
421
+ lengths = []
422
+ for i in range(len(cut_positions) - 1):
423
+ lengths.append(cut_positions[i + 1] - cut_positions[i])
424
+
425
+ if len(lengths) < 2:
426
+ continue
427
+
428
+ mean_len = sum(lengths) / len(lengths)
429
+ variance = sum((x - mean_len) ** 2 for x in lengths) / len(lengths)
430
+ cv = (variance ** 0.5) / mean_len if mean_len > 0 else float('inf')
431
+
432
+ candidates.append(([anchor], cv, true_period, len(cut_positions), n_clusters, centers))
433
+
434
+ if verbose:
435
+ cluster_info = f" ({n_clusters} clusters)" if n_clusters > 1 else ""
436
+ print(f" Candidate: {anchor[:20]}... period={true_period:.0f}, CV={cv:.3f}, cuts={len(cut_positions)}{cluster_info}")
437
+
438
+ # Select best candidate by CV
439
+ if not candidates:
440
+ # Fallback: most frequent edge
441
+ if edges:
442
+ start_edge = max(edges.items(), key=lambda x: x[1])[0]
443
+ if verbose:
444
+ print(f" Fallback to most frequent: {start_edge[0][:20]}...")
445
+ return [start_edge[0]], 0, float('inf'), 1, []
446
+ return [], 0, float('inf'), 1, []
447
+
448
+ # Sort by CV (ascending), then by n_clusters (ascending) - prefer simpler patterns
449
+ candidates.sort(key=lambda x: (x[1], x[4]))
450
+
451
+ best_cycle, best_cv, best_period, best_count, best_n_clusters, best_centers = candidates[0]
452
+
453
+ if verbose:
454
+ cluster_info = f" ({best_n_clusters} clusters)" if best_n_clusters > 1 else ""
455
+ print(f" Selected: {best_cycle[0][:20]}... period={best_period:.0f}, CV={best_cv:.3f}{cluster_info}")
456
+ if len(candidates) > 1:
457
+ print(f" (rejected {len(candidates)-1} other candidates)")
458
+
459
+ return best_cycle, best_period, best_cv, best_n_clusters, best_centers
460
+
461
+
462
+ def _calculate_cycle_distances(cycle: List[str], distances: Dict) -> List[float]:
463
+ """Calculate full cycle distances from edge distances."""
464
+ if len(cycle) == 1:
465
+ edge = (cycle[0], cycle[0])
466
+ return distances.get(edge, [])
467
+
468
+ # For multi-anchor cycle, sum distances along the cycle
469
+ # This is approximate - we'd need to track actual cycle instances
470
+ cycle_dists = []
471
+ edge = (cycle[0], cycle[1])
472
+ if edge in distances:
473
+ # Use first edge distances as proxy
474
+ # TODO: properly track full cycle instances
475
+ cycle_dists = distances[edge]
476
+
477
+ return cycle_dists
478
+
479
+
480
+ def estimate_monomer_length(graph: Dict, cycle: List[str]) -> Tuple[float, float]:
481
+ """
482
+ Estimate monomer length from cycle and transition distances.
483
+
484
+ Returns (mean_length, variance)
485
+ """
486
+ if not cycle:
487
+ return 0, 0
488
+
489
+ distances = graph["distances"]
490
+
491
+ # Sum distances along the cycle
492
+ cycle_lengths = []
493
+
494
+ # We need to find instances where the full cycle occurs
495
+ # For now, estimate from individual edge distances
496
+ total_distances = []
497
+
498
+ for i in range(len(cycle)):
499
+ from_anchor = cycle[i]
500
+ to_anchor = cycle[(i + 1) % len(cycle)]
501
+ edge = (from_anchor, to_anchor)
502
+
503
+ if edge in distances:
504
+ total_distances.extend(distances[edge])
505
+
506
+ if not total_distances:
507
+ return 0, 0
508
+
509
+ # Rough estimate: sum of mean distances along cycle
510
+ edge_means = []
511
+ for i in range(len(cycle)):
512
+ from_anchor = cycle[i]
513
+ to_anchor = cycle[(i + 1) % len(cycle)]
514
+ edge = (from_anchor, to_anchor)
515
+
516
+ if edge in distances:
517
+ edge_means.append(sum(distances[edge]) / len(distances[edge]))
518
+
519
+ if edge_means:
520
+ estimated_length = sum(edge_means)
521
+ variance = sum((d - estimated_length/len(cycle))**2 for d in total_distances) / len(total_distances)
522
+ return estimated_length, variance
523
+
524
+ return 0, 0
525
+
526
+
527
+ def decompose_by_cycle(
528
+ sequence: str,
529
+ hits: List[AnchorHit],
530
+ cycle: List[str],
531
+ cluster_centers: List[float] = None,
532
+ verbose: bool = False
533
+ ) -> List[str]:
534
+ """
535
+ Decompose sequence into monomers based on the anchor cycle.
536
+
537
+ Strategy:
538
+ 1. Find all positions of the anchor
539
+ 2. If cluster_centers provided (multi-region pattern), use cluster analysis
540
+ to select only the "start" positions
541
+ 3. Cut at selected positions
542
+
543
+ Args:
544
+ sequence: The array sequence
545
+ hits: Sorted list of AnchorHit
546
+ cycle: List of anchors representing one monomer
547
+ cluster_centers: If multi-region pattern detected, the cluster centers
548
+ for classifying positions. None = cut at every position.
549
+
550
+ Returns:
551
+ List of monomer sequences
552
+ """
553
+ if not cycle or not hits:
554
+ return [sequence] if sequence else []
555
+
556
+ start_anchor = cycle[0]
557
+
558
+ # Find all positions where anchor appears
559
+ all_positions = []
560
+ for hit in hits:
561
+ if hit.anchor == start_anchor:
562
+ all_positions.append(hit.position)
563
+
564
+ if not all_positions:
565
+ return [sequence]
566
+
567
+ all_positions.sort()
568
+
569
+ # Determine cut positions based on cluster analysis
570
+ if cluster_centers and len(cluster_centers) > 1:
571
+ # Multi-region pattern: use cluster-based selection
572
+ labels = classify_positions_by_distance(all_positions, cluster_centers)
573
+ cut_positions = select_cut_positions_by_cluster(all_positions, labels, verbose)
574
+
575
+ if verbose:
576
+ print(f" Multi-region pattern: {len(cluster_centers)} clusters")
577
+ print(f" All positions: {len(all_positions)}, cut positions: {len(cut_positions)}")
578
+ else:
579
+ # Single region: cut at every position
580
+ cut_positions = all_positions
581
+
582
+ if verbose:
583
+ print(f" Cut positions: {cut_positions[:10]}{'...' if len(cut_positions) > 10 else ''}")
584
+
585
+ if not cut_positions:
586
+ return [sequence]
587
+
588
+ # Cut sequence
589
+ monomers = []
590
+
591
+ # Left flank
592
+ if cut_positions[0] > 0:
593
+ monomers.append(sequence[:cut_positions[0]])
594
+
595
+ # Monomers
596
+ for i in range(len(cut_positions) - 1):
597
+ monomers.append(sequence[cut_positions[i]:cut_positions[i + 1]])
598
+
599
+ # Last part (may be partial)
600
+ if cut_positions[-1] < len(sequence):
601
+ monomers.append(sequence[cut_positions[-1]:])
602
+
603
+ return monomers
604
+
605
+
606
+ class AnchorGraphDecomposer:
607
+ """
608
+ Main class for anchor graph-based decomposition.
609
+
610
+ Usage:
611
+ decomposer = AnchorGraphDecomposer()
612
+ decomposer.build_from_candidates(sequence, candidates)
613
+ monomers = decomposer.decompose()
614
+ """
615
+
616
+ def __init__(self):
617
+ self.sequence: Optional[str] = None
618
+ self.anchors: List[str] = []
619
+ self.hits: List[AnchorHit] = []
620
+ self.graph: Dict = {}
621
+ self.cycle: List[str] = []
622
+ self.estimated_period: float = 0
623
+ self.estimated_cv: float = float('inf')
624
+ self.n_clusters: int = 1 # Number of distance clusters (1 = normal, >1 = multi-region)
625
+ self.cluster_centers: List[float] = [] # Cluster centers for multi-region patterns
626
+
627
+ def build_from_candidates(
628
+ self,
629
+ sequence: str,
630
+ candidates: List[Dict],
631
+ top_k: int = 10,
632
+ verbose: bool = False
633
+ ) -> None:
634
+ """
635
+ Build anchor graph from compute_cuts candidates.
636
+
637
+ Args:
638
+ sequence: The array sequence
639
+ candidates: List of candidate dicts from compute_cuts
640
+ top_k: Number of top candidates to use
641
+ verbose: Print debug info
642
+ """
643
+ self.sequence = sequence
644
+
645
+ # Get top candidates, filtering by max anchor length
646
+ # Anchors should be short (<=11bp) - they are graph nodes, not sequences
647
+ MAX_ANCHOR_LENGTH = 11
648
+ top = get_top_candidates(candidates, top_k)
649
+ self.anchors = [c['cut'] for c in top if len(c['cut']) <= MAX_ANCHOR_LENGTH]
650
+
651
+ if verbose:
652
+ print(f"Top {len(self.anchors)} anchors (max length {MAX_ANCHOR_LENGTH}bp):")
653
+ for c in top:
654
+ if len(c['cut']) <= MAX_ANCHOR_LENGTH:
655
+ print(f" '{c['cut']}' score={c.get('adjusted_score', c.get('base_score', 0)):.3f}")
656
+
657
+ # Find hits hierarchically (longer first)
658
+ if verbose:
659
+ print(f"\nFinding anchor hits (longer anchors first):")
660
+ self.hits = find_anchor_hits_hierarchical(sequence, self.anchors, verbose)
661
+
662
+ if verbose:
663
+ print(f"\nTotal hits: {len(self.hits)}")
664
+
665
+ # Build transition graph
666
+ self.graph = build_transition_graph(self.hits)
667
+
668
+ if verbose:
669
+ print(f"\nTransition graph:")
670
+ for edge, count in sorted(self.graph["edges"].items(), key=lambda x: x[1], reverse=True)[:10]:
671
+ dists = self.graph["distances"][edge]
672
+ mean_dist = sum(dists) / len(dists)
673
+ print(f" {edge[0][:15]}... -> {edge[1][:15]}...: count={count}, mean_dist={mean_dist:.0f}")
674
+
675
+ # Find monomer cycle with cluster analysis
676
+ if verbose:
677
+ print(f"\nFinding monomer cycle:")
678
+ self.cycle, self.estimated_period, self.estimated_cv, self.n_clusters, self.cluster_centers = find_monomer_cycle(
679
+ self.graph, self.hits, sequence, verbose
680
+ )
681
+
682
+ if verbose and self.cycle:
683
+ cluster_info = f", {self.n_clusters} clusters" if self.n_clusters > 1 else ""
684
+ print(f"\nEstimated monomer length: {self.estimated_period:.0f} bp (CV={self.estimated_cv:.3f}{cluster_info})")
685
+
686
+ def decompose(self, verbose: bool = False) -> List[str]:
687
+ """
688
+ Decompose sequence into monomers.
689
+
690
+ Returns:
691
+ List of monomer sequences
692
+ """
693
+ if not self.cycle:
694
+ if verbose:
695
+ print("No cycle found, returning whole sequence")
696
+ return [self.sequence] if self.sequence else []
697
+
698
+ if verbose:
699
+ cluster_info = f" ({self.n_clusters} clusters)" if self.n_clusters > 1 else ""
700
+ print(f"\nDecomposing by cycle: {' -> '.join(a[:10]+'...' for a in self.cycle)}{cluster_info}")
701
+
702
+ monomers = decompose_by_cycle(self.sequence, self.hits, self.cycle, self.cluster_centers, verbose)
703
+
704
+ # Verify reconstruction
705
+ reconstructed = "".join(monomers)
706
+ if reconstructed != self.sequence:
707
+ if verbose:
708
+ print(f"WARNING: Reconstruction mismatch! {len(self.sequence)} vs {len(reconstructed)}")
709
+ elif verbose:
710
+ print(f"Reconstruction: PERFECT")
711
+
712
+ return monomers
713
+
714
+ def get_stats(self) -> Dict:
715
+ """Get decomposer statistics."""
716
+ return {
717
+ "num_anchors": len(self.anchors),
718
+ "anchors": self.anchors,
719
+ "num_hits": len(self.hits),
720
+ "cycle": self.cycle,
721
+ "estimated_monomer_length": self.estimated_period,
722
+ "estimated_cv": self.estimated_cv,
723
+ "n_clusters": self.n_clusters,
724
+ "cluster_centers": self.cluster_centers,
725
+ "edge_counts": self.graph.get("edges", {}),
726
+ }