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.
- pytransdecoder/__init__.py +7 -0
- pytransdecoder/__main__.py +6 -0
- pytransdecoder/cli.py +781 -0
- pytransdecoder/core/__init__.py +1 -0
- pytransdecoder/core/base_freqs.py +71 -0
- pytransdecoder/core/decoys.py +113 -0
- pytransdecoder/core/fasta.py +55 -0
- pytransdecoder/core/gene_model.py +143 -0
- pytransdecoder/core/gff3_parser.py +58 -0
- pytransdecoder/core/homology_starts.py +54 -0
- pytransdecoder/core/kozak.py +180 -0
- pytransdecoder/core/models.py +123 -0
- pytransdecoder/core/orf_finder.py +317 -0
- pytransdecoder/core/presets.py +152 -0
- pytransdecoder/core/prng.py +54 -0
- pytransdecoder/core/pwm.py +174 -0
- pytransdecoder/core/sequence.py +19 -0
- pytransdecoder/core/translator.py +143 -0
- pytransdecoder/core/writers.py +69 -0
- pytransdecoder/genome.py +338 -0
- pytransdecoder/longorfs.py +180 -0
- pytransdecoder/predict.py +1621 -0
- pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
- pytransdecoder/presets/apicomplexan/kozak.tsv +9 -0
- pytransdecoder/presets/apicomplexan/preset.json +20 -0
- pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
- pytransdecoder/presets/ciliate/kozak.tsv +9 -0
- pytransdecoder/presets/ciliate/preset.json +20 -0
- pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
- pytransdecoder/presets/fungus/kozak.tsv +9 -0
- pytransdecoder/presets/fungus/preset.json +20 -0
- pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
- pytransdecoder/presets/green_alga/kozak.tsv +9 -0
- pytransdecoder/presets/green_alga/preset.json +20 -0
- pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
- pytransdecoder/presets/insect/kozak.tsv +9 -0
- pytransdecoder/presets/insect/preset.json +20 -0
- pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
- pytransdecoder/presets/mammal/kozak.tsv +9 -0
- pytransdecoder/presets/mammal/preset.json +20 -0
- pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
- pytransdecoder/presets/plant/kozak.tsv +9 -0
- pytransdecoder/presets/plant/preset.json +20 -0
- pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
- pytransdecoder-0.2.1.dist-info/METADATA +307 -0
- pytransdecoder-0.2.1.dist-info/RECORD +63 -0
- pytransdecoder-0.2.1.dist-info/WHEEL +5 -0
- pytransdecoder-0.2.1.dist-info/entry_points.txt +3 -0
- pytransdecoder-0.2.1.dist-info/licenses/LICENSE +28 -0
- 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]
|