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,1784 @@
1
+
2
+
3
+
4
+ import argparse
5
+
6
+ import os
7
+ from collections import Counter
8
+ import re
9
+ from tqdm import tqdm
10
+ from statistics import mean, stdev
11
+
12
+ import editdistance as ed
13
+
14
+ from .core_functions.io.fasta_reader import \
15
+ sc_iter_fasta_file
16
+ from .core_functions.io.satellome_reader import \
17
+ sc_iter_satellome_file
18
+ from .core_functions.io.trf_reader import sc_iter_trf_file
19
+ from .core_functions.tools.fs_tree import \
20
+ iter_fs_tree_from_sequence
21
+ from .core_functions.tools.sequences import get_revcomp
22
+ from .core_functions.tools.anchor_graph import AnchorGraphDecomposer
23
+
24
+
25
+ def get_canonical_orientation(sequence):
26
+ """
27
+ Determine canonical orientation where A>T and C>G.
28
+ Returns True if sequence is already canonical, False if needs reversal.
29
+ """
30
+ a_count = sequence.count('A')
31
+ t_count = sequence.count('T')
32
+ c_count = sequence.count('C')
33
+ g_count = sequence.count('G')
34
+
35
+ # Primary criterion: A > T
36
+ if a_count != t_count:
37
+ return a_count > t_count
38
+
39
+ # Secondary criterion: C > G (when A == T)
40
+ return c_count > g_count
41
+
42
+
43
+ def rotate_monomers_to_cut(decomposition, cut_sequence):
44
+ """
45
+ Rotate monomers so they start with the cut sequence.
46
+ Returns rotated monomers.
47
+ """
48
+ rotated = []
49
+
50
+ for monomer in decomposition:
51
+ if monomer.startswith(cut_sequence):
52
+ # Already starts with cut
53
+ rotated.append(monomer)
54
+ elif cut_sequence in monomer:
55
+ # Find cut and rotate
56
+ pos = monomer.find(cut_sequence)
57
+ rotated_monomer = monomer[pos:] + monomer[:pos]
58
+ rotated.append(rotated_monomer)
59
+ else:
60
+ # No cut (flank), keep as is
61
+ rotated.append(monomer)
62
+
63
+ return rotated
64
+
65
+
66
+ def get_top1_nucleotide(array):
67
+ ### Step 1. Find the most frequent nucleotide (TODO: check all nucleotides and find with the best final score
68
+ c = Counter()
69
+ for n in "ACTG":
70
+ c[n] = array.count(n)
71
+ # print(n, array.count(n))
72
+ return c.most_common(1)[0][0]
73
+
74
+
75
+ def get_fs_tree(array, top1_nucleotide, cutoff):
76
+ ### Step 2. Build fs_tree (TODO: optimize it for long sequences)
77
+ names_ = [i for i in range(len(array)) if array[i] == top1_nucleotide]
78
+ positions_ = names_[::]
79
+ # print(f"Starting positions: {len(positions_)}")
80
+ return iter_fs_tree_from_sequence(
81
+ array, top1_nucleotide, names_, positions_, cutoff
82
+ )
83
+
84
+
85
+ def is_self_repeating(pattern):
86
+ """Check if a pattern is composed of repeated smaller units."""
87
+ n = len(pattern)
88
+ for sub_len in range(1, n // 2 + 1):
89
+ if n % sub_len == 0:
90
+ sub_pattern = pattern[:sub_len]
91
+ if pattern == sub_pattern * (n // sub_len):
92
+ return sub_pattern
93
+ return None
94
+
95
+
96
+ def iterate_hints(array, fs_tree, depth):
97
+ ### Step 3. Find a list of hints (hint is the sequenece for array cutoff)
98
+ ### Modified to stop chains when self-repeating patterns are detected
99
+
100
+ current_length = 0
101
+ buffer = []
102
+ found_patterns = {} # Track patterns by their minimal unit
103
+
104
+ for L, names, positions in fs_tree:
105
+ if L != current_length:
106
+ if buffer:
107
+ max_n = 0
108
+ found_seq = None
109
+ for start, end, N in buffer:
110
+ if N > max_n:
111
+ max_n = N
112
+ found_seq = array[start : end + 1]
113
+
114
+ # Check if this is a self-repeating pattern
115
+ minimal_unit = is_self_repeating(found_seq)
116
+
117
+ if minimal_unit:
118
+ # This is self-repeating, yield the minimal unit instead
119
+ # But only if we haven't yielded it before
120
+ if minimal_unit not in found_patterns:
121
+ # Find the frequency of the minimal unit
122
+ min_len = len(minimal_unit)
123
+ min_count = array.count(minimal_unit)
124
+ yield min_len, minimal_unit, min_count
125
+ found_patterns[minimal_unit] = True
126
+ # Don't yield the longer self-repeating pattern
127
+ else:
128
+ # Not self-repeating, yield as normal
129
+ yield current_length, found_seq, max_n
130
+ found_patterns[found_seq] = True
131
+
132
+ buffer = []
133
+ current_length = L
134
+ if current_length > depth:
135
+ break
136
+
137
+ start = names[0]
138
+ end = positions[0]
139
+ N = len(names)
140
+ buffer.append((start, end, N))
141
+
142
+ if buffer:
143
+ max_n = 0
144
+ found_seq = None
145
+ for start, end, N in buffer:
146
+ if N > max_n:
147
+ max_n = N
148
+ found_seq = array[start : end + 1]
149
+
150
+ minimal_unit = is_self_repeating(found_seq)
151
+ if minimal_unit and minimal_unit not in found_patterns:
152
+ min_len = len(minimal_unit)
153
+ min_count = array.count(minimal_unit)
154
+ yield min_len, minimal_unit, min_count
155
+ elif not minimal_unit:
156
+ yield current_length, found_seq, max_n
157
+
158
+
159
+ def gcd(a, b):
160
+ """Greatest common divisor."""
161
+ while b:
162
+ a, b = b, a % b
163
+ return a
164
+
165
+
166
+ def find_gcd_of_list(numbers):
167
+ """Find GCD of a list of numbers."""
168
+ if not numbers:
169
+ return 0
170
+ result = numbers[0]
171
+ for num in numbers[1:]:
172
+ result = gcd(result, num)
173
+ if result == 1:
174
+ return 1
175
+ return result
176
+
177
+
178
+ def compute_cuts(array, hints, score_threshold=0.05, fragmentation_threshold=0.5):
179
+ ### Step 4. Find optimal cutoff with improved criteria
180
+
181
+ candidates = []
182
+
183
+ # Calculate metrics for each hint
184
+ for L, cut_sequence, N in hints:
185
+ parts = array.split(cut_sequence)
186
+ periods = []
187
+ non_empty_periods = [] # Track periods from non-empty parts
188
+
189
+ for i, part in enumerate(parts):
190
+ # Handle edge cases for first/last parts
191
+ if i < len(parts) - 1 or part: # All except possibly empty last
192
+ period = len(part) + len(cut_sequence)
193
+ periods.append(period)
194
+ # Track non-empty parts separately
195
+ if len(part) > 0:
196
+ non_empty_periods.append(period)
197
+
198
+ if not periods:
199
+ continue
200
+
201
+ # Determine if this is a perfect/near-perfect repeat
202
+ empty_ratio = (len(periods) - len(non_empty_periods)) / len(periods) if periods else 0
203
+
204
+ if empty_ratio >= 0.8: # 80% or more empty parts = perfect/near-perfect repeat
205
+ # This is a perfect or near-perfect repeat
206
+ # Use the cut sequence length as the period
207
+ period_counts = Counter([len(cut_sequence)])
208
+ mode_period = len(cut_sequence)
209
+ mode_count = len(periods)
210
+ total_segments = len(periods)
211
+ elif non_empty_periods:
212
+ # Use only non-empty parts for period calculation
213
+ period_counts = Counter(non_empty_periods)
214
+ mode_period, mode_count = period_counts.most_common(1)[0]
215
+ total_segments = len(non_empty_periods)
216
+ else:
217
+ # Fallback: use all periods
218
+ period_counts = Counter(periods)
219
+ mode_period, mode_count = period_counts.most_common(1)[0]
220
+ total_segments = len(periods)
221
+
222
+ # Base score (uniformity)
223
+ base_score = mode_count / total_segments
224
+
225
+ # Fragmentation penalty
226
+ short_threshold = mode_period * fragmentation_threshold
227
+ short_fragments = sum(1 for p in periods if p < short_threshold)
228
+ fragmentation = short_fragments / total_segments
229
+
230
+ # Check for periodicity/divisibility
231
+ unique_periods = list(period_counts.keys())
232
+ period_gcd = find_gcd_of_list(unique_periods) if len(unique_periods) > 1 else mode_period
233
+
234
+ candidates.append({
235
+ 'cut': cut_sequence,
236
+ 'mode_period': mode_period,
237
+ 'base_score': base_score,
238
+ 'fragmentation': fragmentation,
239
+ 'period_gcd': period_gcd,
240
+ 'period_distribution': period_counts,
241
+ 'num_segments': total_segments,
242
+ 'num_parts': len(parts), # Total number of parts from split
243
+ 'empty_ratio': empty_ratio
244
+ })
245
+
246
+ if not candidates:
247
+ return array, 0, len(array)
248
+
249
+ # Group candidates by score
250
+ candidates.sort(key=lambda x: x['base_score'], reverse=True)
251
+ best_base_score = candidates[0]['base_score']
252
+
253
+ # Get all candidates within threshold
254
+ similar_candidates = [
255
+ c for c in candidates
256
+ if c['base_score'] >= best_base_score - score_threshold
257
+ ]
258
+
259
+ # Check for fundamental period (GCD > 1)
260
+ fundamental_candidates = []
261
+ for c in similar_candidates:
262
+ if c['period_gcd'] > 1 and c['period_gcd'] < c['mode_period']:
263
+ # Check if most periods are multiples of GCD
264
+ multiples = sum(1 for p in c['period_distribution']
265
+ if p % c['period_gcd'] == 0)
266
+ if multiples >= c['num_segments'] * 0.8: # 80% are multiples
267
+ c['fundamental_period'] = c['period_gcd']
268
+ fundamental_candidates.append(c)
269
+
270
+ # If we found fundamental periods, use those
271
+ if fundamental_candidates:
272
+ best = min(fundamental_candidates, key=lambda x: x['fundamental_period'])
273
+ return best['cut'], best['base_score'], best['fundamental_period']
274
+
275
+ # Otherwise, penalize fragmentation and choose minimal period
276
+ for c in similar_candidates:
277
+ c['adjusted_score'] = c['base_score'] * (1 - c['fragmentation'] * 0.5)
278
+
279
+ # Sort by adjusted score, then by number of segments (fewer is better), then by period (smaller is better)
280
+ similar_candidates.sort(key=lambda x: (-x['adjusted_score'], x['num_segments'], x['mode_period']))
281
+
282
+ best = similar_candidates[0]
283
+ return best['cut'], best['base_score'], best['mode_period']
284
+
285
+
286
+ ### Step 5a. Try to cut long monomers to expected
287
+ def refine_repeat_even(repeat, best_period):
288
+ # Protection against destructive splitting when period=1
289
+ if best_period <= 1 and len(repeat) > 1:
290
+ # Don't split multi-character monomers into single nucleotides
291
+ yield repeat
292
+ return
293
+
294
+ if len(repeat) % best_period == 0:
295
+ start = 0
296
+ for _ in range(len(repeat) // best_period):
297
+ yield repeat[start : start + best_period]
298
+ start += best_period
299
+ else:
300
+ yield repeat
301
+
302
+
303
+ def detect_and_fix_ab_pattern(decomposition, cut_seq, verbose=False):
304
+ """
305
+ Detect A-B alternating pattern and merge if it improves length uniformity.
306
+
307
+ A-B pattern occurs when the cut sequence appears at TWO positions within each
308
+ true monomer, creating an alternating pattern of short (A) and long (B) fragments.
309
+
310
+ Decision is based on CV improvement, NOT sequence similarity.
311
+ Different parts of the same monomer CAN have dissimilar sequences.
312
+
313
+ Returns:
314
+ (new_decomposition, was_merged)
315
+ """
316
+ if len(decomposition) < 4:
317
+ return decomposition, False
318
+
319
+ # Get lengths of monomers that start with cut sequence
320
+ monomer_lengths = []
321
+ monomer_indices = []
322
+
323
+ for i, m in enumerate(decomposition):
324
+ if m.startswith(cut_seq):
325
+ monomer_lengths.append(len(m))
326
+ monomer_indices.append(i)
327
+
328
+ if len(monomer_lengths) < 4:
329
+ return decomposition, False
330
+
331
+ # Check for bimodal distribution
332
+ length_counts = Counter(monomer_lengths)
333
+
334
+ # Need at least 2 distinct length groups with significant counts
335
+ if len(length_counts) < 2:
336
+ return decomposition, False
337
+
338
+ # Find the two most common length modes
339
+ top_modes = length_counts.most_common(2)
340
+ mode_a_len, mode_a_count = top_modes[0]
341
+ mode_b_len, mode_b_count = top_modes[1]
342
+
343
+ # Both modes should have significant representation (at least 15% each)
344
+ total_monomers = len(monomer_lengths)
345
+ if mode_a_count < total_monomers * 0.15 or mode_b_count < total_monomers * 0.15:
346
+ return decomposition, False
347
+
348
+ # Check that the two modes are significantly different (not just noise)
349
+ # They should differ by at least 50% of the smaller one
350
+ smaller_len = min(mode_a_len, mode_b_len)
351
+ larger_len = max(mode_a_len, mode_b_len)
352
+
353
+ if larger_len < smaller_len * 1.5:
354
+ return decomposition, False
355
+
356
+ if verbose:
357
+ print(f" Detected potential A-B pattern: {mode_a_len}bp ({mode_a_count}x) vs {mode_b_len}bp ({mode_b_count}x)")
358
+
359
+ # Calculate current CV
360
+ current_mean = mean(monomer_lengths)
361
+ current_std = stdev(monomer_lengths) if len(monomer_lengths) > 1 else 0
362
+ current_cv = current_std / current_mean if current_mean > 0 else 0
363
+
364
+ # Check if adjacent pairs sum to consistent values
365
+ # This is the key indicator of A-B pattern
366
+ merged_lengths = []
367
+ for i in range(0, len(monomer_indices) - 1, 2):
368
+ idx1 = monomer_indices[i]
369
+ idx2 = monomer_indices[i + 1]
370
+
371
+ # Only merge adjacent fragments in the original decomposition
372
+ if idx2 == idx1 + 1:
373
+ merged_len = len(decomposition[idx1]) + len(decomposition[idx2])
374
+ merged_lengths.append(merged_len)
375
+
376
+ if len(merged_lengths) < 3:
377
+ return decomposition, False
378
+
379
+ # Calculate CV of merged lengths
380
+ merged_mean = mean(merged_lengths)
381
+ merged_std = stdev(merged_lengths) if len(merged_lengths) > 1 else 0
382
+ merged_cv = merged_std / merged_mean if merged_mean > 0 else 0
383
+
384
+ if verbose:
385
+ print(f" Current CV: {current_cv:.3f} (mean={current_mean:.0f})")
386
+ print(f" Merged CV: {merged_cv:.3f} (mean={merged_mean:.0f})")
387
+
388
+ # Merge if CV improves significantly (at least 50% reduction)
389
+ if merged_cv >= current_cv * 0.5:
390
+ if verbose:
391
+ print(f" CV improvement not significant enough, skipping merge")
392
+ return decomposition, False
393
+
394
+ if verbose:
395
+ print(f" ✓ Merging A-B pattern (CV improved by {(1 - merged_cv/current_cv)*100:.0f}%)")
396
+
397
+ # Perform the merge
398
+ original_sequence = "".join(decomposition)
399
+ new_decomposition = []
400
+ i = 0
401
+
402
+ while i < len(decomposition):
403
+ current = decomposition[i]
404
+
405
+ # Check if this and next are both monomers that should be merged
406
+ if (i < len(decomposition) - 1 and
407
+ current.startswith(cut_seq) and
408
+ decomposition[i + 1].startswith(cut_seq)):
409
+
410
+ # Check if their lengths match the A-B pattern
411
+ curr_len = len(current)
412
+ next_len = len(decomposition[i + 1])
413
+
414
+ # Check if one is close to mode_a and other is close to mode_b
415
+ curr_is_a = abs(curr_len - smaller_len) / smaller_len < 0.2
416
+ curr_is_b = abs(curr_len - larger_len) / larger_len < 0.2
417
+ next_is_a = abs(next_len - smaller_len) / smaller_len < 0.2
418
+ next_is_b = abs(next_len - larger_len) / larger_len < 0.2
419
+
420
+ # Merge if one is A and other is B (in either order)
421
+ if (curr_is_a and next_is_b) or (curr_is_b and next_is_a):
422
+ merged = current + decomposition[i + 1]
423
+ new_decomposition.append(merged)
424
+ i += 2
425
+ continue
426
+
427
+ new_decomposition.append(current)
428
+ i += 1
429
+
430
+ # Verify reconstruction
431
+ reconstructed = "".join(new_decomposition)
432
+ if reconstructed != original_sequence:
433
+ if verbose:
434
+ print(f" ERROR: Reconstruction failed after A-B merge, reverting")
435
+ return decomposition, False
436
+
437
+ return new_decomposition, True
438
+
439
+
440
+ def optimize_monomer_lengths(decomposition, cut_seq, verbose=True, array_id=None):
441
+ """
442
+ Post-processing optimization to merge short frequent monomers with adjacent longer ones.
443
+ Goal: minimize variance of monomer lengths.
444
+
445
+ This runs AFTER the main decomposition is complete.
446
+ """
447
+ from collections import Counter
448
+ import editdistance as ed
449
+
450
+ if len(decomposition) < 3:
451
+ return decomposition
452
+
453
+ # Calculate original sequence for verification
454
+ original_sequence = "".join(decomposition)
455
+ original_length = len(original_sequence)
456
+
457
+ # Skip if first fragment doesn't start with cut (it's a flank)
458
+ start_idx = 0
459
+ if decomposition[0] and not decomposition[0].startswith(cut_seq):
460
+ start_idx = 1
461
+
462
+ # Get monomer lengths (excluding flanks)
463
+ monomer_info = [] # List of (index, length) tuples
464
+ for i in range(start_idx, len(decomposition)):
465
+ # Skip likely right flank
466
+ if i == len(decomposition) - 1:
467
+ avg_len = sum(len(d) for d in decomposition[start_idx:i]) / max(1, i - start_idx)
468
+ if len(decomposition[i]) < avg_len * 0.7:
469
+ continue
470
+ monomer_info.append((i, len(decomposition[i])))
471
+
472
+ if len(monomer_info) < 2:
473
+ return decomposition
474
+
475
+ # Count length frequencies
476
+ length_counts = Counter(info[1] for info in monomer_info)
477
+ all_lengths = [info[1] for info in monomer_info]
478
+
479
+ # Calculate initial variance
480
+ initial_mean = mean(all_lengths)
481
+ initial_variance = sum((x - initial_mean) ** 2 for x in all_lengths) / len(all_lengths)
482
+
483
+ if verbose:
484
+ print(f"Initial variance: {initial_variance:.1f} (mean={initial_mean:.1f})")
485
+ print(f"Length distribution: {dict(sorted(length_counts.items())[:5])}...")
486
+
487
+ # Find frequently occurring short monomers (at least 2 occurrences or 15% of monomers)
488
+ min_frequency = max(2, int(len(monomer_info) * 0.15))
489
+ short_lengths = [length for length, count in length_counts.items()
490
+ if count >= min_frequency and length < initial_mean * 0.5]
491
+
492
+ if not short_lengths:
493
+ return decomposition
494
+
495
+ if verbose:
496
+ print(f"Frequent short lengths: {short_lengths}")
497
+
498
+ # Try merging short monomers with adjacent ones
499
+ working_decomposition = decomposition.copy()
500
+ merge_occurred = True
501
+ iteration = 0
502
+
503
+ while merge_occurred and iteration < 1: # Only one iteration to avoid over-merging
504
+ merge_occurred = False
505
+ iteration += 1
506
+ new_decomposition = []
507
+ i = 0
508
+
509
+ while i < len(working_decomposition):
510
+ if i < len(working_decomposition) - 1:
511
+ current = working_decomposition[i]
512
+ next_frag = working_decomposition[i + 1]
513
+
514
+ # Check if current is a short frequent monomer or similar to one
515
+ current_is_short = False
516
+ if current.startswith(cut_seq) and next_frag.startswith(cut_seq):
517
+ if len(current) in short_lengths:
518
+ current_is_short = True
519
+ else:
520
+ # Check if it's within 5% of any frequent short length
521
+ for short_len in short_lengths:
522
+ if abs(len(current) - short_len) / short_len < 0.05:
523
+ current_is_short = True
524
+ break
525
+
526
+ if current_is_short:
527
+ # Check if we're dealing with alternating different sequences (A B A B pattern)
528
+ # Compare the short and long fragments to see if they're different types
529
+ seq_short = current[len(cut_seq):min(len(current), len(cut_seq)+50)]
530
+ seq_long = next_frag[len(cut_seq):min(len(next_frag), len(cut_seq)+50)]
531
+
532
+ if len(seq_short) > 0 and len(seq_long) > 0:
533
+ # Calculate similarity between short and long
534
+ similarity = 1 - (ed.eval(seq_short, seq_long) / max(len(seq_short), len(seq_long)))
535
+
536
+ # If short and long are dissimilar, check for A-B pattern
537
+ if similarity < 0.8: # Less than 80% similar means different types
538
+ # Count how many short and long fragments we have
539
+ short_count = 0
540
+ long_count = 0
541
+
542
+ for j, frag in enumerate(working_decomposition):
543
+ if frag.startswith(cut_seq):
544
+ frag_len = len(frag)
545
+ # Check if it's a short fragment (similar length to current)
546
+ if abs(frag_len - len(current)) / len(current) < 0.2:
547
+ short_count += 1
548
+ # Check if it's a long fragment (similar length to next)
549
+ elif abs(frag_len - len(next_frag)) / len(next_frag) < 0.2:
550
+ long_count += 1
551
+
552
+ # If we have multiple instances of both short and long, it's likely A-B
553
+ if short_count >= 3 and long_count >= 3:
554
+ if verbose:
555
+ print(f" Detected A-B alternating pattern (dissimilar sequences), skipping merge of {len(current)}+{len(next_frag)}")
556
+ # Skip this merge
557
+ new_decomposition.append(current)
558
+ i += 1
559
+ continue
560
+
561
+ # Calculate what variance would be after merge
562
+ test_lengths = []
563
+ for j, frag in enumerate(working_decomposition):
564
+ if j == i:
565
+ test_lengths.append(len(current) + len(next_frag))
566
+ elif j == i + 1:
567
+ continue
568
+ else:
569
+ # Only count monomers, not flanks
570
+ if frag.startswith(cut_seq) or (j > 0 and j < len(working_decomposition) - 1):
571
+ test_lengths.append(len(frag))
572
+
573
+ if test_lengths:
574
+ test_mean = mean(test_lengths)
575
+ test_variance = sum((x - test_mean) ** 2 for x in test_lengths) / len(test_lengths)
576
+
577
+ # For alternating patterns, also check coefficient of variation
578
+ test_cv = (test_variance ** 0.5) / test_mean if test_mean > 0 else 0
579
+ initial_cv = (initial_variance ** 0.5) / initial_mean if initial_mean > 0 else 0
580
+
581
+ if verbose:
582
+ print(f" Testing merge {len(current)}+{len(next_frag)}: CV {initial_cv:.3f} -> {test_cv:.3f}")
583
+
584
+ # Accept merge if:
585
+ # 1. It reduces CV significantly, OR
586
+ # 2. We're merging a short fragment with a much longer one (likely overcutting)
587
+ length_ratio = len(next_frag) / len(current) if len(current) > 0 else 1
588
+
589
+ if test_cv < initial_cv * 0.98 or length_ratio > 3:
590
+ merged = current + next_frag
591
+ new_decomposition.append(merged)
592
+ i += 2
593
+ merge_occurred = True
594
+ if verbose:
595
+ print(f" ✓ Merged!")
596
+ continue
597
+
598
+ # Try merging next with current if next is short
599
+ else:
600
+ next_is_short = False
601
+ if next_frag.startswith(cut_seq) and current.startswith(cut_seq):
602
+ if len(next_frag) in short_lengths:
603
+ next_is_short = True
604
+ else:
605
+ # Check if it's within 5% of any frequent short length
606
+ for short_len in short_lengths:
607
+ if abs(len(next_frag) - short_len) / short_len < 0.05:
608
+ next_is_short = True
609
+ break
610
+
611
+ if next_is_short:
612
+ # Don't merge if current is already long (avoid merging already merged monomers)
613
+ if len(current) > initial_mean * 1.5:
614
+ # Skip - current is already a merged monomer
615
+ pass
616
+ else:
617
+ # Calculate variance after merge
618
+ test_lengths = []
619
+ for j, frag in enumerate(working_decomposition):
620
+ if j == i:
621
+ test_lengths.append(len(current) + len(next_frag))
622
+ elif j == i + 1:
623
+ continue
624
+ else:
625
+ if frag.startswith(cut_seq) or (j > 0 and j < len(working_decomposition) - 1):
626
+ test_lengths.append(len(frag))
627
+
628
+ if test_lengths:
629
+ test_mean = mean(test_lengths)
630
+ test_variance = sum((x - test_mean) ** 2 for x in test_lengths) / len(test_lengths)
631
+
632
+ # Check coefficient of variation
633
+ test_cv = (test_variance ** 0.5) / test_mean if test_mean > 0 else 0
634
+ initial_cv = (initial_variance ** 0.5) / initial_mean if initial_mean > 0 else 0
635
+
636
+ if verbose:
637
+ print(f" Testing merge {len(current)}+{len(next_frag)}: CV {initial_cv:.3f} -> {test_cv:.3f}")
638
+
639
+ # Accept merge for short+long patterns
640
+ length_ratio = len(current) / len(next_frag) if len(next_frag) > 0 else 1
641
+
642
+ if test_cv < initial_cv * 0.98 or length_ratio > 3:
643
+ merged = current + next_frag
644
+ new_decomposition.append(merged)
645
+ i += 2
646
+ merge_occurred = True
647
+ if verbose:
648
+ print(f" ✓ Merged!")
649
+ continue
650
+
651
+ # No merge, keep fragment
652
+ new_decomposition.append(working_decomposition[i])
653
+ i += 1
654
+
655
+ working_decomposition = new_decomposition
656
+
657
+ # Update variance for next iteration
658
+ current_lengths = [len(f) for f in working_decomposition
659
+ if f.startswith(cut_seq) or working_decomposition.index(f) > 0]
660
+ if current_lengths:
661
+ current_mean = mean(current_lengths)
662
+ initial_variance = sum((x - current_mean) ** 2 for x in current_lengths) / len(current_lengths)
663
+
664
+ # Also update initial_mean for ratio calculations
665
+ initial_mean = current_mean
666
+
667
+ # Final verification - ensure perfect reconstruction
668
+ final_sequence = "".join(working_decomposition)
669
+ if final_sequence != original_sequence:
670
+ print(f"ERROR: Sequence changed during merging! {original_length} != {len(final_sequence)}")
671
+ print(f"Reverting to original decomposition")
672
+ return decomposition
673
+
674
+ if verbose and working_decomposition != decomposition:
675
+ final_lengths = [len(f) for f in working_decomposition if f.startswith(cut_seq)]
676
+ if final_lengths:
677
+ final_mean = mean(final_lengths)
678
+ final_variance = sum((x - final_mean) ** 2 for x in final_lengths) / len(final_lengths)
679
+ print(f"Optimization complete: variance {sum((x - mean(all_lengths)) ** 2 for x in all_lengths) / len(all_lengths):.1f} -> {final_variance:.1f}")
680
+
681
+ return working_decomposition
682
+
683
+
684
+ def find_mutant_anchor(sequence, anchor, expected_pos, window=50, max_dist=2):
685
+ """
686
+ Find anchor with mutations (including indels) near expected position.
687
+
688
+ Args:
689
+ sequence: The monomer sequence to search in
690
+ anchor: The original anchor sequence
691
+ expected_pos: Expected position of anchor (e.g., median monomer length)
692
+ window: Search window around expected position
693
+ max_dist: Maximum edit distance to accept
694
+
695
+ Returns:
696
+ Position of mutant anchor, or None if not found
697
+ """
698
+ anchor_len = len(anchor)
699
+ start = max(0, expected_pos - window)
700
+ end = min(len(sequence), expected_pos + window + anchor_len)
701
+
702
+ best_pos = None
703
+ best_dist = max_dist + 1
704
+ best_pos_dist = float('inf') # Distance from expected position
705
+
706
+ for pos in range(start, end):
707
+ # Check different lengths due to possible indels (±2bp)
708
+ for length in range(anchor_len - 2, anchor_len + 3):
709
+ if pos + length > len(sequence):
710
+ continue
711
+ candidate = sequence[pos:pos + length]
712
+ dist = ed.eval(anchor, candidate)
713
+ pos_dist = abs(pos - expected_pos)
714
+
715
+ # Prefer: 1) lower edit distance, 2) closer to expected position
716
+ if dist < best_dist or (dist == best_dist and pos_dist < best_pos_dist):
717
+ best_dist = dist
718
+ best_pos = pos
719
+ best_pos_dist = pos_dist
720
+
721
+ if best_dist <= max_dist:
722
+ return best_pos
723
+ return None
724
+
725
+
726
+ def split_long_monomers(decomposition, cut_seq, verbose=False, array_id=None):
727
+ """
728
+ Post-processing: split monomers that are 2x, 3x, etc. longer than expected.
729
+
730
+ This happens when anchor has mutation and we missed a cut point.
731
+ We search for mutant anchor near expected position and split there.
732
+ Iterates until no more splits can be made.
733
+
734
+ Args:
735
+ decomposition: List of monomer sequences
736
+ cut_seq: The cut/anchor sequence
737
+ verbose: Print debug info
738
+ array_id: Array identifier for logging
739
+
740
+ Returns:
741
+ New decomposition with long monomers split
742
+ """
743
+ if len(decomposition) < 3:
744
+ return decomposition
745
+
746
+ # Save original for verification
747
+ original_sequence = "".join(decomposition)
748
+
749
+ # Iterate until no more splits can be made
750
+ max_iterations = 10 # Safety limit
751
+ total_splits = 0
752
+ current_decomposition = decomposition
753
+ arr_info = f"[{array_id}] " if array_id else ""
754
+
755
+ for iteration in range(max_iterations):
756
+ # Calculate expected monomer length (median)
757
+ # For short repeats, use ALL monomers (after split_duplicate_halves, monomers
758
+ # may not start with original cut_seq anymore, e.g., 6bp pieces from 12bp split)
759
+ lengths = [len(mono) for mono in current_decomposition]
760
+
761
+ if not lengths:
762
+ break
763
+
764
+ # Use median to avoid outliers affecting expected length
765
+ sorted_lengths = sorted(lengths)
766
+ median_length = sorted_lengths[len(sorted_lengths) // 2]
767
+
768
+ if verbose and iteration == 0:
769
+ print(f"{arr_info}Split long monomers: median={median_length}bp, cut={cut_seq}")
770
+
771
+ result = []
772
+ splits_made = 0
773
+
774
+ # For short repeats, find the most common monomer to use as reference
775
+ if median_length < 23:
776
+ mono_counts = Counter(current_decomposition)
777
+ most_common_mono = mono_counts.most_common(1)[0][0] if mono_counts else None
778
+ else:
779
+ most_common_mono = None
780
+
781
+ for mono_idx, mono in enumerate(current_decomposition):
782
+ # Skip flanks (don't start with cut or most common monomer for short repeats)
783
+ if median_length < 23:
784
+ # For short repeats, skip if it's the most common monomer (already correct size)
785
+ if mono == most_common_mono:
786
+ result.append(mono)
787
+ continue
788
+ else:
789
+ # For longer repeats, skip flanks that don't start with cut
790
+ if not mono.startswith(cut_seq):
791
+ result.append(mono)
792
+ continue
793
+
794
+ # Check if monomer is longer than expected
795
+ ratio = len(mono) / median_length
796
+
797
+ if ratio < 1.3:
798
+ # Normal length monomer
799
+ result.append(mono)
800
+ continue
801
+
802
+ # Try to split - either 2x/3x (mutant anchor) or 1.3-1.7x (exact anchor with tail)
803
+ n_expected = max(2, round(ratio))
804
+
805
+ if verbose:
806
+ print(f"{arr_info} #{mono_idx}: Long monomer {len(mono)}bp ({ratio:.1f}x), trying to split into {n_expected} parts")
807
+
808
+ # Try to find mutant anchors and split
809
+ parts = [mono]
810
+
811
+ for split_num in range(1, n_expected):
812
+ # Expected position of next anchor
813
+ expected_pos = split_num * median_length
814
+
815
+ # Search in the last (unsplit) part
816
+ current_part = parts[-1]
817
+
818
+ # Adjust expected_pos relative to current part
819
+ offset = sum(len(p) for p in parts[:-1])
820
+ rel_expected_pos = expected_pos - offset
821
+
822
+ # Skip if expected position is too close to edges
823
+ # For short repeats, allow position at exactly the expected spot
824
+ min_pos = len(cut_seq) // 2 # Allow some flexibility for short repeats
825
+ max_pos = len(current_part) - len(cut_seq) // 2
826
+ if rel_expected_pos < min_pos or rel_expected_pos > max_pos:
827
+ continue
828
+
829
+ # Find split position
830
+ split_pos = None
831
+
832
+ if median_length < 23:
833
+ # For short repeats, just split at median_length position
834
+ # No need to search for anchor - just use the expected position
835
+ if rel_expected_pos > 0 and rel_expected_pos < len(current_part):
836
+ split_pos = rel_expected_pos
837
+ elif ratio >= 1.7:
838
+ # For 2x, 3x: search for mutant anchor with edit distance
839
+ search_window = max(5, int(median_length * 0.15))
840
+ split_pos = find_mutant_anchor(
841
+ current_part,
842
+ cut_seq,
843
+ rel_expected_pos,
844
+ window=search_window,
845
+ max_dist=2
846
+ )
847
+ else:
848
+ # For 1.3-1.7x: split at median_length if monomer starts with exact anchor
849
+ # This handles "exact anchor + short tail" cases
850
+ if current_part.startswith(cut_seq) and rel_expected_pos >= len(cut_seq):
851
+ split_pos = rel_expected_pos
852
+
853
+ if split_pos is not None and split_pos > 0:
854
+ # Split at found position
855
+ part1 = current_part[:split_pos]
856
+ part2 = current_part[split_pos:]
857
+
858
+ # Check variance criterion: split only if it reduces total deviation
859
+ # Without split: |len(current_part) - median|
860
+ # With split: |len(part1) - median| + |len(part2) - median|
861
+ dev_no_split = abs(len(current_part) - median_length)
862
+ dev_with_split = abs(len(part1) - median_length) + abs(len(part2) - median_length)
863
+
864
+ if dev_with_split >= dev_no_split:
865
+ if verbose:
866
+ print(f"{arr_info} #{mono_idx}: Skip split at {split_pos}: deviation {dev_no_split} -> {dev_with_split} (no improvement)")
867
+ continue
868
+
869
+ # Replace last part with two new parts
870
+ parts[-1] = part1
871
+ parts.append(part2)
872
+ splits_made += 1
873
+
874
+ if verbose:
875
+ print(f"{arr_info} #{mono_idx}: Split at pos {split_pos}: {len(part1)}bp + {len(part2)}bp (dev {dev_no_split} -> {dev_with_split})")
876
+
877
+ result.extend(parts)
878
+
879
+ # Update for next iteration
880
+ total_splits += splits_made
881
+ current_decomposition = result
882
+
883
+ if splits_made == 0:
884
+ # No more splits possible
885
+ break
886
+
887
+ if verbose:
888
+ print(f"{arr_info} Iteration {iteration + 1}: {splits_made} splits, continuing...")
889
+
890
+ # Verify reconstruction
891
+ final_sequence = "".join(current_decomposition)
892
+ if final_sequence != original_sequence:
893
+ print(f"ERROR: Sequence changed during split_long_monomers!")
894
+ return decomposition
895
+
896
+ if verbose and total_splits > 0:
897
+ print(f"{arr_info} Total: {total_splits} splits in {iteration + 1} iteration(s)")
898
+
899
+ return current_decomposition
900
+
901
+
902
+ def split_duplicate_halves(decomposition, cut_seq, verbose=False, array_id=None):
903
+ """
904
+ Post-processing: split monomers that consist of two identical halves.
905
+
906
+ This catches cases like CCTAACCCTAAC which is actually CCTAAC + CCTAAC
907
+ (two rotated telomere units) that weren't caught by split_long_monomers
908
+ because CCTAAC is not within edit distance of TAACCC.
909
+
910
+ Iterates until no more splits can be made.
911
+
912
+ Args:
913
+ decomposition: List of monomer sequences
914
+ cut_seq: The cut/anchor sequence
915
+ verbose: Print debug info
916
+ array_id: Array identifier for logging
917
+
918
+ Returns:
919
+ New decomposition with duplicate-half monomers split
920
+ """
921
+ if len(decomposition) < 3:
922
+ return decomposition
923
+
924
+ # Calculate expected monomer length (median)
925
+ lengths = [len(mono) for mono in decomposition if mono.startswith(cut_seq)]
926
+ if not lengths:
927
+ return decomposition
928
+
929
+ median_length = sorted(lengths)[len(lengths) // 2]
930
+
931
+ # Only apply for short repeats where this pattern is common
932
+ if median_length > 24:
933
+ return decomposition
934
+
935
+ arr_info = f"[{array_id}] " if array_id else ""
936
+
937
+ # Save original for verification
938
+ original_sequence = "".join(decomposition)
939
+
940
+ # Iterate until no more splits
941
+ max_iterations = 10
942
+ total_splits = 0
943
+ current_decomposition = decomposition
944
+
945
+ for iteration in range(max_iterations):
946
+ result = []
947
+ splits_made = 0
948
+
949
+ for mono_idx, mono in enumerate(current_decomposition):
950
+ mono_len = len(mono)
951
+
952
+ # For short repeats, check ANY even-length monomer for duplicate halves
953
+ # This catches cases where median is 12bp but true unit is 6bp
954
+ if mono_len % 2 != 0:
955
+ result.append(mono)
956
+ continue
957
+
958
+ half_len = mono_len // 2
959
+ first_half = mono[:half_len]
960
+ second_half = mono[half_len:]
961
+
962
+ # Check if both halves are identical or nearly identical
963
+ if first_half == second_half:
964
+ # Perfect duplicate - split!
965
+ result.append(first_half)
966
+ result.append(second_half)
967
+ splits_made += 1
968
+
969
+ if verbose:
970
+ print(f"{arr_info} #{mono_idx}: Split duplicate halves: {mono_len}bp -> {half_len}bp + {half_len}bp ({first_half})")
971
+ else:
972
+ # Check edit distance for near-duplicates (allow 1 mismatch for sequences >= 8bp)
973
+ dist = ed.eval(first_half, second_half)
974
+ if dist <= 1 and half_len >= 4:
975
+ result.append(first_half)
976
+ result.append(second_half)
977
+ splits_made += 1
978
+
979
+ if verbose:
980
+ print(f"{arr_info} #{mono_idx}: Split near-duplicate halves (dist={dist}): {mono_len}bp -> {half_len}bp + {half_len}bp")
981
+ else:
982
+ result.append(mono)
983
+
984
+ total_splits += splits_made
985
+ current_decomposition = result
986
+
987
+ if splits_made == 0:
988
+ break
989
+
990
+ if verbose:
991
+ print(f"{arr_info} Duplicate halves iteration {iteration + 1}: {splits_made} splits")
992
+
993
+ # Verify reconstruction
994
+ final_sequence = "".join(current_decomposition)
995
+ if final_sequence != original_sequence:
996
+ print(f"ERROR: Sequence changed during split_duplicate_halves!")
997
+ return decomposition
998
+
999
+ if verbose and total_splits > 0:
1000
+ print(f"{arr_info} Total duplicate-half splits: {total_splits}")
1001
+
1002
+ return current_decomposition
1003
+
1004
+
1005
+ def split_by_popular_prefix(decomposition, cut_seq, verbose=False, array_id=None):
1006
+ """
1007
+ Post-processing: split monomers that start with the most popular monomer.
1008
+
1009
+ For short repeats, if a monomer starts with the most common monomer sequence
1010
+ but is longer, split it into (popular_monomer) + (remainder).
1011
+
1012
+ Example: If popular monomer is CCTAAC (6bp), then CCTAACCCTA (10bp)
1013
+ becomes CCTAAC (6bp) + CCTA (4bp).
1014
+
1015
+ Args:
1016
+ decomposition: List of monomer sequences
1017
+ cut_seq: The cut/anchor sequence
1018
+ verbose: Print debug info
1019
+ array_id: Array identifier for logging
1020
+
1021
+ Returns:
1022
+ New decomposition with prefix-matched monomers split
1023
+ """
1024
+ if len(decomposition) < 3:
1025
+ return decomposition
1026
+
1027
+ # Find the most common monomer
1028
+ mono_counts = Counter(decomposition)
1029
+ if not mono_counts:
1030
+ return decomposition
1031
+
1032
+ popular_mono, popular_count = mono_counts.most_common(1)[0]
1033
+ popular_len = len(popular_mono)
1034
+
1035
+ # Only apply for short repeats where this pattern is common
1036
+ if popular_len > 20:
1037
+ return decomposition
1038
+
1039
+ # Need significant majority to use as reference
1040
+ if popular_count < len(decomposition) * 0.3:
1041
+ return decomposition
1042
+
1043
+ arr_info = f"[{array_id}] " if array_id else ""
1044
+
1045
+ # Save original for verification
1046
+ original_sequence = "".join(decomposition)
1047
+
1048
+ result = []
1049
+ splits_made = 0
1050
+
1051
+ for mono_idx, mono in enumerate(decomposition):
1052
+ mono_len = len(mono)
1053
+
1054
+ # Skip if it's the popular monomer or shorter
1055
+ if mono_len <= popular_len:
1056
+ result.append(mono)
1057
+ continue
1058
+
1059
+ # Check if it starts with the popular monomer
1060
+ if mono.startswith(popular_mono):
1061
+ remainder = mono[popular_len:]
1062
+ remainder_len = len(remainder)
1063
+
1064
+ # Variance check: only split if it reduces total deviation
1065
+ # Before: |mono_len - popular_len|
1066
+ # After: |popular_len - popular_len| + |remainder_len - popular_len| = 0 + |remainder_len - popular_len|
1067
+ dev_before = abs(mono_len - popular_len)
1068
+ dev_after = abs(remainder_len - popular_len)
1069
+
1070
+ if dev_after < dev_before:
1071
+ # Split improves variance
1072
+ result.append(popular_mono)
1073
+ result.append(remainder)
1074
+ splits_made += 1
1075
+
1076
+ if verbose:
1077
+ print(f"{arr_info} #{mono_idx}: Split by prefix: {mono_len}bp -> {popular_len}bp + {remainder_len}bp (dev {dev_before} -> {dev_after})")
1078
+ else:
1079
+ # Split would make variance worse, keep as is
1080
+ result.append(mono)
1081
+ if verbose:
1082
+ print(f"{arr_info} #{mono_idx}: Skip prefix split: {mono_len}bp (dev {dev_before} -> {dev_after}, no improvement)")
1083
+ else:
1084
+ result.append(mono)
1085
+
1086
+ # Verify reconstruction
1087
+ final_sequence = "".join(result)
1088
+ if final_sequence != original_sequence:
1089
+ print(f"ERROR: Sequence changed during split_by_popular_prefix!")
1090
+ return decomposition
1091
+
1092
+ if verbose and splits_made > 0:
1093
+ print(f"{arr_info} Split {splits_made} monomers by popular prefix ({popular_mono})")
1094
+
1095
+ return result
1096
+
1097
+
1098
+ def decompose_array_iter1(array, best_cut_seq, best_period, verbose=True, array_id=None):
1099
+ """
1100
+ Decompose array using the cut sequence, ensuring perfect reconstruction.
1101
+ Cut sequence is the START of each monomer (except the first fragment).
1102
+ """
1103
+ repeats2count = Counter()
1104
+ decomposition = []
1105
+
1106
+ if not best_cut_seq or best_cut_seq not in array:
1107
+ # No cut sequence or not found, return whole array
1108
+ decomposition.append(array)
1109
+ repeats2count[array] = 1
1110
+ return decomposition, repeats2count
1111
+
1112
+ # Split by cut sequence
1113
+ parts = array.split(best_cut_seq)
1114
+
1115
+ # Build monomers: cut + following part
1116
+ # First part is a special case (flank)
1117
+ if parts[0]:
1118
+ # First fragment (before first cut) - this is a flank
1119
+ decomposition.append(parts[0])
1120
+ repeats2count[parts[0]] += 1
1121
+ if verbose:
1122
+ print(f"Flank: {len(parts[0])}bp")
1123
+
1124
+ # Process all other parts: cut + part = monomer
1125
+ for i in range(1, len(parts)):
1126
+ monomer = best_cut_seq + parts[i]
1127
+ decomposition.append(monomer)
1128
+ repeats2count[monomer] += 1
1129
+ if verbose:
1130
+ print(f"Monomer {i}: {len(monomer)}bp (cut {len(best_cut_seq)}bp + part {len(parts[i])}bp)")
1131
+
1132
+ # Don't merge here - we'll do optimization at the very end of the pipeline
1133
+
1134
+ # Verify reconstruction
1135
+ reconstructed = "".join(decomposition)
1136
+ if reconstructed != array:
1137
+ print(f"WARNING: Reconstruction mismatch! {len(array)} != {len(reconstructed)}")
1138
+ if array_id:
1139
+ print(f" Sequence ID: {array_id}")
1140
+ # Print array identifier for debugging
1141
+ array_preview = array[:50] + "..." if len(array) > 50 else array
1142
+ print(f" Array preview: {array_preview}")
1143
+ print(f" Cut sequence: '{best_cut_seq}'")
1144
+
1145
+ return decomposition, repeats2count
1146
+
1147
+
1148
+ ### Step 5b. Try to cut long monomers to expected
1149
+ def refine_repeat_odd(repeat, best_period, most_common_monomer, verbose=False):
1150
+ if len(repeat) / best_period > 1.3:
1151
+ n = len(most_common_monomer)
1152
+ optimal_cut = 0
1153
+ best_ed = n
1154
+
1155
+ begin_positions = [i for i in range(min(len(repeat) - n + 1, 5))]
1156
+ end_positions = [i for i in range(max(0, len(repeat) - n + 1 - 5), len(repeat) - n + 1)]
1157
+
1158
+ for i in begin_positions+end_positions:
1159
+ rep_b = repeat[i : i + n]
1160
+ dist = ed.eval(most_common_monomer, rep_b)
1161
+ if dist < best_ed:
1162
+ best_ed = dist
1163
+ optimal_cut = i
1164
+ if verbose:
1165
+ print(
1166
+ "Optimal cut",
1167
+ best_ed,
1168
+ optimal_cut,
1169
+ len(repeat[:optimal_cut]),
1170
+ len(repeat[optimal_cut:]),
1171
+ )
1172
+ if best_ed < n / 2:
1173
+ if optimal_cut == 0:
1174
+ optimal_cut += n
1175
+ a = repeat[:optimal_cut]
1176
+ b = repeat[optimal_cut:]
1177
+ if min(len(a), len(b)) < 0: # n/3:
1178
+ yield repeat
1179
+ else:
1180
+ if a:
1181
+ yield a
1182
+ if b:
1183
+ yield b
1184
+ else:
1185
+ yield repeat
1186
+ else:
1187
+ yield repeat
1188
+
1189
+
1190
+ def decompose_array_iter2(decomposition, best_period, repeats2count_ref, verbose=True):
1191
+ repeats2count = Counter()
1192
+ refined_decomposition = []
1193
+ most_common_monomer = None
1194
+ for monomer, tf in repeats2count_ref.most_common(1000):
1195
+ if len(monomer) == best_period:
1196
+ most_common_monomer = monomer
1197
+ break
1198
+ if not most_common_monomer:
1199
+ # No monomer with exact period length found
1200
+ # Try to find the most common monomer of any length
1201
+ if repeats2count_ref:
1202
+ most_common_monomer = repeats2count_ref.most_common(1)[0][0]
1203
+ else:
1204
+ # Use first monomer if nothing else available
1205
+ most_common_monomer = decomposition[0] if decomposition else ""
1206
+
1207
+ if verbose:
1208
+ print(f"No monomer of length {best_period} found, using '{most_common_monomer}' (len={len(most_common_monomer)})")
1209
+ for repeat in decomposition:
1210
+ if verbose:
1211
+ print("Repeat under consideration", len(repeat), repeat)
1212
+ for repeat in refine_repeat_odd(
1213
+ repeat, best_period, most_common_monomer, verbose=verbose
1214
+ ):
1215
+ if verbose:
1216
+ print("Added:", len(repeat), repeat)
1217
+ repeats2count[repeat] += 1
1218
+ refined_decomposition.append(repeat)
1219
+ return (
1220
+ refined_decomposition,
1221
+ repeats2count,
1222
+ len(refined_decomposition) != len(decomposition),
1223
+ )
1224
+
1225
+
1226
+ def print_monomers(decomposition, repeats2count, best_period):
1227
+ start2tf = Counter()
1228
+ for monomer in decomposition:
1229
+ start2tf[monomer[:5]] += 1
1230
+ print(start2tf)
1231
+
1232
+ most_common_monomer = None
1233
+ for monomer, tf in repeats2count.most_common(1000):
1234
+ if len(monomer) == best_period:
1235
+ most_common_monomer = monomer
1236
+ break
1237
+ assert most_common_monomer
1238
+ for repeat in decomposition:
1239
+ print(
1240
+ len(repeat),
1241
+ start2tf[repeat[:5]],
1242
+ repeat,
1243
+ ed.eval(repeat, most_common_monomer),
1244
+ )
1245
+
1246
+
1247
+ def print_pause_clean(decomposition, repeats2count, best_period):
1248
+ print_monomers(decomposition, repeats2count, best_period)
1249
+ input("?")
1250
+
1251
+
1252
+ # clear_output(wait=True)
1253
+
1254
+
1255
+ def decompose_array_with_cuts(array, cut_sequences, verbose=False, array_id=None):
1256
+ """
1257
+ Decompose array using predefined cut sequences.
1258
+ Tries each cut and selects the best one based on scoring.
1259
+ """
1260
+ if not cut_sequences:
1261
+ raise ValueError("No cut sequences provided")
1262
+
1263
+ # Try each cut sequence
1264
+ best_result = None
1265
+ best_score = -1
1266
+
1267
+ for cut_seq in cut_sequences:
1268
+ if cut_seq not in array:
1269
+ continue
1270
+
1271
+ # Split by this cut
1272
+ parts = array.split(cut_seq)
1273
+
1274
+ # Calculate score (same logic as compute_cuts)
1275
+ periods = []
1276
+ for i, part in enumerate(parts):
1277
+ if i < len(parts) - 1 or part:
1278
+ period = len(part) + len(cut_seq)
1279
+ periods.append(period)
1280
+
1281
+ if not periods:
1282
+ continue
1283
+
1284
+ # Calculate score
1285
+ period_counts = Counter(periods)
1286
+ mode_period, mode_count = period_counts.most_common(1)[0]
1287
+ total_segments = len(periods)
1288
+ base_score = mode_count / total_segments
1289
+
1290
+ # Fragmentation penalty
1291
+ short_threshold = mode_period * 0.5
1292
+ short_fragments = sum(1 for p in periods if p < short_threshold)
1293
+ fragmentation = short_fragments / total_segments
1294
+ adjusted_score = base_score * (1 - fragmentation * 0.5)
1295
+
1296
+ if adjusted_score > best_score:
1297
+ best_score = adjusted_score
1298
+ best_result = (cut_seq, base_score, mode_period, len(parts))
1299
+
1300
+ if best_result is None:
1301
+ # No cuts found, return whole array
1302
+ return [array], Counter({array: 1}), "", 0, len(array)
1303
+
1304
+ cut_seq, score, period, num_parts = best_result
1305
+
1306
+ # Decompose with best cut
1307
+ decomposition, repeats2count = decompose_array_iter1(
1308
+ array, cut_seq, period, verbose=verbose, array_id=array_id
1309
+ )
1310
+
1311
+ return decomposition, repeats2count, cut_seq, score, period
1312
+
1313
+
1314
+ def decompose_array(array, depth=500, cutoff=None, verbose=False, array_id=None):
1315
+ ### Step 0. Set cutoff based on array size if not provided
1316
+ if cutoff is None:
1317
+ if len(array) > 1_000_000:
1318
+ cutoff = 1000
1319
+ elif len(array) > 100_000:
1320
+ cutoff = 250
1321
+ elif len(array) > 10_000:
1322
+ cutoff = 10
1323
+ else:
1324
+ cutoff = 3
1325
+
1326
+ ### Step 1-3. Get hints from all nucleotides instead of just the most frequent
1327
+ all_hints = []
1328
+ hint_sources = {} # Track which nucleotide generated each hint
1329
+
1330
+ for nucleotide in "ACTG":
1331
+ # Get positions of this nucleotide
1332
+ positions = [i for i in range(len(array)) if array[i] == nucleotide]
1333
+
1334
+ if len(positions) <= cutoff:
1335
+ continue
1336
+
1337
+ # Build fs_tree for this nucleotide
1338
+ fs_tree = get_fs_tree(array, nucleotide, cutoff=cutoff)
1339
+
1340
+ # Get hints from this fs_tree
1341
+ for hint in iterate_hints(array, fs_tree, depth):
1342
+ all_hints.append(hint)
1343
+ hint_key = (hint[0], hint[1]) # (length, sequence)
1344
+ if hint_key not in hint_sources or hint[2] > hint_sources[hint_key][0]:
1345
+ hint_sources[hint_key] = (hint[2], nucleotide)
1346
+
1347
+ # Remove duplicates, keeping the one with highest frequency
1348
+ unique_hints = {}
1349
+ for length, sequence, freq in all_hints:
1350
+ key = (length, sequence)
1351
+ if key not in unique_hints or freq > unique_hints[key][2]:
1352
+ unique_hints[key] = (length, sequence, freq)
1353
+
1354
+ hints = list(unique_hints.values())
1355
+
1356
+ ### Step 4. PRIMARY: Try anchor graph decomposition first
1357
+ ### Anchor graph selects the cycle with minimum CV (best length uniformity)
1358
+ graph_result = decompose_with_anchor_graph(array, hints, verbose=verbose)
1359
+
1360
+ if graph_result is not None:
1361
+ decomposition, best_cut_seq, best_period, cv = graph_result
1362
+ if verbose:
1363
+ print(f" Using anchor graph decomposition: {len(decomposition)} monomers, period={best_period}, CV={cv:.3f}")
1364
+
1365
+ # Calculate best_cut_score as fraction of monomers starting with cut
1366
+ monomers_with_cut = sum(1 for m in decomposition if m.startswith(best_cut_seq))
1367
+ best_cut_score = monomers_with_cut / len(decomposition) if decomposition else 0
1368
+ repeats2count = Counter(decomposition)
1369
+
1370
+ return decomposition, repeats2count, best_cut_seq, best_cut_score, best_period
1371
+
1372
+ ### Step 5. FALLBACK: Use FS-tree decomposition if anchor graph fails
1373
+ if verbose:
1374
+ print(f" Anchor graph failed, falling back to FS-tree decomposition")
1375
+
1376
+ best_cut_seq, best_cut_score, best_period = compute_cuts(array, hints)
1377
+
1378
+ decomposition, repeats2count = decompose_array_iter1(
1379
+ array, best_cut_seq, best_period, verbose=verbose, array_id=array_id
1380
+ )
1381
+
1382
+ return decomposition, repeats2count, best_cut_seq, best_cut_score, best_period
1383
+
1384
+
1385
+ def get_candidates_from_hints(array, hints):
1386
+ """
1387
+ Convert hints to candidates list with scores (same logic as compute_cuts but return all).
1388
+ Used for anchor graph decomposition.
1389
+ """
1390
+ candidates = []
1391
+
1392
+ for L, cut_sequence, N in hints:
1393
+ parts = array.split(cut_sequence)
1394
+ periods = []
1395
+ non_empty_periods = []
1396
+
1397
+ for i, part in enumerate(parts):
1398
+ if i < len(parts) - 1 or part:
1399
+ period = len(part) + len(cut_sequence)
1400
+ periods.append(period)
1401
+ if len(part) > 0:
1402
+ non_empty_periods.append(period)
1403
+
1404
+ if not periods:
1405
+ continue
1406
+
1407
+ empty_ratio = (len(periods) - len(non_empty_periods)) / len(periods) if periods else 0
1408
+
1409
+ if empty_ratio >= 0.8:
1410
+ period_counts = Counter([len(cut_sequence)])
1411
+ mode_period = len(cut_sequence)
1412
+ mode_count = len(periods)
1413
+ total_segments = len(periods)
1414
+ elif non_empty_periods:
1415
+ period_counts = Counter(non_empty_periods)
1416
+ mode_period, mode_count = period_counts.most_common(1)[0]
1417
+ total_segments = len(non_empty_periods)
1418
+ else:
1419
+ period_counts = Counter(periods)
1420
+ mode_period, mode_count = period_counts.most_common(1)[0]
1421
+ total_segments = len(periods)
1422
+
1423
+ base_score = mode_count / total_segments
1424
+ short_threshold = mode_period * 0.5
1425
+ short_fragments = sum(1 for p in periods if p < short_threshold)
1426
+ fragmentation = short_fragments / total_segments
1427
+ adjusted_score = base_score * (1 - fragmentation * 0.5)
1428
+
1429
+ candidates.append({
1430
+ 'cut': cut_sequence,
1431
+ 'length': L,
1432
+ 'frequency': N,
1433
+ 'mode_period': mode_period,
1434
+ 'base_score': base_score,
1435
+ 'adjusted_score': adjusted_score,
1436
+ 'fragmentation': fragmentation,
1437
+ 'num_segments': total_segments,
1438
+ })
1439
+
1440
+ return candidates
1441
+
1442
+
1443
+ def get_all_hints_for_graph(array, depth=100, cutoff=3):
1444
+ """
1445
+ Get hints from FS-tree for all nucleotides with small cutoff.
1446
+ Used for anchor graph analysis to find longer/rarer anchors.
1447
+ """
1448
+ all_hints = []
1449
+
1450
+ for nucleotide in "ACTG":
1451
+ positions = [i for i in range(len(array)) if array[i] == nucleotide]
1452
+
1453
+ if len(positions) <= cutoff:
1454
+ continue
1455
+
1456
+ fs_tree = get_fs_tree(array, nucleotide, cutoff=cutoff)
1457
+
1458
+ for hint in iterate_hints(array, fs_tree, depth):
1459
+ all_hints.append(hint)
1460
+
1461
+ # Remove duplicates, keeping highest frequency
1462
+ unique = {}
1463
+ for length, anchor, freq in all_hints:
1464
+ key = (length, anchor)
1465
+ if key not in unique or freq > unique[key][2]:
1466
+ unique[key] = (length, anchor, freq)
1467
+
1468
+ return list(unique.values())
1469
+
1470
+
1471
+ def decompose_with_anchor_graph(array, hints, verbose=False):
1472
+ """
1473
+ Primary decomposition method using anchor graph.
1474
+
1475
+ Anchor graph is built from FS-tree hints and selects the cycle
1476
+ with minimum CV (best length uniformity).
1477
+
1478
+ Returns:
1479
+ (decomposition, cut_seq, period, cv) or None if failed
1480
+ """
1481
+ # For large sequences, get more hints with smaller cutoff
1482
+ if len(array) > 10000:
1483
+ graph_hints = get_all_hints_for_graph(array, depth=100, cutoff=3)
1484
+ if len(graph_hints) > len(hints):
1485
+ hints = graph_hints
1486
+
1487
+ # Build candidates from hints
1488
+ candidates = get_candidates_from_hints(array, hints)
1489
+
1490
+ if not candidates:
1491
+ return None
1492
+
1493
+ # Build anchor graph
1494
+ decomposer = AnchorGraphDecomposer()
1495
+ decomposer.build_from_candidates(array, candidates, top_k=15, verbose=verbose)
1496
+
1497
+ stats = decomposer.get_stats()
1498
+ cycle = stats['cycle']
1499
+ graph_period = stats['estimated_monomer_length']
1500
+
1501
+ if not cycle or graph_period < 10:
1502
+ return None
1503
+
1504
+ # Decompose using graph
1505
+ graph_decomposition = decomposer.decompose(verbose=verbose)
1506
+
1507
+ # Verify reconstruction
1508
+ reconstructed = "".join(graph_decomposition)
1509
+ if reconstructed != array:
1510
+ if verbose:
1511
+ print(f" Graph reconstruction failed")
1512
+ return None
1513
+
1514
+ # Calculate CV of resulting decomposition
1515
+ cut_seq = cycle[0]
1516
+ lengths = [len(m) for m in graph_decomposition if m.startswith(cut_seq)]
1517
+
1518
+ if len(lengths) < 2:
1519
+ return None
1520
+
1521
+ mean_len = sum(lengths) / len(lengths)
1522
+ variance = sum((x - mean_len) ** 2 for x in lengths) / len(lengths)
1523
+ cv = (variance ** 0.5) / mean_len if mean_len > 0 else float('inf')
1524
+
1525
+ if verbose:
1526
+ print(f" Graph result: {len(graph_decomposition)} monomers, period={graph_period:.0f}, CV={cv:.3f}")
1527
+
1528
+ return graph_decomposition, cut_seq, int(graph_period), cv
1529
+
1530
+
1531
+ def check_and_fix_overcutting(array, decomposition, best_cut_seq, best_period, hints, verbose=False):
1532
+ """
1533
+ DEPRECATED: This function is kept for backwards compatibility.
1534
+ The anchor graph is now the primary decomposition method.
1535
+ """
1536
+ # This is now a no-op since graph is applied first
1537
+ return decomposition, best_cut_seq, best_period, False
1538
+
1539
+
1540
+ def get_array_generator(input_file, format):
1541
+ '''Get array generator by format.'''
1542
+ if format == "fasta":
1543
+ return sc_iter_fasta_file(input_file)
1544
+ if format == "trf":
1545
+ return sc_iter_trf_file(input_file)
1546
+ if format == "satellome":
1547
+ return sc_iter_satellome_file(input_file)
1548
+
1549
+ print(f"Unknown format: {format}")
1550
+ exit(1)
1551
+
1552
+
1553
+ def main(input_file, output_prefix, format, threads, predefined_cuts=None, depth=100, verbose=False):
1554
+ """Main function."""
1555
+
1556
+ sequences = get_array_generator(input_file, format)
1557
+ total = 0
1558
+ for _ in sequences:
1559
+ total += 1
1560
+ sequences = get_array_generator(input_file, format)
1561
+
1562
+ print(f"Start processing")
1563
+ if predefined_cuts:
1564
+ print(f"Using predefined cuts: {', '.join(predefined_cuts)}")
1565
+ else:
1566
+ print(f"Will discover cuts automatically (depth={depth})")
1567
+
1568
+ if output_prefix.endswith(".fasta"):
1569
+ print("Remove .fasta from output prefix")
1570
+ output_prefix = output_prefix[:-6]
1571
+ elif output_prefix.endswith(".fa"):
1572
+ print("Remove .fa from output prefix")
1573
+ output_prefix = output_prefix[:-3]
1574
+
1575
+ output_file = f"{output_prefix}.decomposed.fasta"
1576
+ detail_file = f"{output_prefix}.monomers.tsv"
1577
+ lengths_file = f"{output_prefix}.lengths"
1578
+ print(f"Output file: {output_file}")
1579
+ print(f"Detail file: {detail_file}")
1580
+ print(f"Lengths file: {lengths_file}")
1581
+
1582
+ # Open all output files
1583
+ with open(output_file, "w") as fw, open(detail_file, "w") as fw_detail, open(lengths_file, "w") as fw_lengths:
1584
+ # Write header for detail file
1585
+ fw_detail.write("sequence_id\torientation\tindex\ttype\tlength\tis_flank\tsequence\n")
1586
+
1587
+ for header, array in tqdm(sequences, total=total):
1588
+ # Check canonical orientation
1589
+ is_canonical = get_canonical_orientation(array)
1590
+ was_reversed = False
1591
+
1592
+ if not is_canonical:
1593
+ # Need reverse complement
1594
+ array = get_revcomp(array)
1595
+ was_reversed = True
1596
+
1597
+ # print(len(array), end=" ")
1598
+ # Use predefined cuts or discover automatically
1599
+ if predefined_cuts:
1600
+ (
1601
+ decomposition,
1602
+ repeats2count,
1603
+ best_cut_seq,
1604
+ best_cut_score,
1605
+ best_period,
1606
+ ) = decompose_array_with_cuts(array, predefined_cuts, verbose=verbose, array_id=header)
1607
+ else:
1608
+ # cutoff will be set automatically based on array size
1609
+ (
1610
+ decomposition,
1611
+ repeats2count,
1612
+ best_cut_seq,
1613
+ best_cut_score,
1614
+ best_period,
1615
+ ) = decompose_array(array, depth=depth, cutoff=None, verbose=verbose, array_id=header)
1616
+
1617
+ # Verify initial decomposition before any modifications
1618
+ reconstructed = "".join(decomposition)
1619
+ if reconstructed != array:
1620
+ print(f"FATAL ERROR: Initial decomposition does not match original sequence!")
1621
+ print(f" Array ID: {header}")
1622
+ print(f" Original length: {len(array)}")
1623
+ print(f" Reconstructed length: {len(reconstructed)}")
1624
+ print(f" Cut sequence: {best_cut_seq}")
1625
+ for i in range(min(len(array), len(reconstructed))):
1626
+ if array[i] != reconstructed[i]:
1627
+ print(f" First mismatch at position {i}")
1628
+ print(f" Original: ...{array[max(0,i-10):i+20]}...")
1629
+ print(f" Reconstructed: ...{reconstructed[max(0,i-10):i+20]}...")
1630
+ break
1631
+ raise ValueError(f"Initial decomposition verification failed for {header}")
1632
+
1633
+ # Note: We do NOT rotate monomers internally - that would change the sequence.
1634
+ # "Rotation" means choosing a different cut point, not circular shifting within monomers.
1635
+ # decomposition = rotate_monomers_to_cut(decomposition, best_cut_seq) # REMOVED - was breaking sequence
1636
+
1637
+ # Detect and fix A-B alternating pattern (before other optimizations)
1638
+ decomposition, ab_merged = detect_and_fix_ab_pattern(decomposition, best_cut_seq, verbose=verbose)
1639
+ if ab_merged:
1640
+ # Recount monomers after A-B merge
1641
+ repeats2count = Counter(decomposition)
1642
+
1643
+ # Apply post-processing optimization to merge short frequent monomers
1644
+ decomposition = optimize_monomer_lengths(decomposition, best_cut_seq, verbose=verbose, array_id=header)
1645
+
1646
+ # Split monomers that are duplicate halves (e.g., CCTAACCCTAAC -> CCTAAC + CCTAAC)
1647
+ # Do this FIRST to establish correct median length before splitting long monomers
1648
+ decomposition = split_duplicate_halves(decomposition, best_cut_seq, verbose=verbose, array_id=header)
1649
+
1650
+ # Split long monomers where anchor has mutation (x2, x3 longer than expected)
1651
+ # Now median is correct (e.g., 6bp instead of 12bp), so 11bp, 17bp etc. will be split
1652
+ decomposition = split_long_monomers(decomposition, best_cut_seq, verbose=verbose, array_id=header)
1653
+
1654
+ # Final polish: split monomers that start with popular monomer
1655
+ # Example: CCTAACCCTA -> CCTAAC + CCTA if CCTAAC is the most common
1656
+ decomposition = split_by_popular_prefix(decomposition, best_cut_seq, verbose=verbose, array_id=header)
1657
+
1658
+ # Final verification: decomposition must reconstruct original sequence exactly
1659
+ final_seq = "".join(decomposition)
1660
+ if final_seq != array:
1661
+ print(f"FATAL ERROR: Decomposition does not match original sequence!")
1662
+ print(f" Array ID: {header}")
1663
+ print(f" Original length: {len(array)}, Reconstructed length: {len(final_seq)}")
1664
+ for i in range(min(len(array), len(final_seq))):
1665
+ if array[i] != final_seq[i]:
1666
+ print(f" First mismatch at position {i}")
1667
+ print(f" Original: ...{array[max(0,i-10):i+20]}...")
1668
+ print(f" Reconstructed: ...{final_seq[max(0,i-10):i+20]}...")
1669
+ break
1670
+ raise ValueError(f"Decomposition verification failed for {header}")
1671
+
1672
+ # Calculate statistics for internal monomers (excluding flanks)
1673
+ internal_monomers = []
1674
+ all_monomer_lengths = []
1675
+
1676
+ # First pass: collect all potential monomer lengths
1677
+ for i, monomer in enumerate(decomposition):
1678
+ if monomer.startswith(best_cut_seq):
1679
+ all_monomer_lengths.append(len(monomer))
1680
+
1681
+ # Calculate average to better identify flanks
1682
+ if len(all_monomer_lengths) > 1:
1683
+ avg_monomer_len = sum(all_monomer_lengths) / len(all_monomer_lengths)
1684
+ flank_threshold = avg_monomer_len * 0.7 # 70% of average
1685
+ else:
1686
+ flank_threshold = best_period * 0.5
1687
+
1688
+ # Second pass: identify true internal monomers
1689
+ for i, monomer in enumerate(decomposition):
1690
+ if monomer.startswith(best_cut_seq):
1691
+ # Check if it's the last piece and too short (right flank)
1692
+ if i == len(decomposition) - 1 and len(monomer) < flank_threshold:
1693
+ continue # Skip right flank
1694
+ internal_monomers.append(monomer)
1695
+
1696
+ if internal_monomers:
1697
+ internal_lengths = [len(m) for m in internal_monomers]
1698
+ min_len = min(internal_lengths)
1699
+ max_len = max(internal_lengths)
1700
+ avg_len = sum(internal_lengths) / len(internal_lengths)
1701
+ orientation = "rev" if was_reversed else "fwd"
1702
+ header_info = f"{header} cut={best_cut_seq} orientation={orientation} n_monomers={len(internal_monomers)} range={min_len}-{max_len} avg={avg_len:.1f}"
1703
+ else:
1704
+ orientation = "rev" if was_reversed else "fwd"
1705
+ header_info = f"{header} cut={best_cut_seq} orientation={orientation} n_monomers=0"
1706
+
1707
+ fw.write(f">{header_info}\n")
1708
+ fw.write(" ".join(decomposition) + "\n")
1709
+
1710
+ # Write lengths file
1711
+ fw_lengths.write(f">{header_info}\n")
1712
+ lengths = [str(len(m)) for m in decomposition]
1713
+ fw_lengths.write(" ".join(lengths) + "\n")
1714
+
1715
+ # Write detailed monomer information
1716
+ for i, monomer in enumerate(decomposition):
1717
+ # LEFT_FLANK: only first fragment if it doesn't start with cut
1718
+ if i == 0 and not monomer.startswith(best_cut_seq):
1719
+ piece_type = "LEFT_FLANK"
1720
+ is_flank = "TRUE"
1721
+ # RIGHT_FLANK: only last fragment if too short
1722
+ elif i == len(decomposition) - 1 and len(monomer) < flank_threshold:
1723
+ piece_type = "RIGHT_FLANK"
1724
+ is_flank = "TRUE"
1725
+ else:
1726
+ # Everything else is MONOMER (including mutant monomers without exact cut)
1727
+ piece_type = "MONOMER"
1728
+ is_flank = "FALSE"
1729
+
1730
+ orientation = "rev" if was_reversed else "fwd"
1731
+ fw_detail.write(f"{header}\t{orientation}\t{i}\t{piece_type}\t{len(monomer)}\t{is_flank}\t{monomer}\n")
1732
+
1733
+
1734
+ def run_it():
1735
+ parser = argparse.ArgumentParser(
1736
+ description="De novo decomposition of satellite DNA arrays into monomers"
1737
+ )
1738
+ parser.add_argument("-i", "--input", help="Input file", required=True)
1739
+ parser.add_argument(
1740
+ "--format",
1741
+ help="Input format: fasta, trf [fasta]",
1742
+ required=False,
1743
+ default="fasta",
1744
+ )
1745
+ parser.add_argument("-o", "--output", help="Output prefix", required=True)
1746
+ parser.add_argument(
1747
+ "-t", "--threads", help="Number of threads", required=False, default=4
1748
+ )
1749
+ parser.add_argument(
1750
+ "-c", "--cuts",
1751
+ help="Comma-separated list of predefined cut sequences (e.g., ATG,ATGATG). If provided, skips cut discovery.",
1752
+ required=False,
1753
+ default=None
1754
+ )
1755
+ parser.add_argument(
1756
+ "-d", "--depth",
1757
+ help="Depth for hint discovery (default: 100)",
1758
+ required=False,
1759
+ type=int,
1760
+ default=100
1761
+ )
1762
+ parser.add_argument(
1763
+ "-v", "--verbose",
1764
+ help="Verbose output",
1765
+ action="store_true"
1766
+ )
1767
+ args = parser.parse_args()
1768
+
1769
+ input_file = args.input
1770
+ output_prefix = args.output
1771
+ format = args.format
1772
+ threads = int(args.threads)
1773
+ predefined_cuts = args.cuts.split(',') if args.cuts else None
1774
+ depth = args.depth
1775
+ verbose = args.verbose
1776
+
1777
+ if not os.path.isfile(input_file):
1778
+ print(f"File {input_file} not found")
1779
+ exit(1)
1780
+
1781
+ main(input_file, output_prefix, format, threads, predefined_cuts, depth, verbose)
1782
+
1783
+ if __name__ == "__main__":
1784
+ run_it()