pytransdecoder 0.2.1__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.
Files changed (63) hide show
  1. pytransdecoder/__init__.py +7 -0
  2. pytransdecoder/__main__.py +6 -0
  3. pytransdecoder/cli.py +781 -0
  4. pytransdecoder/core/__init__.py +1 -0
  5. pytransdecoder/core/base_freqs.py +71 -0
  6. pytransdecoder/core/decoys.py +113 -0
  7. pytransdecoder/core/fasta.py +55 -0
  8. pytransdecoder/core/gene_model.py +143 -0
  9. pytransdecoder/core/gff3_parser.py +58 -0
  10. pytransdecoder/core/homology_starts.py +54 -0
  11. pytransdecoder/core/kozak.py +180 -0
  12. pytransdecoder/core/models.py +123 -0
  13. pytransdecoder/core/orf_finder.py +317 -0
  14. pytransdecoder/core/presets.py +152 -0
  15. pytransdecoder/core/prng.py +54 -0
  16. pytransdecoder/core/pwm.py +174 -0
  17. pytransdecoder/core/sequence.py +19 -0
  18. pytransdecoder/core/translator.py +143 -0
  19. pytransdecoder/core/writers.py +69 -0
  20. pytransdecoder/genome.py +338 -0
  21. pytransdecoder/longorfs.py +180 -0
  22. pytransdecoder/predict.py +1621 -0
  23. pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
  24. pytransdecoder/presets/apicomplexan/kozak.tsv +9 -0
  25. pytransdecoder/presets/apicomplexan/preset.json +20 -0
  26. pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
  27. pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
  28. pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
  29. pytransdecoder/presets/ciliate/kozak.tsv +9 -0
  30. pytransdecoder/presets/ciliate/preset.json +20 -0
  31. pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
  32. pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
  33. pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
  34. pytransdecoder/presets/fungus/kozak.tsv +9 -0
  35. pytransdecoder/presets/fungus/preset.json +20 -0
  36. pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
  37. pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
  38. pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
  39. pytransdecoder/presets/green_alga/kozak.tsv +9 -0
  40. pytransdecoder/presets/green_alga/preset.json +20 -0
  41. pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
  42. pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
  43. pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
  44. pytransdecoder/presets/insect/kozak.tsv +9 -0
  45. pytransdecoder/presets/insect/preset.json +20 -0
  46. pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
  47. pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
  48. pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
  49. pytransdecoder/presets/mammal/kozak.tsv +9 -0
  50. pytransdecoder/presets/mammal/preset.json +20 -0
  51. pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
  52. pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
  53. pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
  54. pytransdecoder/presets/plant/kozak.tsv +9 -0
  55. pytransdecoder/presets/plant/preset.json +20 -0
  56. pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
  57. pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
  58. pytransdecoder-0.2.1.dist-info/METADATA +307 -0
  59. pytransdecoder-0.2.1.dist-info/RECORD +63 -0
  60. pytransdecoder-0.2.1.dist-info/WHEEL +5 -0
  61. pytransdecoder-0.2.1.dist-info/entry_points.txt +3 -0
  62. pytransdecoder-0.2.1.dist-info/licenses/LICENSE +28 -0
  63. pytransdecoder-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,123 @@
1
+ """
2
+ Data models for PyTransDecoder
3
+
4
+ Minimal ORF representation - replaces the 5,588-line Gene_obj.pm
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, Tuple, List
9
+
10
+
11
+ @dataclass
12
+ class ORF:
13
+ """
14
+ Open Reading Frame representation.
15
+
16
+ This replaces Gene_obj.pm with only the essential functionality
17
+ that TransDecoder actually uses.
18
+ """
19
+ # Identifiers
20
+ transcript_id: str # asmbl_id in Gene_obj
21
+ gene_id: str # TU_feat_name
22
+ model_id: str # Model_feat_name
23
+
24
+ # Coordinates (1-based, as in original TransDecoder)
25
+ start: int
26
+ end: int
27
+ strand: str # '+' or '-'
28
+
29
+ # Sequences
30
+ sequence: str # CDS nucleotide sequence
31
+ protein: str # Translated protein
32
+
33
+ # Metadata
34
+ orf_type: str # complete, 5prime_partial, 3prime_partial, internal
35
+ length: int # Length in nucleotides
36
+ frame: int = 0 # Reading frame (0, 1, 2)
37
+ phase: int = 0 # CDS phase for GFF3 output
38
+ description: str = "" # com_name in Gene_obj
39
+
40
+ # For selection/ranking (populated in Predict phase)
41
+ markov_scores: Optional[List[float]] = None
42
+ blast_hits: List[str] = field(default_factory=list)
43
+ pfam_hits: List[str] = field(default_factory=list)
44
+
45
+ def __post_init__(self):
46
+ """Set defaults after initialization"""
47
+ if not self.description:
48
+ orient = '+' if self.strand == '+' else '-'
49
+ self.description = f"ORF type:{self.orf_type} ({orient})"
50
+
51
+ def get_cds_length(self) -> int:
52
+ """Get CDS length in nucleotides (replaces Gene_obj::get_CDS_length)"""
53
+ return self.length
54
+
55
+ def get_model_span(self) -> Tuple[int, int]:
56
+ """Get (min, max) coordinates (replaces Gene_obj::get_model_span)"""
57
+ return (min(self.start, self.end), max(self.start, self.end))
58
+
59
+ def to_gff3(self, source: str = "transdecoder") -> str:
60
+ """
61
+ Generate GFF3 representation (replaces Gene_obj::to_GFF3_format).
62
+
63
+ Returns multi-line string with gene, mRNA, and CDS features.
64
+ """
65
+ lend, rend = self.get_model_span()
66
+
67
+ # Attributes for each feature type
68
+ gene_attrs = f"ID={self.gene_id};Name=ORF_{self.gene_id}"
69
+ mrna_attrs = f"ID={self.model_id};Parent={self.gene_id};Name={self.model_id}"
70
+ cds_attrs = f"ID=cds.{self.model_id};Parent={self.model_id}"
71
+
72
+ lines = []
73
+
74
+ # Gene feature
75
+ lines.append("\t".join([
76
+ self.transcript_id,
77
+ source,
78
+ "gene",
79
+ str(lend),
80
+ str(rend),
81
+ ".",
82
+ self.strand,
83
+ ".",
84
+ gene_attrs
85
+ ]))
86
+
87
+ # mRNA feature
88
+ lines.append("\t".join([
89
+ self.transcript_id,
90
+ source,
91
+ "mRNA",
92
+ str(lend),
93
+ str(rend),
94
+ ".",
95
+ self.strand,
96
+ ".",
97
+ mrna_attrs
98
+ ]))
99
+
100
+ # CDS feature (with phase)
101
+ lines.append("\t".join([
102
+ self.transcript_id,
103
+ source,
104
+ "CDS",
105
+ str(lend),
106
+ str(rend),
107
+ ".",
108
+ self.strand,
109
+ str(self.phase),
110
+ cds_attrs
111
+ ]))
112
+
113
+ return "\n".join(lines)
114
+
115
+ def to_fasta_cds(self) -> str:
116
+ """Generate FASTA format for CDS sequence"""
117
+ header = f">{self.model_id} type:{self.orf_type} {self.transcript_id}:{self.start}-{self.end}({self.strand})"
118
+ return f"{header}\n{self.sequence}"
119
+
120
+ def to_fasta_protein(self, genetic_code: str = "universal") -> str:
121
+ """Generate FASTA format for protein sequence"""
122
+ header = f">{self.model_id} type:{self.orf_type} gc:{genetic_code} {self.transcript_id}:{self.start}-{self.end}({self.strand})"
123
+ return f"{header}\n{self.protein}"
@@ -0,0 +1,317 @@
1
+ """
2
+ ORF (Open Reading Frame) finder
3
+
4
+ Replicates the core algorithm from Longest_orf.pm
5
+ """
6
+
7
+ from typing import List, Dict, Tuple
8
+ from .models import ORF
9
+ from .translator import Translator
10
+ from .sequence import reverse_complement
11
+
12
+
13
+ class ORFFinder:
14
+ """
15
+ Find all Open Reading Frames in nucleotide sequences.
16
+
17
+ Replicates the functionality of Longest_orf.pm
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ min_protein_length: int = 100,
23
+ allow_5prime_partial: bool = True,
24
+ allow_3prime_partial: bool = True,
25
+ allow_non_met_starts: bool = False,
26
+ forward_strand: bool = True,
27
+ reverse_strand: bool = True,
28
+ genetic_code: str = "universal",
29
+ complete_orfs_only: bool = False
30
+ ):
31
+ """
32
+ Initialize ORF finder.
33
+
34
+ Args:
35
+ min_protein_length: Minimum protein length in amino acids
36
+ allow_5prime_partial: Allow ORFs without start codon
37
+ allow_3prime_partial: Allow ORFs without stop codon
38
+ allow_non_met_starts: Allow non-Met start codons
39
+ forward_strand: Analyze forward strand
40
+ reverse_strand: Analyze reverse strand
41
+ genetic_code: Genetic code name
42
+ complete_orfs_only: Only return complete ORFs (has start and stop)
43
+ """
44
+ self.min_protein_length = min_protein_length
45
+ self.min_nt_length = min_protein_length * 3
46
+ self.allow_5prime_partial = allow_5prime_partial
47
+ self.allow_3prime_partial = allow_3prime_partial
48
+ self.allow_non_met_starts = allow_non_met_starts
49
+ self.forward_strand = forward_strand
50
+ self.reverse_strand = reverse_strand
51
+ self.genetic_code = genetic_code
52
+ self.complete_orfs_only = complete_orfs_only
53
+
54
+ self.translator = Translator(genetic_code)
55
+ self.stop_codons = set(self.translator.get_stop_codons())
56
+ self.start_codons = set(self.translator.get_start_codons(allow_non_met_starts))
57
+
58
+ def find_all_orfs(self, sequence: str, seq_id: str) -> List[ORF]:
59
+ """
60
+ Find all ORFs in a sequence.
61
+
62
+ Args:
63
+ sequence: Nucleotide sequence
64
+ seq_id: Sequence identifier
65
+
66
+ Returns:
67
+ List of ORF objects, sorted by length (longest first)
68
+ """
69
+ orfs = []
70
+ original_length = len(sequence)
71
+
72
+ # Forward strand
73
+ if self.forward_strand:
74
+ orfs.extend(self._find_orfs_strand(sequence, seq_id, '+', original_length))
75
+
76
+ # Reverse strand
77
+ if self.reverse_strand:
78
+ rev_seq = reverse_complement(sequence)
79
+ orfs.extend(self._find_orfs_strand(rev_seq, seq_id, '-', original_length))
80
+
81
+ # Longest first, as LongOrfs orders them (and hence numbers them .p1, .p2, ...).
82
+ # Perl generates ORFs per strand (+ then -) in order of start position and then
83
+ # applies two stable 'reverse sort's by length, so ties keep generation order.
84
+ strand_rank = {'+': 0, '-': 1}
85
+ orfs.sort(key=lambda x: (-x._perl_sort_len, strand_rank[x.strand], x._strand_start))
86
+
87
+ return orfs
88
+
89
+ def _find_orfs_strand(self, sequence: str, seq_id: str, strand: str, original_length: int) -> List[ORF]:
90
+ """Find all ORFs on one strand"""
91
+ sequence = sequence.upper()
92
+ orfs = []
93
+
94
+ # Find stops and starts for each frame
95
+ for frame in range(3):
96
+ # Find all stop codons in this frame
97
+ stops, real_stops = self._find_stops_in_frame(sequence, frame)
98
+
99
+ # Find all start codons in this frame (passing real_stops to avoid starts at stop positions)
100
+ starts = self._find_starts_in_frame(sequence, frame, real_stops)
101
+
102
+ # Build ORFs from start-stop pairs
103
+ orfs.extend(
104
+ self._build_orfs_from_coords(
105
+ starts, stops, real_stops, sequence, seq_id, strand, frame, original_length
106
+ )
107
+ )
108
+
109
+ return orfs
110
+
111
+ def _find_stops_in_frame(self, sequence: str, frame: int) -> Tuple[List[int], set]:
112
+ """
113
+ Find all stop codon positions in a specific frame.
114
+
115
+ Returns:
116
+ Tuple of (all_stops, real_stop_set) where:
117
+ - all_stops: List of all stop positions (real + implicit)
118
+ - real_stop_set: Set of positions that are actual stop codons
119
+ """
120
+ stops = []
121
+ real_stops = set()
122
+
123
+ for i in range(frame, len(sequence) - 2, 3):
124
+ codon = sequence[i:i+3]
125
+ if codon in self.stop_codons:
126
+ stops.append(i)
127
+ real_stops.add(i)
128
+
129
+ # Add implicit stops at end for 3' partials (NOT real stop codons)
130
+ # Perl adds seq_length, seq_length-1, seq_length-2 as implicit stops
131
+ if self.allow_3prime_partial:
132
+ seq_len = len(sequence)
133
+ for offset in [0, -1, -2]:
134
+ pos = seq_len + offset
135
+ if pos >= 0 and pos not in real_stops:
136
+ stops.append(pos)
137
+
138
+ return stops, real_stops
139
+
140
+ def _find_starts_in_frame(self, sequence: str, frame: int, real_stops: set) -> List[int]:
141
+ """
142
+ Find all start codon positions in a specific frame.
143
+
144
+ Args:
145
+ sequence: DNA/RNA sequence
146
+ frame: Reading frame (0, 1, or 2)
147
+ real_stops: Set of positions that are actual stop codons (to avoid adding stops as starts)
148
+
149
+ Returns:
150
+ List of start positions
151
+ """
152
+ starts = []
153
+
154
+ # Add implicit start at beginning for 5' partials
155
+ # But don't add it if it's already a stop codon (Perl's logic)
156
+ if self.allow_5prime_partial and frame not in real_stops:
157
+ starts.append(frame)
158
+
159
+ # Find explicit start codons
160
+ for i in range(frame, len(sequence) - 2, 3):
161
+ codon = sequence[i:i+3]
162
+ if codon in self.start_codons:
163
+ starts.append(i)
164
+
165
+ return starts
166
+
167
+ def _build_orfs_from_coords(
168
+ self,
169
+ starts: List[int],
170
+ stops: List[int],
171
+ real_stops: set,
172
+ sequence: str,
173
+ seq_id: str,
174
+ strand: str,
175
+ frame: int,
176
+ original_length: int
177
+ ) -> List[ORF]:
178
+ """
179
+ Build ORF objects from start and stop coordinate lists.
180
+
181
+ Important: Replicates Perl's algorithm where:
182
+ - Each stop codon is used only once
183
+ - Each start codon is paired with only its first valid stop
184
+ This prevents generating overlapping ORFs.
185
+
186
+ Args:
187
+ real_stops: Set of positions that are actual stop codons
188
+ (vs implicit end-of-sequence stops)
189
+ """
190
+ orfs = []
191
+ last_used_stop = -1 # Track the last stop position used in this frame
192
+
193
+ # Iterate through starts in order
194
+ for start_pos in starts:
195
+ # Skip starts that come before or at the last used stop
196
+ # This prevents reusing the same stop codon
197
+ if start_pos <= last_used_stop:
198
+ continue
199
+
200
+ # Find the first valid stop after this start (Perl's algorithm)
201
+ valid_stop = None
202
+ for stop_pos in stops:
203
+ if (stop_pos > start_pos and
204
+ (stop_pos - start_pos) % 3 == 0):
205
+ valid_stop = stop_pos
206
+ break # Take first valid stop (replicates Perl's 'last')
207
+
208
+ if valid_stop is None:
209
+ continue
210
+
211
+ # Mark this stop as used
212
+ last_used_stop = valid_stop
213
+
214
+ # Check if this is a real stop codon or implicit end-of-sequence
215
+ is_real_stop = valid_stop in real_stops
216
+
217
+ # Extract ORF sequence
218
+ # For real stops: include the 3 bases of the stop codon
219
+ # For implicit stops (3' partial): extract up to end of sequence
220
+ if is_real_stop:
221
+ # Real stop codon - include it (valid_stop points to start of stop codon)
222
+ orf_end = valid_stop + 3
223
+ else:
224
+ # Implicit stop (3' partial) - extract to end of sequence
225
+ orf_end = len(sequence)
226
+
227
+ orf_seq = sequence[start_pos:orf_end]
228
+
229
+ # Ensure CDS length is a multiple of 3 (trim partial codons)
230
+ remainder = len(orf_seq) % 3
231
+ if remainder != 0:
232
+ orf_seq = orf_seq[:-remainder]
233
+ orf_end = start_pos + len(orf_seq)
234
+
235
+ # Length check. As in TransDecoder.LongOrfs, length is counted in codons up to
236
+ # and including the stop; for 3' partials the implicit stop just past the
237
+ # sequence end counts as that final codon, so both complete and 3'-partial
238
+ # ORFs must encode at least (min_protein_length - 1) amino acids.
239
+ min_len = self.min_nt_length if is_real_stop else self.min_nt_length - 3
240
+ if len(orf_seq) < min_len:
241
+ continue
242
+
243
+ # Translate
244
+ protein = self.translator.translate(orf_seq)
245
+
246
+ # Verify no internal stops (sanity check from Perl code)
247
+ stop_count = protein.count('*')
248
+ if stop_count > 1:
249
+ # Should not happen if algorithm is correct
250
+ continue
251
+
252
+ # Determine ORF type
253
+ has_start = sequence[start_pos:start_pos+3] in self.start_codons
254
+ has_stop = is_real_stop # Only real stop codons count
255
+
256
+ is_5prime_partial = not has_start
257
+ is_3prime_partial = not has_stop
258
+
259
+ # Determine ORF type string
260
+ if not is_5prime_partial and not is_3prime_partial:
261
+ orf_type = "complete"
262
+ elif not is_5prime_partial:
263
+ orf_type = "3prime_partial"
264
+ elif not is_3prime_partial:
265
+ orf_type = "5prime_partial"
266
+ else:
267
+ orf_type = "internal"
268
+
269
+ # Skip if complete_orfs_only and not complete
270
+ if self.complete_orfs_only and orf_type != "complete":
271
+ continue
272
+
273
+ # Calculate coordinates
274
+ # For reverse strand, transform back to original sequence coordinates
275
+ # Perl uses: revcomp_coord($pos, $seq_length) = $seq_length - $pos + 1
276
+ # Perl: start_pos_adj = start_pos + 1, stop_pos_adj = stop_pos + 1 + 2
277
+ # Perl ALWAYS adds +3 to stop position, even for implicit stops
278
+ # This affects both coordinate reporting and length calculations
279
+ if strand == '-':
280
+ # Transform coordinates back to original sequence. For 3' partial
281
+ # ORFs, use the trimmed sequence end rather than the implicit stop
282
+ # position so the reported span matches the extracted CDS.
283
+ start_adj = start_pos + 1 # 1-based position of start
284
+ end_adj = orf_end
285
+
286
+ # Transform to original sequence coordinates
287
+ orf_start = original_length - start_adj + 1
288
+ orf_end_coord = original_length - end_adj + 1
289
+ else:
290
+ # Forward strand: convert to 1-based. For 3' partial ORFs, use the
291
+ # trimmed CDS endpoint rather than valid_stop + 3.
292
+ orf_start = start_pos + 1 # 1-based position of start
293
+ orf_end_coord = orf_end
294
+
295
+ # Create ORF object
296
+ orf = ORF(
297
+ transcript_id=seq_id,
298
+ gene_id="", # Will be set later
299
+ model_id="", # Will be set later
300
+ start=orf_start,
301
+ end=orf_end_coord,
302
+ strand=strand,
303
+ sequence=orf_seq,
304
+ protein=protein,
305
+ orf_type=orf_type,
306
+ length=len(orf_seq),
307
+ frame=frame
308
+ )
309
+ # Ordering used by LongOrfs (see find_all_orfs): Perl sorts on the untrimmed
310
+ # length (through the end of the sequence for 3' partials) and generates
311
+ # ORFs in order of start position within a strand, across frames.
312
+ orf._perl_sort_len = (orf_end if is_real_stop else len(sequence)) - start_pos
313
+ orf._strand_start = start_pos
314
+
315
+ orfs.append(orf)
316
+
317
+ return orfs
@@ -0,0 +1,152 @@
1
+ """
2
+ Presets: models and ORF acceptance thresholds learned from a large reference transcriptome, for inputs
3
+ too small (or too partial) to learn from (TransDecoderPaper evidence E21).
4
+
5
+ A preset directory holds
6
+ preset.json name, source, genetic code, GC %, long-ORF auto-accept length (nt or null),
7
+ decoy strata, q levels
8
+ hexamer.scores.gz the reference run's Markov coding model
9
+ training_orfs.cds.gz the reference run's training ORFs (fix the median per-base coding contrast
10
+ used by start refinement)
11
+ kozak.tsv the reference run's start-context model (optional)
12
+ thresholds.tsv.gz per decoy stratum and q level, the smallest frame contrast accepted in the
13
+ reference run's target-decoy q-values (decoy_qvalues.tsv)
14
+ (uncompressed files are read too). Bundled presets live in pytransdecoder/presets/<name>; the same
15
+ files ship with TransDecoder (Perl) in PerlLib/presets.
16
+ With a preset, Predict scores the input with the preset model and keeps a candidate ORF (in-frame score
17
+ > 0, frame contrast > 0) when its contrast reaches the preset threshold of its stratum at the chosen q.
18
+ """
19
+ import gzip
20
+ import json
21
+ import shutil
22
+ from collections import defaultdict
23
+ from pathlib import Path
24
+ from typing import Dict, List, Optional, Tuple
25
+
26
+ Q_LEVELS = (0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5)
27
+ BUNDLED_DIR = Path(__file__).resolve().parent.parent / "presets"
28
+
29
+
30
+ def bundled_presets() -> List[str]:
31
+ if not BUNDLED_DIR.is_dir():
32
+ return []
33
+ return sorted(d.name for d in BUNDLED_DIR.iterdir() if (d / "preset.json").exists())
34
+
35
+
36
+ def resolve(name_or_dir: str) -> Path:
37
+ """A bundled preset name or a preset directory."""
38
+ p = Path(name_or_dir)
39
+ if (p / "preset.json").exists():
40
+ return p
41
+ if (BUNDLED_DIR / name_or_dir / "preset.json").exists():
42
+ return BUNDLED_DIR / name_or_dir
43
+ raise FileNotFoundError(f"no preset '{name_or_dir}': not a preset directory or a bundled preset "
44
+ f"({', '.join(bundled_presets()) or 'none installed'})")
45
+
46
+
47
+ def _find(path: Path, name: str) -> Optional[Path]:
48
+ for f in (path / (name + ".gz"), path / name):
49
+ if f.exists():
50
+ return f
51
+ return None
52
+
53
+
54
+ def _open(path: Path):
55
+ return gzip.open(path, "rt") if path.suffix == ".gz" else open(path)
56
+
57
+
58
+ def copy_plain(src: Path, dest: Path) -> None:
59
+ """Copy a preset file, decompressing .gz."""
60
+ with _open(src) as fin, open(dest, "w") as fout:
61
+ shutil.copyfileobj(fin, fout)
62
+
63
+
64
+ def _gzip_copy(src: Path, dest: Path) -> None:
65
+ # mtime 0: identical bytes for identical content (reproducible bundled files)
66
+ with open(src, "rb") as fin, open(dest, "wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as fout:
67
+ shutil.copyfileobj(fin, fout)
68
+
69
+
70
+ def build_preset(run_workdir: Path, out_dir: Path, name: str, gc_pct: float,
71
+ auto_accept_nt: Optional[float], genetic_code: str, strata: str, source: str = "") -> Path:
72
+ """Write a preset from a finished decoy-rule Predict run (its <input>.transdecoder_dir)."""
73
+ run_workdir, out_dir = Path(run_workdir), Path(out_dir)
74
+ qfile = run_workdir / "decoy_qvalues.tsv"
75
+ if not qfile.exists():
76
+ raise FileNotFoundError(f"{qfile} not found: build presets from a finished Predict run")
77
+ out_dir.mkdir(parents=True, exist_ok=True)
78
+ _gzip_copy(run_workdir / "hexamer.scores", out_dir / "hexamer.scores.gz")
79
+ _gzip_copy(run_workdir / "__checkpoints_TDpredict" / "top_training_orfs.cds", out_dir / "training_orfs.cds.gz")
80
+ kozak = run_workdir / "start_refinement.kozak.tsv"
81
+ if kozak.exists():
82
+ shutil.copy(kozak, out_dir / "kozak.tsv")
83
+ by_stratum = defaultdict(list)
84
+ with open(qfile) as fh:
85
+ for line in fh:
86
+ if line.startswith("#"):
87
+ continue
88
+ _, stratum, contrast, q = line.rstrip("\n").split("\t")
89
+ by_stratum[stratum].append((float(q), float(contrast)))
90
+ lines = ["#stratum\tq\tmin_contrast\tn_targets\n"]
91
+ for stratum in sorted(by_stratum, key=lambda s: tuple(_key(s))):
92
+ rows = by_stratum[stratum]
93
+ for level in Q_LEVELS:
94
+ ok = [c for q, c in rows if q <= level]
95
+ lines.append(f"{stratum}\t{level:g}\t{min(ok) if ok else 'inf'}\t{len(rows)}\n")
96
+ with gzip.GzipFile(out_dir / "thresholds.tsv.gz", "wb", mtime=0) as fh:
97
+ fh.write("".join(lines).encode())
98
+ meta = {"name": name, "source": source, "genetic_code": genetic_code, "gc_pct": round(gc_pct, 2),
99
+ "auto_accept_nt": None if auto_accept_nt in (None, float("inf")) else auto_accept_nt,
100
+ "strata": strata, "q_levels": list(Q_LEVELS)}
101
+ (out_dir / "preset.json").write_text(json.dumps(meta, indent=1) + "\n")
102
+ return out_dir
103
+
104
+
105
+ def _key(stratum: str) -> Tuple:
106
+ out = []
107
+ for x in stratum.split(","):
108
+ out.append(x == "True" if x in ("True", "False") else int(x))
109
+ return tuple(out)
110
+
111
+
112
+ class Preset:
113
+ def __init__(self, path):
114
+ self.path = Path(path)
115
+ self.meta = json.loads((self.path / "preset.json").read_text())
116
+ self.hexamer_scores = _find(self.path, "hexamer.scores")
117
+ self.training_orfs = _find(self.path, "training_orfs.cds")
118
+ self.kozak = self.path / "kozak.tsv" if (self.path / "kozak.tsv").exists() else None
119
+ self.strata = self.meta["strata"]
120
+ self._thr: Dict[float, Dict[Tuple, float]] = defaultdict(dict)
121
+ with _open(_find(self.path, "thresholds.tsv")) as fh:
122
+ for line in fh:
123
+ if line.startswith("#"):
124
+ continue
125
+ stratum, q, c, _ = line.rstrip("\n").split("\t")
126
+ self._thr[float(q)][_key(stratum)] = float(c)
127
+
128
+ @property
129
+ def auto_accept_nt(self) -> float:
130
+ a = self.meta.get("auto_accept_nt")
131
+ return float("inf") if a is None else float(a)
132
+
133
+ def level(self, q: float) -> float:
134
+ """Largest stored q level <= q (the smallest level if q is below all of them)."""
135
+ levels = sorted(self._thr)
136
+ ok = [x for x in levels if x <= q + 1e-12]
137
+ return ok[-1] if ok else levels[0]
138
+
139
+ def threshold(self, stratum: Tuple, q: float) -> float:
140
+ """Minimum frame contrast at q for a stratum; strata absent from the reference use the nearest
141
+ ORF-length bin with the same remaining keys, then any transcript-length bin of the same type."""
142
+ table = self._thr[self.level(q)]
143
+ if stratum in table:
144
+ return table[stratum]
145
+ rest = stratum[1:]
146
+ same = [k for k in table if k[1:] == rest]
147
+ if not same and len(stratum) == 3:
148
+ same = [k for k in table if k[2] == stratum[2]]
149
+ if not same:
150
+ same = list(table)
151
+ best = min(same, key=lambda k: (abs(k[0] - stratum[0]), abs(k[1] - stratum[1]) if len(k) > 1 else 0))
152
+ return table[best]
@@ -0,0 +1,54 @@
1
+ """
2
+ Portable pseudo-random numbers for decoy shuffles, identical in PyTransDecoder and TransDecoder (Perl).
3
+
4
+ xoshiro128** (Blackman & Vigna 2018) on 32-bit words, seeded from FNV-1a hashes of a seed string.
5
+ All arithmetic stays below 2^53, so the Perl implementation (PerlLib/Decoys.pm) gives the same stream.
6
+ """
7
+ from typing import List
8
+
9
+ M32 = 0xFFFFFFFF
10
+
11
+
12
+ def fnv1a32(text: str) -> int:
13
+ h = 0x811C9DC5
14
+ for b in text.encode():
15
+ h = ((h ^ b) * 0x01000193) & M32
16
+ return h
17
+
18
+
19
+ def _rotl(x: int, k: int) -> int:
20
+ return ((x << k) | (x >> (32 - k))) & M32
21
+
22
+
23
+ class Xoshiro128ss:
24
+ def __init__(self, seed: str):
25
+ s = [fnv1a32(f"{seed}#{i}") for i in range(4)]
26
+ if not any(s):
27
+ s[0] = 1
28
+ self.s: List[int] = s
29
+
30
+ def next32(self) -> int:
31
+ s = self.s
32
+ result = (_rotl((s[1] * 5) & M32, 7) * 9) & M32
33
+ t = (s[1] << 9) & M32
34
+ s[2] ^= s[0]
35
+ s[3] ^= s[1]
36
+ s[1] ^= s[2]
37
+ s[0] ^= s[3]
38
+ s[2] ^= t
39
+ s[3] = _rotl(s[3], 11)
40
+ return result
41
+
42
+ def below(self, n: int) -> int:
43
+ """Uniform integer in [0, n) by rejection (n >= 1)."""
44
+ limit = (2 ** 32 // n) * n
45
+ while True:
46
+ r = self.next32()
47
+ if r < limit:
48
+ return r % n
49
+
50
+ def shuffle(self, items: list) -> None:
51
+ """Fisher-Yates, in place."""
52
+ for i in range(len(items) - 1, 0, -1):
53
+ j = self.below(i + 1)
54
+ items[i], items[j] = items[j], items[i]