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.
Files changed (47) hide show
  1. {arraysplitter-1.3.0/src/ArraySplitter.egg-info → arraysplitter-1.4.0}/PKG-INFO +1 -1
  2. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/__init__.py +1 -1
  3. arraysplitter-1.4.0/src/ArraySplitter/indel_chromosome_analysis.py +242 -0
  4. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/main.py +94 -3
  5. arraysplitter-1.4.0/src/ArraySplitter/monomer_rotation.py +262 -0
  6. arraysplitter-1.4.0/src/ArraySplitter/msa_refine.py +273 -0
  7. arraysplitter-1.4.0/src/ArraySplitter/refine.py +389 -0
  8. {arraysplitter-1.3.0 → arraysplitter-1.4.0/src/ArraySplitter.egg-info}/PKG-INFO +1 -1
  9. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/SOURCES.txt +4 -0
  10. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/LICENSE +0 -0
  11. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/README.md +0 -0
  12. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/setup.cfg +0 -0
  13. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/setup.py +0 -0
  14. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/classify.py +0 -0
  15. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/__init__.py +0 -0
  16. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/__init__.py +0 -0
  17. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/block_file.py +0 -0
  18. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/fasta_reader.py +0 -0
  19. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/satellome_reader.py +0 -0
  20. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/tab_file.py +0 -0
  21. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/io/trf_reader.py +0 -0
  22. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/models/__init__.py +0 -0
  23. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/models/trf_model.py +0 -0
  24. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/__init__.py +0 -0
  25. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/anchor_graph.py +0 -0
  26. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/fs_tree.py +0 -0
  27. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/parsers.py +0 -0
  28. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/rotation.py +0 -0
  29. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/core_functions/tools/sequences.py +0 -0
  30. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/decompose.py +0 -0
  31. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/extract.py +0 -0
  32. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter/rotate.py +0 -0
  33. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/dependency_links.txt +0 -0
  34. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/entry_points.txt +0 -0
  35. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/requires.txt +0 -0
  36. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/src/ArraySplitter.egg-info/top_level.txt +0 -0
  37. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_ab_pattern.py +0 -0
  38. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_basic.py +0 -0
  39. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_bugfix_independent.py +0 -0
  40. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_decompose.py +0 -0
  41. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_fs_tree.py +0 -0
  42. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_fs_tree_simple.py +0 -0
  43. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_rotation.py +0 -0
  44. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_rotation_simple.py +0 -0
  45. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_sequences.py +0 -0
  46. {arraysplitter-1.3.0 → arraysplitter-1.4.0}/tests/test_sequences_simple.py +0 -0
  47. {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.0
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
@@ -5,6 +5,6 @@
5
5
  # @author: Aleksey Komissarov
6
6
  # @contact: ad3002@gmail.com
7
7
 
8
- __version__ = "1.3.0"
8
+ __version__ = "1.4.0"
9
9
 
10
10
  # __all__ = []
@@ -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()