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.
- ArraySplitter/__init__.py +10 -0
- ArraySplitter/classify.py +519 -0
- ArraySplitter/core_functions/__init__.py +0 -0
- ArraySplitter/core_functions/io/__init__.py +0 -0
- ArraySplitter/core_functions/io/block_file.py +156 -0
- ArraySplitter/core_functions/io/fasta_reader.py +51 -0
- ArraySplitter/core_functions/io/satellome_reader.py +20 -0
- ArraySplitter/core_functions/io/tab_file.py +200 -0
- ArraySplitter/core_functions/io/trf_reader.py +79 -0
- ArraySplitter/core_functions/models/__init__.py +0 -0
- ArraySplitter/core_functions/models/trf_model.py +332 -0
- ArraySplitter/core_functions/tools/__init__.py +0 -0
- ArraySplitter/core_functions/tools/anchor_graph.py +726 -0
- ArraySplitter/core_functions/tools/fs_tree.py +210 -0
- ArraySplitter/core_functions/tools/parsers.py +230 -0
- ArraySplitter/core_functions/tools/rotation.py +99 -0
- ArraySplitter/core_functions/tools/sequences.py +20 -0
- ArraySplitter/decompose.py +1784 -0
- ArraySplitter/extract.py +54 -0
- ArraySplitter/main.py +191 -0
- ArraySplitter/rotate.py +35 -0
- arraysplitter-1.3.0.dist-info/METADATA +272 -0
- arraysplitter-1.3.0.dist-info/RECORD +27 -0
- arraysplitter-1.3.0.dist-info/WHEEL +5 -0
- arraysplitter-1.3.0.dist-info/entry_points.txt +2 -0
- arraysplitter-1.3.0.dist-info/licenses/LICENSE +21 -0
- arraysplitter-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|