ArraySplitter 1.3.0__tar.gz → 1.4.0__tar.gz
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-1.3.0/src/ArraySplitter.egg-info → arraysplitter-1.4.0}/PKG-INFO +1 -1
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/__init__.py +1 -1
- arraysplitter-1.4.0/src/ArraySplitter/indel_chromosome_analysis.py +242 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/main.py +94 -3
- arraysplitter-1.4.0/src/ArraySplitter/monomer_rotation.py +262 -0
- arraysplitter-1.4.0/src/ArraySplitter/msa_refine.py +273 -0
- arraysplitter-1.4.0/src/ArraySplitter/refine.py +389 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0/src/ArraySplitter.egg-info}/PKG-INFO +1 -1
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/SOURCES.txt +4 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/LICENSE +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/README.md +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/setup.cfg +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/setup.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/classify.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/__init__.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/__init__.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/block_file.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/fasta_reader.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/satellome_reader.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/tab_file.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/trf_reader.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/models/__init__.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/models/trf_model.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/__init__.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/anchor_graph.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/fs_tree.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/parsers.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/rotation.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/sequences.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/decompose.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/extract.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/rotate.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/dependency_links.txt +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/entry_points.txt +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/requires.txt +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/top_level.txt +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_ab_pattern.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_basic.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_bugfix_independent.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_decompose.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_fs_tree.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_fs_tree_simple.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_rotation.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_rotation_simple.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_sequences.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_sequences_simple.py +0 -0
- {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_variable_repeats.py +0 -0
|
@@ -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
|
|
@@ -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()
|
|
@@ -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()
|