ArraySplitter 1.3.0__py3-none-any.whl → 1.4.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 +1 -1
- ArraySplitter/indel_chromosome_analysis.py +242 -0
- ArraySplitter/main.py +94 -3
- ArraySplitter/monomer_rotation.py +262 -0
- ArraySplitter/msa_refine.py +273 -0
- ArraySplitter/refine.py +389 -0
- {arraysplitter-1.3.0.dist-info → arraysplitter-1.4.0.dist-info}/METADATA +1 -1
- {arraysplitter-1.3.0.dist-info → arraysplitter-1.4.0.dist-info}/RECORD +12 -8
- {arraysplitter-1.3.0.dist-info → arraysplitter-1.4.0.dist-info}/WHEEL +0 -0
- {arraysplitter-1.3.0.dist-info → arraysplitter-1.4.0.dist-info}/entry_points.txt +0 -0
- {arraysplitter-1.3.0.dist-info → arraysplitter-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {arraysplitter-1.3.0.dist-info → arraysplitter-1.4.0.dist-info}/top_level.txt +0 -0
ArraySplitter/__init__.py
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Indel Chromosome Specificity Analysis.
|
|
5
|
+
|
|
6
|
+
Analyzes how chromosome-specific indels are in MSA alignments.
|
|
7
|
+
For each indel, calculates:
|
|
8
|
+
- Which chromosomes contain sequences with this indel
|
|
9
|
+
- What fraction of sequences from each chromosome have the indel
|
|
10
|
+
- Overall specificity score
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from typing import List, Tuple, Dict, Set
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
import re
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Indel:
|
|
23
|
+
start: int
|
|
24
|
+
end: int
|
|
25
|
+
length: int
|
|
26
|
+
|
|
27
|
+
def __hash__(self):
|
|
28
|
+
return hash((self.start, self.end))
|
|
29
|
+
|
|
30
|
+
def __eq__(self, other):
|
|
31
|
+
return self.start == other.start and self.end == other.end
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_fasta(filepath: str) -> List[Tuple[str, str]]:
|
|
35
|
+
"""Parse FASTA file."""
|
|
36
|
+
sequences = []
|
|
37
|
+
with open(filepath) as f:
|
|
38
|
+
name = ""
|
|
39
|
+
seq = ""
|
|
40
|
+
for line in f:
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if line.startswith(">"):
|
|
43
|
+
if seq:
|
|
44
|
+
sequences.append((name, seq))
|
|
45
|
+
name = line[1:].split()[0]
|
|
46
|
+
seq = ""
|
|
47
|
+
else:
|
|
48
|
+
seq += line
|
|
49
|
+
if seq:
|
|
50
|
+
sequences.append((name, seq))
|
|
51
|
+
return sequences
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_chromosome(name: str) -> str:
|
|
55
|
+
"""Extract chromosome name from sequence header."""
|
|
56
|
+
# Pattern: chr1_mat:123-456|... or chr1A_pat:...
|
|
57
|
+
match = re.match(r'(chr[^:]+)', name)
|
|
58
|
+
if match:
|
|
59
|
+
return match.group(1)
|
|
60
|
+
return "unknown"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_indels_in_sequence(seq: str) -> List[Indel]:
|
|
64
|
+
"""Find all indel regions in a sequence."""
|
|
65
|
+
indels = []
|
|
66
|
+
in_gap = False
|
|
67
|
+
gap_start = 0
|
|
68
|
+
|
|
69
|
+
for i, base in enumerate(seq):
|
|
70
|
+
if base == '-':
|
|
71
|
+
if not in_gap:
|
|
72
|
+
in_gap = True
|
|
73
|
+
gap_start = i
|
|
74
|
+
else:
|
|
75
|
+
if in_gap:
|
|
76
|
+
indels.append(Indel(start=gap_start, end=i, length=i - gap_start))
|
|
77
|
+
in_gap = False
|
|
78
|
+
|
|
79
|
+
if in_gap:
|
|
80
|
+
indels.append(Indel(start=gap_start, end=len(seq), length=len(seq) - gap_start))
|
|
81
|
+
|
|
82
|
+
return indels
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyze_chromosome_specificity(input_fasta: str, output_tsv: str, output_json: str,
|
|
86
|
+
min_count: int = 3, verbose: bool = False) -> Dict:
|
|
87
|
+
"""
|
|
88
|
+
Analyze chromosome specificity of indels.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
input_fasta: Aligned FASTA file
|
|
92
|
+
output_tsv: Output TSV with detailed analysis
|
|
93
|
+
output_json: Output JSON for HTML report
|
|
94
|
+
min_count: Minimum sequence count for indel to be included
|
|
95
|
+
verbose: Print progress
|
|
96
|
+
"""
|
|
97
|
+
sequences = parse_fasta(input_fasta)
|
|
98
|
+
n_seqs = len(sequences)
|
|
99
|
+
|
|
100
|
+
if verbose:
|
|
101
|
+
print(f"Loaded {n_seqs} sequences")
|
|
102
|
+
|
|
103
|
+
# Count sequences per chromosome
|
|
104
|
+
chr_seq_counts: Dict[str, int] = defaultdict(int)
|
|
105
|
+
for name, _ in sequences:
|
|
106
|
+
chrom = extract_chromosome(name)
|
|
107
|
+
chr_seq_counts[chrom] += 1
|
|
108
|
+
|
|
109
|
+
if verbose:
|
|
110
|
+
print(f"Found {len(chr_seq_counts)} chromosomes")
|
|
111
|
+
|
|
112
|
+
# Map indels to chromosomes and sequences
|
|
113
|
+
indel_to_chr_seqs: Dict[Indel, Dict[str, List[str]]] = defaultdict(lambda: defaultdict(list))
|
|
114
|
+
|
|
115
|
+
for name, seq in sequences:
|
|
116
|
+
chrom = extract_chromosome(name)
|
|
117
|
+
indels = find_indels_in_sequence(seq)
|
|
118
|
+
for indel in indels:
|
|
119
|
+
indel_to_chr_seqs[indel][chrom].append(name)
|
|
120
|
+
|
|
121
|
+
# Analyze each indel
|
|
122
|
+
results = []
|
|
123
|
+
|
|
124
|
+
for indel, chr_seqs in indel_to_chr_seqs.items():
|
|
125
|
+
total_count = sum(len(seqs) for seqs in chr_seqs.values())
|
|
126
|
+
|
|
127
|
+
# Skip rare indels
|
|
128
|
+
if total_count < min_count:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Calculate per-chromosome statistics
|
|
132
|
+
chr_stats = []
|
|
133
|
+
for chrom, seq_names in chr_seqs.items():
|
|
134
|
+
count_in_chr = len(seq_names)
|
|
135
|
+
total_in_chr = chr_seq_counts[chrom]
|
|
136
|
+
fraction = count_in_chr / total_in_chr if total_in_chr > 0 else 0
|
|
137
|
+
chr_stats.append({
|
|
138
|
+
"chr": chrom,
|
|
139
|
+
"count": count_in_chr,
|
|
140
|
+
"total": total_in_chr,
|
|
141
|
+
"fraction": fraction
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
# Sort by fraction descending
|
|
145
|
+
chr_stats.sort(key=lambda x: -x["fraction"])
|
|
146
|
+
|
|
147
|
+
# Calculate specificity metrics
|
|
148
|
+
n_chromosomes = len(chr_stats)
|
|
149
|
+
max_fraction = chr_stats[0]["fraction"] if chr_stats else 0
|
|
150
|
+
|
|
151
|
+
# Specificity score: high if indel is concentrated in few chromosomes
|
|
152
|
+
# If indel appears in all sequences of 1-2 chromosomes, it's highly specific
|
|
153
|
+
top_chr_contribution = sum(s["count"] for s in chr_stats[:2]) / total_count if total_count > 0 else 0
|
|
154
|
+
|
|
155
|
+
# Is this indel chromosome-specific?
|
|
156
|
+
# Criteria: >80% of occurrences from top 2 chromosomes AND top chr has >50% of its sequences with indel
|
|
157
|
+
is_chr_specific = top_chr_contribution > 0.8 and max_fraction > 0.5
|
|
158
|
+
|
|
159
|
+
results.append({
|
|
160
|
+
"start": indel.start,
|
|
161
|
+
"end": indel.end,
|
|
162
|
+
"length": indel.length,
|
|
163
|
+
"total_count": total_count,
|
|
164
|
+
"frequency": total_count / n_seqs,
|
|
165
|
+
"n_chromosomes": n_chromosomes,
|
|
166
|
+
"max_fraction": max_fraction,
|
|
167
|
+
"top_chr_contribution": top_chr_contribution,
|
|
168
|
+
"is_chr_specific": is_chr_specific,
|
|
169
|
+
"chr_stats": chr_stats
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
# Sort by total count descending
|
|
173
|
+
results.sort(key=lambda x: -x["total_count"])
|
|
174
|
+
|
|
175
|
+
# Summary statistics
|
|
176
|
+
n_total_indels = len(results)
|
|
177
|
+
n_chr_specific = sum(1 for r in results if r["is_chr_specific"])
|
|
178
|
+
|
|
179
|
+
# Write TSV
|
|
180
|
+
with open(output_tsv, 'w') as f:
|
|
181
|
+
f.write("# Indel Chromosome Specificity Analysis\n")
|
|
182
|
+
f.write(f"# Input: {input_fasta}\n")
|
|
183
|
+
f.write(f"# Total sequences: {n_seqs}\n")
|
|
184
|
+
f.write(f"# Chromosomes: {len(chr_seq_counts)}\n")
|
|
185
|
+
f.write(f"# Indels analyzed (count >= {min_count}): {n_total_indels}\n")
|
|
186
|
+
f.write(f"# Chromosome-specific indels: {n_chr_specific} ({n_chr_specific/n_total_indels*100:.1f}%)\n")
|
|
187
|
+
f.write("#\n")
|
|
188
|
+
f.write("start\tend\tlength\ttotal_count\tfrequency\tn_chromosomes\tmax_chr_fraction\ttop2_contribution\tis_specific\ttop_chromosomes\n")
|
|
189
|
+
|
|
190
|
+
for r in results:
|
|
191
|
+
top_chrs = ", ".join(f"{s['chr']}({s['count']}/{s['total']}={s['fraction']*100:.0f}%)"
|
|
192
|
+
for s in r["chr_stats"][:5])
|
|
193
|
+
f.write(f"{r['start']}\t{r['end']}\t{r['length']}\t{r['total_count']}\t"
|
|
194
|
+
f"{r['frequency']*100:.2f}%\t{r['n_chromosomes']}\t{r['max_fraction']*100:.1f}%\t"
|
|
195
|
+
f"{r['top_chr_contribution']*100:.1f}%\t{'YES' if r['is_chr_specific'] else 'no'}\t{top_chrs}\n")
|
|
196
|
+
|
|
197
|
+
# Write JSON for HTML report
|
|
198
|
+
json_data = {
|
|
199
|
+
"summary": {
|
|
200
|
+
"total_sequences": n_seqs,
|
|
201
|
+
"n_chromosomes": len(chr_seq_counts),
|
|
202
|
+
"n_indels_analyzed": n_total_indels,
|
|
203
|
+
"n_chr_specific": n_chr_specific,
|
|
204
|
+
"pct_chr_specific": round(n_chr_specific / n_total_indels * 100, 1) if n_total_indels > 0 else 0
|
|
205
|
+
},
|
|
206
|
+
"chromosome_counts": dict(chr_seq_counts),
|
|
207
|
+
"indels": results[:50] # Top 50 for HTML
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
with open(output_json, 'w') as f:
|
|
211
|
+
json.dump(json_data, f, indent=2)
|
|
212
|
+
|
|
213
|
+
if verbose:
|
|
214
|
+
print(f"\nResults:")
|
|
215
|
+
print(f" Indels analyzed: {n_total_indels}")
|
|
216
|
+
print(f" Chromosome-specific: {n_chr_specific} ({n_chr_specific/n_total_indels*100:.1f}%)")
|
|
217
|
+
print(f"\nOutput: {output_tsv}, {output_json}")
|
|
218
|
+
|
|
219
|
+
return json_data
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main():
|
|
223
|
+
parser = argparse.ArgumentParser(description="Analyze chromosome specificity of indels")
|
|
224
|
+
parser.add_argument("-i", "--input", required=True, help="Aligned FASTA file")
|
|
225
|
+
parser.add_argument("-o", "--output", required=True, help="Output prefix")
|
|
226
|
+
parser.add_argument("-m", "--min-count", type=int, default=3,
|
|
227
|
+
help="Minimum count for indel to be included (default: 3)")
|
|
228
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
|
229
|
+
|
|
230
|
+
args = parser.parse_args()
|
|
231
|
+
|
|
232
|
+
analyze_chromosome_specificity(
|
|
233
|
+
input_fasta=args.input,
|
|
234
|
+
output_tsv=f"{args.output}.chr_specificity.tsv",
|
|
235
|
+
output_json=f"{args.output}.chr_specificity.json",
|
|
236
|
+
min_count=args.min_count,
|
|
237
|
+
verbose=args.verbose
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
main()
|
ArraySplitter/main.py
CHANGED
|
@@ -31,6 +31,12 @@ Examples:
|
|
|
31
31
|
# Extract unique monomers
|
|
32
32
|
arraysplitter extract -i decomposed.fa -o monomers
|
|
33
33
|
|
|
34
|
+
# Refine decomposition (split merged, join fragments)
|
|
35
|
+
arraysplitter refine -i decomposed.fa -o refined.fa
|
|
36
|
+
|
|
37
|
+
# Refine MSA by removing sequences with rare indels
|
|
38
|
+
arraysplitter msa_refine -i aligned.fa -o cleaned
|
|
39
|
+
|
|
34
40
|
For help on specific commands:
|
|
35
41
|
arraysplitter <command> -h
|
|
36
42
|
"""
|
|
@@ -122,7 +128,51 @@ For help on specific commands:
|
|
|
122
128
|
type=int,
|
|
123
129
|
default=1
|
|
124
130
|
)
|
|
125
|
-
|
|
131
|
+
|
|
132
|
+
# Refine command
|
|
133
|
+
parser_refine = subparsers.add_parser(
|
|
134
|
+
'refine',
|
|
135
|
+
help='Refine decomposition: split merged monomers, join fragments'
|
|
136
|
+
)
|
|
137
|
+
parser_refine.add_argument("-i", "--input", help="Input .monomers.tsv file", required=True)
|
|
138
|
+
parser_refine.add_argument("-o", "--output", help="Output .monomers.tsv file (refined)", required=True)
|
|
139
|
+
parser_refine.add_argument(
|
|
140
|
+
"-l", "--length",
|
|
141
|
+
help="Expected monomer length (auto-detected if not specified)",
|
|
142
|
+
type=int,
|
|
143
|
+
default=None
|
|
144
|
+
)
|
|
145
|
+
parser_refine.add_argument(
|
|
146
|
+
"-m", "--mismatches",
|
|
147
|
+
help="Maximum mismatches for cut sequence matching (default: 3)",
|
|
148
|
+
type=int,
|
|
149
|
+
default=3
|
|
150
|
+
)
|
|
151
|
+
parser_refine.add_argument(
|
|
152
|
+
"-v", "--verbose",
|
|
153
|
+
help="Verbose output",
|
|
154
|
+
action="store_true"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# MSA Refine command
|
|
158
|
+
parser_msa_refine = subparsers.add_parser(
|
|
159
|
+
'msa_refine',
|
|
160
|
+
help='Remove sequences with rare indels from MSA'
|
|
161
|
+
)
|
|
162
|
+
parser_msa_refine.add_argument("-i", "--input", help="Input aligned FASTA file", required=True)
|
|
163
|
+
parser_msa_refine.add_argument("-o", "--output", help="Output prefix", required=True)
|
|
164
|
+
parser_msa_refine.add_argument(
|
|
165
|
+
"-f", "--min-freq",
|
|
166
|
+
help="Minimum indel frequency threshold (0-1, default: 0.05 = 5%%)",
|
|
167
|
+
type=float,
|
|
168
|
+
default=0.05
|
|
169
|
+
)
|
|
170
|
+
parser_msa_refine.add_argument(
|
|
171
|
+
"-v", "--verbose",
|
|
172
|
+
help="Verbose output",
|
|
173
|
+
action="store_true"
|
|
174
|
+
)
|
|
175
|
+
|
|
126
176
|
# Parse arguments
|
|
127
177
|
args = parser.parse_args()
|
|
128
178
|
|
|
@@ -178,9 +228,50 @@ For help on specific commands:
|
|
|
178
228
|
|
|
179
229
|
elif args.command == 'extract':
|
|
180
230
|
from .extract import main as extract_main
|
|
181
|
-
|
|
231
|
+
|
|
182
232
|
extract_main(args.fasta, args.output, min_tf=args.min)
|
|
183
|
-
|
|
233
|
+
|
|
234
|
+
elif args.command == 'refine':
|
|
235
|
+
from .refine import main as refine_main
|
|
236
|
+
import os
|
|
237
|
+
|
|
238
|
+
if not os.path.isfile(args.input):
|
|
239
|
+
print(f"Error: Input file {args.input} not found")
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
refine_main(
|
|
243
|
+
input_tsv=args.input,
|
|
244
|
+
output_tsv=args.output,
|
|
245
|
+
expected_length=args.length,
|
|
246
|
+
max_mismatches=args.mismatches,
|
|
247
|
+
verbose=args.verbose
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
elif args.command == 'msa_refine':
|
|
251
|
+
from .msa_refine import refine_msa
|
|
252
|
+
import os
|
|
253
|
+
|
|
254
|
+
if not os.path.isfile(args.input):
|
|
255
|
+
print(f"Error: Input file {args.input} not found")
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
|
|
258
|
+
output_clean = f"{args.output}.clean.fasta"
|
|
259
|
+
output_removed = f"{args.output}.removed.fasta"
|
|
260
|
+
output_stats = f"{args.output}.indel_stats.tsv"
|
|
261
|
+
|
|
262
|
+
stats = refine_msa(
|
|
263
|
+
input_fasta=args.input,
|
|
264
|
+
output_clean=output_clean,
|
|
265
|
+
output_removed=output_removed,
|
|
266
|
+
output_stats=output_stats,
|
|
267
|
+
min_freq=args.min_freq,
|
|
268
|
+
verbose=args.verbose
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
print(f"\nRefinement complete:")
|
|
272
|
+
print(f" Total: {stats['total']} -> Clean: {stats['clean']}, Removed: {stats['removed']}")
|
|
273
|
+
print(f" Indels: {stats['total_indels']} total, {stats['rare_indels']} rare (< {stats['min_freq']*100:.1f}%)")
|
|
274
|
+
|
|
184
275
|
else:
|
|
185
276
|
print(f"Error: Unknown command '{args.command}'")
|
|
186
277
|
parser.print_help()
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
ArraySplitter Monomer Rotation - Rotate monomers to common starting position.
|
|
5
|
+
|
|
6
|
+
This module finds the optimal rotation k-mer and rotates all monomers to start
|
|
7
|
+
from that position. Important for tandem repeats: the sequence is duplicated
|
|
8
|
+
before searching to handle cases where the k-mer spans the boundary.
|
|
9
|
+
|
|
10
|
+
Algorithm:
|
|
11
|
+
1. Find k-mer that appears exactly ONCE in the maximum number of monomers
|
|
12
|
+
2. Duplicate each sequence (seq+seq) to handle boundary cases
|
|
13
|
+
3. Find best match position using edit distance
|
|
14
|
+
4. Rotate sequence to start from that position
|
|
15
|
+
5. Ensure all sequences are on the same strand
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from typing import List, Tuple, Dict, Optional
|
|
21
|
+
import editdistance as ed
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_revcomp(seq: str) -> str:
|
|
25
|
+
"""Get reverse complement of sequence."""
|
|
26
|
+
complement = {'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C', 'N': 'N'}
|
|
27
|
+
return ''.join(complement.get(b, b) for b in reversed(seq))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_best_rotation_kmer(sequences: List[str], k: int = 16) -> Tuple[Optional[str], float]:
|
|
31
|
+
"""
|
|
32
|
+
Find k-mer that appears exactly once in the maximum number of sequences.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
sequences: List of DNA sequences (monomers)
|
|
36
|
+
k: K-mer length (default 16)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
(best_kmer, fraction_unique) - k-mer and fraction of sequences with unique occurrence
|
|
40
|
+
"""
|
|
41
|
+
kmer_unique_count: Counter = Counter()
|
|
42
|
+
|
|
43
|
+
for seq in sequences:
|
|
44
|
+
# Count k-mers in this sequence
|
|
45
|
+
seq_kmers: Counter = Counter()
|
|
46
|
+
for i in range(len(seq) - k + 1):
|
|
47
|
+
kmer = seq[i:i+k]
|
|
48
|
+
seq_kmers[kmer] += 1
|
|
49
|
+
|
|
50
|
+
# Record k-mers that appear exactly once
|
|
51
|
+
for kmer, count in seq_kmers.items():
|
|
52
|
+
if count == 1:
|
|
53
|
+
kmer_unique_count[kmer] += 1
|
|
54
|
+
|
|
55
|
+
if not kmer_unique_count:
|
|
56
|
+
return None, 0.0
|
|
57
|
+
|
|
58
|
+
best_kmer, best_count = kmer_unique_count.most_common(1)[0]
|
|
59
|
+
fraction = best_count / len(sequences)
|
|
60
|
+
|
|
61
|
+
return best_kmer, fraction
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_kmer_position_in_doubled(seq: str, kmer: str, max_ed: int = 5) -> Tuple[int, int, str]:
|
|
65
|
+
"""
|
|
66
|
+
Find best position of k-mer in doubled sequence (seq+seq) to handle boundary cases.
|
|
67
|
+
Checks both strands.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
seq: Original sequence
|
|
71
|
+
kmer: K-mer to find
|
|
72
|
+
max_ed: Maximum edit distance allowed
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
(position, edit_distance, strand) where position is in original [0, len(seq))
|
|
76
|
+
"""
|
|
77
|
+
k = len(kmer)
|
|
78
|
+
seq_len = len(seq)
|
|
79
|
+
|
|
80
|
+
# Double the sequence to handle boundary
|
|
81
|
+
doubled_fwd = seq + seq
|
|
82
|
+
doubled_rev = get_revcomp(seq) + get_revcomp(seq)
|
|
83
|
+
|
|
84
|
+
best_pos = -1
|
|
85
|
+
best_dist = max_ed + 1
|
|
86
|
+
best_strand = "unk"
|
|
87
|
+
|
|
88
|
+
# Search in forward strand (doubled)
|
|
89
|
+
for i in range(seq_len): # Only search first copy positions
|
|
90
|
+
window = doubled_fwd[i:i+k]
|
|
91
|
+
dist = ed.eval(kmer, window)
|
|
92
|
+
if dist < best_dist:
|
|
93
|
+
best_dist = dist
|
|
94
|
+
best_pos = i
|
|
95
|
+
best_strand = "fwd"
|
|
96
|
+
|
|
97
|
+
# Search in reverse complement (doubled)
|
|
98
|
+
for i in range(seq_len):
|
|
99
|
+
window = doubled_rev[i:i+k]
|
|
100
|
+
dist = ed.eval(kmer, window)
|
|
101
|
+
if dist < best_dist:
|
|
102
|
+
best_dist = dist
|
|
103
|
+
best_pos = i
|
|
104
|
+
best_strand = "rev"
|
|
105
|
+
|
|
106
|
+
if best_dist <= max_ed:
|
|
107
|
+
return best_pos, best_dist, best_strand
|
|
108
|
+
|
|
109
|
+
return -1, best_dist, "unk"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def rotate_sequence(seq: str, position: int, strand: str) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Rotate sequence to start from given position.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
seq: Original sequence
|
|
118
|
+
position: Position to start from
|
|
119
|
+
strand: "fwd" or "rev" - which strand to use
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Rotated sequence
|
|
123
|
+
"""
|
|
124
|
+
if strand == "rev":
|
|
125
|
+
seq = get_revcomp(seq)
|
|
126
|
+
|
|
127
|
+
return seq[position:] + seq[:position]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def rotate_monomers(input_fasta: str, output_fasta: str,
|
|
131
|
+
kmer: Optional[str] = None, k: int = 16,
|
|
132
|
+
max_ed: int = 5, verbose: bool = False) -> Dict:
|
|
133
|
+
"""
|
|
134
|
+
Main rotation function.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
input_fasta: Input FASTA file with monomers
|
|
138
|
+
output_fasta: Output FASTA file with rotated monomers
|
|
139
|
+
kmer: Optional rotation k-mer (auto-detected if not provided)
|
|
140
|
+
k: K-mer length for auto-detection
|
|
141
|
+
max_ed: Maximum edit distance for rotation
|
|
142
|
+
verbose: Print progress
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Statistics dictionary
|
|
146
|
+
"""
|
|
147
|
+
# Read sequences
|
|
148
|
+
sequences = []
|
|
149
|
+
headers = []
|
|
150
|
+
|
|
151
|
+
with open(input_fasta) as f:
|
|
152
|
+
seq = ""
|
|
153
|
+
header = ""
|
|
154
|
+
for line in f:
|
|
155
|
+
if line.startswith(">"):
|
|
156
|
+
if seq:
|
|
157
|
+
sequences.append(seq)
|
|
158
|
+
headers.append(header)
|
|
159
|
+
seq = ""
|
|
160
|
+
header = line.strip()
|
|
161
|
+
else:
|
|
162
|
+
seq += line.strip()
|
|
163
|
+
if seq:
|
|
164
|
+
sequences.append(seq)
|
|
165
|
+
headers.append(header)
|
|
166
|
+
|
|
167
|
+
if verbose:
|
|
168
|
+
print(f"Loaded {len(sequences)} sequences")
|
|
169
|
+
|
|
170
|
+
# Auto-detect rotation k-mer if not provided
|
|
171
|
+
if kmer is None:
|
|
172
|
+
# Filter to normal-length monomers for k-mer detection
|
|
173
|
+
normal_seqs = [s for s in sequences if 600 <= len(s) <= 800]
|
|
174
|
+
if not normal_seqs:
|
|
175
|
+
normal_seqs = sequences
|
|
176
|
+
|
|
177
|
+
kmer, fraction = find_best_rotation_kmer(normal_seqs, k)
|
|
178
|
+
|
|
179
|
+
if verbose:
|
|
180
|
+
print(f"Auto-detected rotation k-mer: {kmer}")
|
|
181
|
+
print(f" Unique in {fraction*100:.1f}% of monomers")
|
|
182
|
+
|
|
183
|
+
if kmer is None:
|
|
184
|
+
raise ValueError("Could not detect rotation k-mer")
|
|
185
|
+
|
|
186
|
+
# Rotate all sequences
|
|
187
|
+
rotated = []
|
|
188
|
+
stats = {
|
|
189
|
+
"total": len(sequences),
|
|
190
|
+
"fwd": 0,
|
|
191
|
+
"rev": 0,
|
|
192
|
+
"failed": 0,
|
|
193
|
+
"ed_distribution": Counter()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for header, seq in zip(headers, sequences):
|
|
197
|
+
pos, dist, strand = find_kmer_position_in_doubled(seq, kmer, max_ed)
|
|
198
|
+
|
|
199
|
+
if pos >= 0:
|
|
200
|
+
rotated_seq = rotate_sequence(seq, pos, strand)
|
|
201
|
+
stats[strand] += 1
|
|
202
|
+
stats["ed_distribution"][dist] += 1
|
|
203
|
+
else:
|
|
204
|
+
# Keep original if rotation failed
|
|
205
|
+
rotated_seq = seq
|
|
206
|
+
strand = "failed"
|
|
207
|
+
stats["failed"] += 1
|
|
208
|
+
|
|
209
|
+
rotated.append((header, rotated_seq, strand, dist))
|
|
210
|
+
|
|
211
|
+
# Write output
|
|
212
|
+
with open(output_fasta, 'w') as f:
|
|
213
|
+
for header, seq, strand, dist in rotated:
|
|
214
|
+
new_header = f"{header}|rot_{strand}|ed{dist}"
|
|
215
|
+
f.write(f"{new_header}\n{seq}\n")
|
|
216
|
+
|
|
217
|
+
if verbose:
|
|
218
|
+
print(f"\nRotation results:")
|
|
219
|
+
print(f" Forward: {stats['fwd']}")
|
|
220
|
+
print(f" Reverse: {stats['rev']}")
|
|
221
|
+
print(f" Failed: {stats['failed']}")
|
|
222
|
+
print(f"\nEdit distance distribution:")
|
|
223
|
+
for d, c in sorted(stats["ed_distribution"].items()):
|
|
224
|
+
print(f" ED={d}: {c}")
|
|
225
|
+
|
|
226
|
+
stats["kmer"] = kmer
|
|
227
|
+
return stats
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
parser = argparse.ArgumentParser(
|
|
232
|
+
description="Rotate monomers to common starting position"
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument("-i", "--input", required=True,
|
|
235
|
+
help="Input FASTA file with monomers")
|
|
236
|
+
parser.add_argument("-o", "--output", required=True,
|
|
237
|
+
help="Output FASTA file with rotated monomers")
|
|
238
|
+
parser.add_argument("-k", "--kmer", default=None,
|
|
239
|
+
help="Rotation k-mer (auto-detected if not provided)")
|
|
240
|
+
parser.add_argument("-l", "--kmer-length", type=int, default=16,
|
|
241
|
+
help="K-mer length for auto-detection (default: 16)")
|
|
242
|
+
parser.add_argument("-e", "--max-ed", type=int, default=5,
|
|
243
|
+
help="Maximum edit distance for rotation (default: 5)")
|
|
244
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
245
|
+
help="Verbose output")
|
|
246
|
+
|
|
247
|
+
args = parser.parse_args()
|
|
248
|
+
|
|
249
|
+
stats = rotate_monomers(
|
|
250
|
+
input_fasta=args.input,
|
|
251
|
+
output_fasta=args.output,
|
|
252
|
+
kmer=args.kmer,
|
|
253
|
+
k=args.kmer_length,
|
|
254
|
+
max_ed=args.max_ed,
|
|
255
|
+
verbose=args.verbose
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
print(f"Rotation complete. K-mer used: {stats['kmer']}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
main()
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
MSA Refine - Remove sequences with rare indels from multiple sequence alignment.
|
|
5
|
+
|
|
6
|
+
This module analyzes indel patterns in MSA and removes sequences containing
|
|
7
|
+
rare indels (below frequency threshold). This helps clean up alignments
|
|
8
|
+
before final consensus generation.
|
|
9
|
+
|
|
10
|
+
Algorithm:
|
|
11
|
+
1. Parse aligned FASTA and identify all indel patterns (contiguous gaps)
|
|
12
|
+
2. Calculate frequency of each indel pattern across all sequences
|
|
13
|
+
3. Mark sequences containing rare indels (frequency < threshold)
|
|
14
|
+
4. Output: cleaned sequences, removed sequences, statistics report
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
from collections import Counter, defaultdict
|
|
19
|
+
from typing import List, Tuple, Dict, Set
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Indel:
|
|
26
|
+
"""Represents an indel (gap region) in the alignment."""
|
|
27
|
+
start: int
|
|
28
|
+
end: int
|
|
29
|
+
length: int
|
|
30
|
+
|
|
31
|
+
def __hash__(self):
|
|
32
|
+
return hash((self.start, self.end))
|
|
33
|
+
|
|
34
|
+
def __eq__(self, other):
|
|
35
|
+
return self.start == other.start and self.end == other.end
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class IndelStats:
|
|
40
|
+
"""Statistics for an indel pattern."""
|
|
41
|
+
indel: Indel
|
|
42
|
+
count: int
|
|
43
|
+
frequency: float
|
|
44
|
+
sequences: List[str] # sequence names containing this indel
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_fasta(filepath: str) -> List[Tuple[str, str]]:
|
|
48
|
+
"""Parse FASTA file, return list of (name, sequence) tuples."""
|
|
49
|
+
sequences = []
|
|
50
|
+
with open(filepath) as f:
|
|
51
|
+
name = ""
|
|
52
|
+
seq = ""
|
|
53
|
+
for line in f:
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if line.startswith(">"):
|
|
56
|
+
if seq:
|
|
57
|
+
sequences.append((name, seq))
|
|
58
|
+
name = line[1:].split()[0] # Take first word as name
|
|
59
|
+
seq = ""
|
|
60
|
+
else:
|
|
61
|
+
seq += line
|
|
62
|
+
if seq:
|
|
63
|
+
sequences.append((name, seq))
|
|
64
|
+
return sequences
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_indels_in_sequence(seq: str) -> List[Indel]:
|
|
68
|
+
"""Find all indel (gap) regions in a sequence."""
|
|
69
|
+
indels = []
|
|
70
|
+
in_gap = False
|
|
71
|
+
gap_start = 0
|
|
72
|
+
|
|
73
|
+
for i, base in enumerate(seq):
|
|
74
|
+
if base == '-':
|
|
75
|
+
if not in_gap:
|
|
76
|
+
in_gap = True
|
|
77
|
+
gap_start = i
|
|
78
|
+
else:
|
|
79
|
+
if in_gap:
|
|
80
|
+
indels.append(Indel(start=gap_start, end=i, length=i - gap_start))
|
|
81
|
+
in_gap = False
|
|
82
|
+
|
|
83
|
+
# Handle gap at end
|
|
84
|
+
if in_gap:
|
|
85
|
+
indels.append(Indel(start=gap_start, end=len(seq), length=len(seq) - gap_start))
|
|
86
|
+
|
|
87
|
+
return indels
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def analyze_indels(sequences: List[Tuple[str, str]]) -> Dict[Indel, IndelStats]:
|
|
91
|
+
"""Analyze all indels across sequences and compute statistics."""
|
|
92
|
+
indel_to_seqs: Dict[Indel, List[str]] = defaultdict(list)
|
|
93
|
+
n_seqs = len(sequences)
|
|
94
|
+
|
|
95
|
+
for name, seq in sequences:
|
|
96
|
+
indels = find_indels_in_sequence(seq)
|
|
97
|
+
for indel in indels:
|
|
98
|
+
indel_to_seqs[indel].append(name)
|
|
99
|
+
|
|
100
|
+
# Compute statistics
|
|
101
|
+
stats = {}
|
|
102
|
+
for indel, seq_names in indel_to_seqs.items():
|
|
103
|
+
count = len(seq_names)
|
|
104
|
+
stats[indel] = IndelStats(
|
|
105
|
+
indel=indel,
|
|
106
|
+
count=count,
|
|
107
|
+
frequency=count / n_seqs,
|
|
108
|
+
sequences=seq_names
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return stats
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def refine_msa(
|
|
115
|
+
input_fasta: str,
|
|
116
|
+
output_clean: str,
|
|
117
|
+
output_removed: str,
|
|
118
|
+
output_stats: str,
|
|
119
|
+
min_freq: float = 0.05,
|
|
120
|
+
verbose: bool = False
|
|
121
|
+
) -> Dict:
|
|
122
|
+
"""
|
|
123
|
+
Refine MSA by removing sequences with rare indels.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
input_fasta: Input aligned FASTA file
|
|
127
|
+
output_clean: Output FASTA with cleaned sequences
|
|
128
|
+
output_removed: Output FASTA with removed sequences
|
|
129
|
+
output_stats: Output TSV with indel statistics
|
|
130
|
+
min_freq: Minimum frequency threshold for indels (default 5%)
|
|
131
|
+
verbose: Print progress
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Statistics dictionary
|
|
135
|
+
"""
|
|
136
|
+
# Parse sequences
|
|
137
|
+
sequences = parse_fasta(input_fasta)
|
|
138
|
+
n_total = len(sequences)
|
|
139
|
+
|
|
140
|
+
if verbose:
|
|
141
|
+
print(f"Loaded {n_total} aligned sequences")
|
|
142
|
+
|
|
143
|
+
# Analyze indels
|
|
144
|
+
indel_stats = analyze_indels(sequences)
|
|
145
|
+
|
|
146
|
+
if verbose:
|
|
147
|
+
print(f"Found {len(indel_stats)} unique indel patterns")
|
|
148
|
+
|
|
149
|
+
# Find rare indels and sequences to remove
|
|
150
|
+
rare_indels = {indel: stats for indel, stats in indel_stats.items()
|
|
151
|
+
if stats.frequency < min_freq}
|
|
152
|
+
|
|
153
|
+
# Collect sequences to remove (those with ANY rare indel)
|
|
154
|
+
seqs_to_remove: Set[str] = set()
|
|
155
|
+
for stats in rare_indels.values():
|
|
156
|
+
seqs_to_remove.update(stats.sequences)
|
|
157
|
+
|
|
158
|
+
if verbose:
|
|
159
|
+
print(f"Found {len(rare_indels)} rare indels (freq < {min_freq*100:.1f}%)")
|
|
160
|
+
print(f"Sequences to remove: {len(seqs_to_remove)}")
|
|
161
|
+
|
|
162
|
+
# Split sequences
|
|
163
|
+
clean_seqs = []
|
|
164
|
+
removed_seqs = []
|
|
165
|
+
|
|
166
|
+
for name, seq in sequences:
|
|
167
|
+
if name in seqs_to_remove:
|
|
168
|
+
removed_seqs.append((name, seq))
|
|
169
|
+
else:
|
|
170
|
+
clean_seqs.append((name, seq))
|
|
171
|
+
|
|
172
|
+
# Write clean sequences (remove columns that are now all gaps)
|
|
173
|
+
if clean_seqs:
|
|
174
|
+
# Find columns that have at least one non-gap
|
|
175
|
+
align_len = len(clean_seqs[0][1])
|
|
176
|
+
non_gap_cols = []
|
|
177
|
+
for i in range(align_len):
|
|
178
|
+
has_base = any(seq[i] != '-' for _, seq in clean_seqs)
|
|
179
|
+
if has_base:
|
|
180
|
+
non_gap_cols.append(i)
|
|
181
|
+
|
|
182
|
+
# Write cleaned sequences with gap-only columns removed
|
|
183
|
+
with open(output_clean, 'w') as f:
|
|
184
|
+
for name, seq in clean_seqs:
|
|
185
|
+
cleaned_seq = ''.join(seq[i] for i in non_gap_cols)
|
|
186
|
+
f.write(f">{name}\n{cleaned_seq}\n")
|
|
187
|
+
|
|
188
|
+
cols_removed = align_len - len(non_gap_cols)
|
|
189
|
+
if verbose and cols_removed > 0:
|
|
190
|
+
print(f"Removed {cols_removed} gap-only columns")
|
|
191
|
+
|
|
192
|
+
# Write removed sequences
|
|
193
|
+
with open(output_removed, 'w') as f:
|
|
194
|
+
for name, seq in removed_seqs:
|
|
195
|
+
f.write(f">{name}\n{seq}\n")
|
|
196
|
+
|
|
197
|
+
# Write statistics
|
|
198
|
+
with open(output_stats, 'w') as f:
|
|
199
|
+
f.write("# MSA Refinement Statistics\n")
|
|
200
|
+
f.write(f"# Input: {input_fasta}\n")
|
|
201
|
+
f.write(f"# Total sequences: {n_total}\n")
|
|
202
|
+
f.write(f"# Clean sequences: {len(clean_seqs)}\n")
|
|
203
|
+
f.write(f"# Removed sequences: {len(removed_seqs)}\n")
|
|
204
|
+
f.write(f"# Min frequency threshold: {min_freq*100:.1f}%\n")
|
|
205
|
+
f.write(f"# Unique indel patterns: {len(indel_stats)}\n")
|
|
206
|
+
f.write(f"# Rare indels (below threshold): {len(rare_indels)}\n")
|
|
207
|
+
f.write("#\n")
|
|
208
|
+
f.write("# Indel details (sorted by frequency):\n")
|
|
209
|
+
f.write("start\tend\tlength\tcount\tfrequency\tis_rare\texample_sequences\n")
|
|
210
|
+
|
|
211
|
+
# Sort by frequency descending
|
|
212
|
+
sorted_indels = sorted(indel_stats.items(),
|
|
213
|
+
key=lambda x: (-x[1].frequency, x[0].start))
|
|
214
|
+
|
|
215
|
+
for indel, stats in sorted_indels:
|
|
216
|
+
is_rare = "RARE" if stats.frequency < min_freq else ""
|
|
217
|
+
example_seqs = ",".join(stats.sequences[:3])
|
|
218
|
+
if len(stats.sequences) > 3:
|
|
219
|
+
example_seqs += f"... (+{len(stats.sequences)-3} more)"
|
|
220
|
+
f.write(f"{indel.start}\t{indel.end}\t{indel.length}\t"
|
|
221
|
+
f"{stats.count}\t{stats.frequency*100:.2f}%\t{is_rare}\t{example_seqs}\n")
|
|
222
|
+
|
|
223
|
+
if verbose:
|
|
224
|
+
print(f"\nOutput files:")
|
|
225
|
+
print(f" Clean sequences: {output_clean}")
|
|
226
|
+
print(f" Removed sequences: {output_removed}")
|
|
227
|
+
print(f" Statistics: {output_stats}")
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"total": n_total,
|
|
231
|
+
"clean": len(clean_seqs),
|
|
232
|
+
"removed": len(removed_seqs),
|
|
233
|
+
"total_indels": len(indel_stats),
|
|
234
|
+
"rare_indels": len(rare_indels),
|
|
235
|
+
"min_freq": min_freq
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main():
|
|
240
|
+
parser = argparse.ArgumentParser(
|
|
241
|
+
description="Refine MSA by removing sequences with rare indels"
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument("-i", "--input", required=True,
|
|
244
|
+
help="Input aligned FASTA file")
|
|
245
|
+
parser.add_argument("-o", "--output", required=True,
|
|
246
|
+
help="Output prefix for cleaned files")
|
|
247
|
+
parser.add_argument("-f", "--min-freq", type=float, default=0.05,
|
|
248
|
+
help="Minimum indel frequency threshold (default: 0.05 = 5%%)")
|
|
249
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
250
|
+
help="Verbose output")
|
|
251
|
+
|
|
252
|
+
args = parser.parse_args()
|
|
253
|
+
|
|
254
|
+
output_clean = f"{args.output}.clean.fasta"
|
|
255
|
+
output_removed = f"{args.output}.removed.fasta"
|
|
256
|
+
output_stats = f"{args.output}.indel_stats.tsv"
|
|
257
|
+
|
|
258
|
+
stats = refine_msa(
|
|
259
|
+
input_fasta=args.input,
|
|
260
|
+
output_clean=output_clean,
|
|
261
|
+
output_removed=output_removed,
|
|
262
|
+
output_stats=output_stats,
|
|
263
|
+
min_freq=args.min_freq,
|
|
264
|
+
verbose=args.verbose
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
print(f"\nRefinement complete:")
|
|
268
|
+
print(f" Total: {stats['total']} → Clean: {stats['clean']}, Removed: {stats['removed']}")
|
|
269
|
+
print(f" Indels: {stats['total_indels']} total, {stats['rare_indels']} rare (< {stats['min_freq']*100:.1f}%)")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
main()
|
ArraySplitter/refine.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
ArraySplitter Refine - Post-processing of decomposed monomers.
|
|
5
|
+
|
|
6
|
+
This module refines the .monomers.tsv file by:
|
|
7
|
+
1. Splitting merged monomers (doubled, tripled, etc.) that have mutations in cut sites
|
|
8
|
+
2. Merging fragmented half-monomers with their neighbors
|
|
9
|
+
|
|
10
|
+
Input/Output: .monomers.tsv format
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from typing import List, Dict, Optional, Tuple
|
|
16
|
+
import editdistance as ed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_monomers_tsv(filepath: str) -> Tuple[List[str], List[Dict]]:
|
|
20
|
+
"""
|
|
21
|
+
Read .monomers.tsv file.
|
|
22
|
+
Returns (header_fields, list of row dicts).
|
|
23
|
+
"""
|
|
24
|
+
rows = []
|
|
25
|
+
header_fields = []
|
|
26
|
+
|
|
27
|
+
with open(filepath, 'r') as f:
|
|
28
|
+
header = f.readline().strip()
|
|
29
|
+
header_fields = header.split('\t')
|
|
30
|
+
|
|
31
|
+
for line in f:
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
parts = line.split('\t')
|
|
36
|
+
row = {}
|
|
37
|
+
for i, field in enumerate(header_fields):
|
|
38
|
+
if i < len(parts):
|
|
39
|
+
row[field] = parts[i]
|
|
40
|
+
else:
|
|
41
|
+
row[field] = ''
|
|
42
|
+
|
|
43
|
+
# Convert numeric fields
|
|
44
|
+
if 'length' in row:
|
|
45
|
+
row['length'] = int(row['length']) if row['length'] else 0
|
|
46
|
+
if 'index' in row:
|
|
47
|
+
row['index'] = int(row['index']) if row['index'] else 0
|
|
48
|
+
|
|
49
|
+
rows.append(row)
|
|
50
|
+
|
|
51
|
+
return header_fields, rows
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_monomers_tsv(filepath: str, header_fields: List[str], rows: List[Dict]):
|
|
55
|
+
"""Write rows to .monomers.tsv file."""
|
|
56
|
+
with open(filepath, 'w') as f:
|
|
57
|
+
f.write('\t'.join(header_fields) + '\n')
|
|
58
|
+
for row in rows:
|
|
59
|
+
values = []
|
|
60
|
+
for field in header_fields:
|
|
61
|
+
val = row.get(field, '')
|
|
62
|
+
values.append(str(val))
|
|
63
|
+
f.write('\t'.join(values) + '\n')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_cut_sequence(rows: List[Dict], kmer_len: int = 16) -> Optional[str]:
|
|
67
|
+
"""
|
|
68
|
+
Discover the most common starting k-mer from monomers.
|
|
69
|
+
"""
|
|
70
|
+
kmer_counts: Counter = Counter()
|
|
71
|
+
|
|
72
|
+
for row in rows:
|
|
73
|
+
if row.get('type') == 'MONOMER':
|
|
74
|
+
seq = row.get('sequence', '')
|
|
75
|
+
if len(seq) >= kmer_len:
|
|
76
|
+
kmer = seq[:kmer_len]
|
|
77
|
+
kmer_counts[kmer] += 1
|
|
78
|
+
|
|
79
|
+
if not kmer_counts:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
return kmer_counts.most_common(1)[0][0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_expected_lengths(rows: List[Dict]) -> Tuple[List[int], int]:
|
|
86
|
+
"""
|
|
87
|
+
Analyze length distribution to find expected monomer lengths.
|
|
88
|
+
Returns (common_lengths, modal_length).
|
|
89
|
+
"""
|
|
90
|
+
lengths = []
|
|
91
|
+
for row in rows:
|
|
92
|
+
if row.get('type') == 'MONOMER':
|
|
93
|
+
lengths.append(row.get('length', 0))
|
|
94
|
+
|
|
95
|
+
if not lengths:
|
|
96
|
+
return [], 700
|
|
97
|
+
|
|
98
|
+
length_counts = Counter(lengths)
|
|
99
|
+
common_lengths = [l for l, c in length_counts.most_common(10) if c >= 5]
|
|
100
|
+
|
|
101
|
+
if not common_lengths:
|
|
102
|
+
common_lengths = [length_counts.most_common(1)[0][0]]
|
|
103
|
+
|
|
104
|
+
modal_length = common_lengths[0]
|
|
105
|
+
return common_lengths, modal_length
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_approximate_cut_positions(sequence: str, cut_seq: str,
|
|
109
|
+
max_mismatches: int = 3) -> List[int]:
|
|
110
|
+
"""
|
|
111
|
+
Find positions where cut_seq appears with up to max_mismatches.
|
|
112
|
+
"""
|
|
113
|
+
positions = []
|
|
114
|
+
cut_len = len(cut_seq)
|
|
115
|
+
|
|
116
|
+
for i in range(len(sequence) - cut_len + 1):
|
|
117
|
+
window = sequence[i:i + cut_len]
|
|
118
|
+
dist = ed.eval(cut_seq, window)
|
|
119
|
+
if dist <= max_mismatches:
|
|
120
|
+
positions.append(i)
|
|
121
|
+
|
|
122
|
+
return positions
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def split_sequence(sequence: str, cut_seq: str, expected_length: int,
|
|
126
|
+
max_mismatches: int = 3) -> List[str]:
|
|
127
|
+
"""
|
|
128
|
+
Split a merged sequence into individual monomers.
|
|
129
|
+
"""
|
|
130
|
+
seq_len = len(sequence)
|
|
131
|
+
num_monomers = round(seq_len / expected_length)
|
|
132
|
+
|
|
133
|
+
if num_monomers < 2:
|
|
134
|
+
return [sequence]
|
|
135
|
+
|
|
136
|
+
cut_positions = find_approximate_cut_positions(sequence, cut_seq, max_mismatches)
|
|
137
|
+
|
|
138
|
+
if not cut_positions:
|
|
139
|
+
return [sequence]
|
|
140
|
+
|
|
141
|
+
# Filter positions - need minimum distance between cuts
|
|
142
|
+
min_distance = expected_length * 0.6
|
|
143
|
+
filtered_positions = []
|
|
144
|
+
last_pos = 0
|
|
145
|
+
|
|
146
|
+
for pos in sorted(cut_positions):
|
|
147
|
+
if pos > 0 and pos - last_pos >= min_distance:
|
|
148
|
+
filtered_positions.append(pos)
|
|
149
|
+
last_pos = pos
|
|
150
|
+
|
|
151
|
+
if not filtered_positions:
|
|
152
|
+
return [sequence]
|
|
153
|
+
|
|
154
|
+
# Split at found positions
|
|
155
|
+
result = []
|
|
156
|
+
prev_pos = 0
|
|
157
|
+
|
|
158
|
+
for pos in filtered_positions:
|
|
159
|
+
part = sequence[prev_pos:pos]
|
|
160
|
+
if part:
|
|
161
|
+
result.append(part)
|
|
162
|
+
prev_pos = pos
|
|
163
|
+
|
|
164
|
+
last_part = sequence[prev_pos:]
|
|
165
|
+
if last_part:
|
|
166
|
+
result.append(last_part)
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def refine_monomers_tsv(input_tsv: str, output_tsv: str,
|
|
172
|
+
expected_length: Optional[int] = None,
|
|
173
|
+
max_mismatches: int = 3,
|
|
174
|
+
min_length_ratio: float = 0.5,
|
|
175
|
+
max_length_ratio: float = 1.5,
|
|
176
|
+
verbose: bool = False) -> Dict:
|
|
177
|
+
"""
|
|
178
|
+
Main refinement function for TSV files.
|
|
179
|
+
"""
|
|
180
|
+
# Read input
|
|
181
|
+
header_fields, rows = read_monomers_tsv(input_tsv)
|
|
182
|
+
|
|
183
|
+
if verbose:
|
|
184
|
+
print(f"Read {len(rows)} rows from {input_tsv}")
|
|
185
|
+
|
|
186
|
+
# Find cut sequence
|
|
187
|
+
cut_seq = find_cut_sequence(rows)
|
|
188
|
+
if cut_seq is None:
|
|
189
|
+
print("Warning: Could not detect cut sequence")
|
|
190
|
+
if verbose:
|
|
191
|
+
print(f"Detected cut sequence: {cut_seq}")
|
|
192
|
+
|
|
193
|
+
# Analyze length distribution
|
|
194
|
+
common_lengths, modal_length = get_expected_lengths(rows)
|
|
195
|
+
|
|
196
|
+
if expected_length is None:
|
|
197
|
+
expected_length = modal_length
|
|
198
|
+
|
|
199
|
+
if verbose:
|
|
200
|
+
print(f"Expected monomer length: {expected_length} bp")
|
|
201
|
+
print(f"Common lengths: {common_lengths[:5]}")
|
|
202
|
+
|
|
203
|
+
# Define thresholds
|
|
204
|
+
min_length = int(expected_length * min_length_ratio)
|
|
205
|
+
max_length = int(expected_length * max_length_ratio)
|
|
206
|
+
|
|
207
|
+
if verbose:
|
|
208
|
+
print(f"Normal range: {min_length}-{max_length} bp")
|
|
209
|
+
|
|
210
|
+
# Process rows
|
|
211
|
+
refined_rows = []
|
|
212
|
+
stats = {
|
|
213
|
+
'total_input': len(rows),
|
|
214
|
+
'normal': 0,
|
|
215
|
+
'split': 0,
|
|
216
|
+
'merged': 0,
|
|
217
|
+
'flanks': 0,
|
|
218
|
+
'split_into': 0,
|
|
219
|
+
'merged_from': 0,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
i = 0
|
|
223
|
+
while i < len(rows):
|
|
224
|
+
row = rows[i]
|
|
225
|
+
row_type = row.get('type', '')
|
|
226
|
+
seq = row.get('sequence', '')
|
|
227
|
+
seq_len = row.get('length', len(seq))
|
|
228
|
+
|
|
229
|
+
# Skip flanks - keep as is
|
|
230
|
+
if 'FLANK' in row_type:
|
|
231
|
+
refined_rows.append(row)
|
|
232
|
+
stats['flanks'] += 1
|
|
233
|
+
i += 1
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Normal length - keep as is
|
|
237
|
+
if min_length <= seq_len <= max_length:
|
|
238
|
+
refined_rows.append(row)
|
|
239
|
+
stats['normal'] += 1
|
|
240
|
+
i += 1
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# Merged monomer (too long) - try to split
|
|
244
|
+
if seq_len > max_length:
|
|
245
|
+
if cut_seq is not None:
|
|
246
|
+
parts = split_sequence(seq, cut_seq, expected_length, max_mismatches)
|
|
247
|
+
|
|
248
|
+
if len(parts) > 1:
|
|
249
|
+
stats['split'] += 1
|
|
250
|
+
stats['split_into'] += len(parts)
|
|
251
|
+
|
|
252
|
+
if verbose:
|
|
253
|
+
print(f" Split {seq_len}bp -> {[len(p) for p in parts]}")
|
|
254
|
+
|
|
255
|
+
# Create new rows for each part
|
|
256
|
+
for j, part in enumerate(parts):
|
|
257
|
+
new_row = row.copy()
|
|
258
|
+
new_row['sequence'] = part
|
|
259
|
+
new_row['length'] = len(part)
|
|
260
|
+
new_row['type'] = f"MONOMER|split_{j+1}of{len(parts)}"
|
|
261
|
+
refined_rows.append(new_row)
|
|
262
|
+
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Could not split
|
|
267
|
+
row_copy = row.copy()
|
|
268
|
+
row_copy['type'] = row_type + '|unsplit'
|
|
269
|
+
refined_rows.append(row_copy)
|
|
270
|
+
i += 1
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Short monomer - try to merge with neighbor
|
|
274
|
+
if seq_len < min_length:
|
|
275
|
+
merged = False
|
|
276
|
+
|
|
277
|
+
# Look at next row
|
|
278
|
+
if i + 1 < len(rows):
|
|
279
|
+
next_row = rows[i + 1]
|
|
280
|
+
next_type = next_row.get('type', '')
|
|
281
|
+
next_seq = next_row.get('sequence', '')
|
|
282
|
+
next_len = next_row.get('length', len(next_seq))
|
|
283
|
+
|
|
284
|
+
# Check if same array and both are short monomers
|
|
285
|
+
if (row.get('sequence_id') == next_row.get('sequence_id') and
|
|
286
|
+
'FLANK' not in next_type and
|
|
287
|
+
next_len < min_length):
|
|
288
|
+
|
|
289
|
+
combined_len = seq_len + next_len
|
|
290
|
+
# Check if combined is reasonable
|
|
291
|
+
if min_length <= combined_len <= max_length * 1.1:
|
|
292
|
+
# Merge
|
|
293
|
+
merged_seq = seq + next_seq
|
|
294
|
+
merged_row = row.copy()
|
|
295
|
+
merged_row['sequence'] = merged_seq
|
|
296
|
+
merged_row['length'] = len(merged_seq)
|
|
297
|
+
merged_row['type'] = 'MONOMER|merged'
|
|
298
|
+
refined_rows.append(merged_row)
|
|
299
|
+
|
|
300
|
+
stats['merged'] += 1
|
|
301
|
+
stats['merged_from'] += 2
|
|
302
|
+
|
|
303
|
+
if verbose:
|
|
304
|
+
print(f" Merged {seq_len}bp + {next_len}bp = {len(merged_seq)}bp")
|
|
305
|
+
|
|
306
|
+
i += 2
|
|
307
|
+
merged = True
|
|
308
|
+
|
|
309
|
+
if not merged:
|
|
310
|
+
# Keep as is with tag
|
|
311
|
+
row_copy = row.copy()
|
|
312
|
+
row_copy['type'] = row_type + '|short'
|
|
313
|
+
refined_rows.append(row_copy)
|
|
314
|
+
i += 1
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# Fallback
|
|
318
|
+
refined_rows.append(row)
|
|
319
|
+
i += 1
|
|
320
|
+
|
|
321
|
+
stats['total_output'] = len(refined_rows)
|
|
322
|
+
|
|
323
|
+
# Renumber indices within each sequence_id
|
|
324
|
+
current_seq_id = None
|
|
325
|
+
current_idx = 0
|
|
326
|
+
for row in refined_rows:
|
|
327
|
+
seq_id = row.get('sequence_id')
|
|
328
|
+
if seq_id != current_seq_id:
|
|
329
|
+
current_seq_id = seq_id
|
|
330
|
+
current_idx = 0
|
|
331
|
+
row['index'] = current_idx
|
|
332
|
+
current_idx += 1
|
|
333
|
+
|
|
334
|
+
# Write output
|
|
335
|
+
write_monomers_tsv(output_tsv, header_fields, refined_rows)
|
|
336
|
+
|
|
337
|
+
if verbose:
|
|
338
|
+
print(f"\nRefinement statistics:")
|
|
339
|
+
print(f" Input rows: {stats['total_input']}")
|
|
340
|
+
print(f" Output rows: {stats['total_output']}")
|
|
341
|
+
print(f" Normal (unchanged): {stats['normal']}")
|
|
342
|
+
print(f" Split merged: {stats['split']} -> {stats['split_into']} parts")
|
|
343
|
+
print(f" Merged short: {stats['merged']} (from {stats['merged_from']} fragments)")
|
|
344
|
+
print(f" Flanks (unchanged): {stats['flanks']}")
|
|
345
|
+
|
|
346
|
+
return stats
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main(input_tsv: str, output_tsv: str,
|
|
350
|
+
expected_length: Optional[int] = None,
|
|
351
|
+
max_mismatches: int = 3,
|
|
352
|
+
verbose: bool = False) -> Dict:
|
|
353
|
+
"""Main entry point for refine command."""
|
|
354
|
+
|
|
355
|
+
stats = refine_monomers_tsv(
|
|
356
|
+
input_tsv=input_tsv,
|
|
357
|
+
output_tsv=output_tsv,
|
|
358
|
+
expected_length=expected_length,
|
|
359
|
+
max_mismatches=max_mismatches,
|
|
360
|
+
verbose=verbose
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return stats
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
parser = argparse.ArgumentParser(
|
|
368
|
+
description="Refine ArraySplitter .monomers.tsv by splitting merged monomers and joining fragments"
|
|
369
|
+
)
|
|
370
|
+
parser.add_argument("-i", "--input", required=True,
|
|
371
|
+
help="Input .monomers.tsv file")
|
|
372
|
+
parser.add_argument("-o", "--output", required=True,
|
|
373
|
+
help="Output .monomers.tsv file (refined)")
|
|
374
|
+
parser.add_argument("-l", "--length", type=int, default=None,
|
|
375
|
+
help="Expected monomer length (auto-detected if not specified)")
|
|
376
|
+
parser.add_argument("-m", "--mismatches", type=int, default=3,
|
|
377
|
+
help="Maximum mismatches for cut sequence matching (default: 3)")
|
|
378
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
379
|
+
help="Verbose output")
|
|
380
|
+
|
|
381
|
+
args = parser.parse_args()
|
|
382
|
+
|
|
383
|
+
main(
|
|
384
|
+
input_tsv=args.input,
|
|
385
|
+
output_tsv=args.output,
|
|
386
|
+
expected_length=args.length,
|
|
387
|
+
max_mismatches=args.mismatches,
|
|
388
|
+
verbose=args.verbose
|
|
389
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ArraySplitter
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: De Novo Decomposition of Satellite DNA Arrays into Monomers within Telomere-to-Telomere Assemblies
|
|
5
5
|
Home-page: https://github.com/aglabx/ArraySplitter
|
|
6
6
|
Author: Aleksey Komissarov
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
ArraySplitter/__init__.py,sha256=
|
|
1
|
+
ArraySplitter/__init__.py,sha256=FzGKjGO2V7GyNG6KBBUhvlc9IeGFcEMJofm6vKxgGEg,169
|
|
2
2
|
ArraySplitter/classify.py,sha256=9OkmGecGvEa40mmIK4BPblVpuRBfZFS7oWbkETJ2idQ,21023
|
|
3
3
|
ArraySplitter/decompose.py,sha256=Voqcwgquy6-lSnVjusjPrKiszxG5x0SIhmjrtGCTVvo,70530
|
|
4
4
|
ArraySplitter/extract.py,sha256=bqlC8E6MjndMCBa44Zvv23gXmH2yr7IdQBXnc5foqhM,2300
|
|
5
|
-
ArraySplitter/
|
|
5
|
+
ArraySplitter/indel_chromosome_analysis.py,sha256=xSgJqY1Pmeie47hnuc2tKZvyuB0RWpfTAwYB_K3cJ5w,8283
|
|
6
|
+
ArraySplitter/main.py,sha256=hoFw4OMMF27nmz0eyg1-gMtpQ43sXBkqQAyads5Lmrw,8799
|
|
7
|
+
ArraySplitter/monomer_rotation.py,sha256=tdpqmNJYJQxatsEZ6v6ieEUizZQTS66ggrDKosQk1FM,8023
|
|
8
|
+
ArraySplitter/msa_refine.py,sha256=AHbUzRv7uCpxCJ9AFAWDEEwahz57wRiYT3XaFu9cpuw,8886
|
|
9
|
+
ArraySplitter/refine.py,sha256=CmXuUViCbbGo5ZtojfAzqyVaV2DA7RlIITFMoKN8ou4,12069
|
|
6
10
|
ArraySplitter/rotate.py,sha256=ZvH2WDnRlfeqbXtUDmrgqE2CQEGe5qKDVR-my_uKkLA,1231
|
|
7
11
|
ArraySplitter/core_functions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
12
|
ArraySplitter/core_functions/io/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -19,9 +23,9 @@ ArraySplitter/core_functions/tools/fs_tree.py,sha256=OHvamldtHW_7VEiWg7XiJ7Xwml2
|
|
|
19
23
|
ArraySplitter/core_functions/tools/parsers.py,sha256=J_rtt384ycfgrWlgrazb3qj7Sr0waWj7yShlril5rqM,6766
|
|
20
24
|
ArraySplitter/core_functions/tools/rotation.py,sha256=v_-3gkx8AR10RDCNMO82Pok1ZbqT2Zy9asyWZ6Ib_v8,3109
|
|
21
25
|
ArraySplitter/core_functions/tools/sequences.py,sha256=v74uHp1nTF_UvMRefYmJfMbnzdurRn995C06hugoSa4,441
|
|
22
|
-
arraysplitter-1.
|
|
23
|
-
arraysplitter-1.
|
|
24
|
-
arraysplitter-1.
|
|
25
|
-
arraysplitter-1.
|
|
26
|
-
arraysplitter-1.
|
|
27
|
-
arraysplitter-1.
|
|
26
|
+
arraysplitter-1.4.0.dist-info/licenses/LICENSE,sha256=m2dY1BxIk3gKB3jSa8kDtlSnNfW68VumXAQzGacCtvc,1063
|
|
27
|
+
arraysplitter-1.4.0.dist-info/METADATA,sha256=tyZZZNL2E6gqOWVVlmAEbevdHD2QmX8O_-ropuYoWvQ,10177
|
|
28
|
+
arraysplitter-1.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
29
|
+
arraysplitter-1.4.0.dist-info/entry_points.txt,sha256=H8EDFoNSqnM6J8m2w97H8HKjzAqs-iNTrIHk87-kfhY,60
|
|
30
|
+
arraysplitter-1.4.0.dist-info/top_level.txt,sha256=zk1yzRnjygCl8ZF8rx3w4WSWdW8FUw1-MqSk-s3NOHY,14
|
|
31
|
+
arraysplitter-1.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|