fusion-function 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.
- fusion_function/__init__.py +9 -0
- fusion_function/__main__.py +3 -0
- fusion_function/cli.py +27 -0
- fusion_function/data.py +2683 -0
- fusion_function/ensembl.py +149 -0
- fusion_function/fusion.py +1024 -0
- fusion_function/interpro.py +29 -0
- fusion_function/prebuilt.py +518 -0
- fusion_function/reference.py +112 -0
- fusion_function/reference_catalog.json +82 -0
- fusion_function/uniprot.py +315 -0
- fusion_function-0.2.1.dist-info/METADATA +98 -0
- fusion_function-0.2.1.dist-info/RECORD +16 -0
- fusion_function-0.2.1.dist-info/WHEEL +4 -0
- fusion_function-0.2.1.dist-info/entry_points.txt +3 -0
- fusion_function-0.2.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from itertools import product
|
|
4
|
+
from typing import Literal, NotRequired, TypedDict, cast
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .reference import ReferenceDatabase, get_reference
|
|
9
|
+
|
|
10
|
+
from .ensembl import (
|
|
11
|
+
EnsemblError,
|
|
12
|
+
ProteinFeature,
|
|
13
|
+
FeatureEvidence,
|
|
14
|
+
feature_identity,
|
|
15
|
+
Strand,
|
|
16
|
+
TranscriptProteinFeatureResult,
|
|
17
|
+
get_protein_domains,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FusionSideName = Literal["transcript1", "transcript2"]
|
|
22
|
+
FusionTerminus = Literal["N", "C"]
|
|
23
|
+
SpliceSiteType = Literal["donor", "acceptor"]
|
|
24
|
+
BreakpointBasedStatus = Literal["included", "disrupted", "excluded"]
|
|
25
|
+
PostSplicingStatus = Literal["preserved", "lost"]
|
|
26
|
+
PostTranslationStatus = Literal[
|
|
27
|
+
"preserved",
|
|
28
|
+
"frame_disrupted",
|
|
29
|
+
"translation_start_disrupted",
|
|
30
|
+
"translation_start_excluded",
|
|
31
|
+
"premature_termination_disrupted",
|
|
32
|
+
"premature_termination_excluded",
|
|
33
|
+
]
|
|
34
|
+
FrameStatus = Literal["in_frame", "out_of_frame"]
|
|
35
|
+
FusionFrameStatus = Literal["in_frame", "out_of_frame", "in_frame/out_of_frame"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
TranslationStartStatus = Literal[
|
|
39
|
+
"native_start_retained",
|
|
40
|
+
"alternative_start_found",
|
|
41
|
+
"alternative_start_not_found",
|
|
42
|
+
"native_start_lost",
|
|
43
|
+
]
|
|
44
|
+
TRANSLATION_START_STATUS_ORDER = (
|
|
45
|
+
"native_start_retained",
|
|
46
|
+
"alternative_start_found",
|
|
47
|
+
"alternative_start_not_found",
|
|
48
|
+
"native_start_lost",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CodingFragment(TypedDict):
|
|
53
|
+
side: FusionSideName
|
|
54
|
+
transcript_id: str
|
|
55
|
+
source_cds_start: int
|
|
56
|
+
source_cds_end: int
|
|
57
|
+
product_start: int
|
|
58
|
+
product_end: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SpliceProduct(TypedDict):
|
|
62
|
+
translation_start: int | None
|
|
63
|
+
stop_position: int | None
|
|
64
|
+
frame_status: FrameStatus
|
|
65
|
+
coding_fragments: list[CodingFragment]
|
|
66
|
+
translation_start_source: NotRequired[Literal["alternative", "not_found"]]
|
|
67
|
+
start_status: NotRequired[TranslationStartStatus]
|
|
68
|
+
start_transcript_id: NotRequired[str | None]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FusionCodingSegment(TypedDict):
|
|
72
|
+
side: FusionSideName
|
|
73
|
+
transcript_id: str
|
|
74
|
+
fusion_terminus: FusionTerminus
|
|
75
|
+
local_start: int
|
|
76
|
+
local_end: int
|
|
77
|
+
source_cds_start: int
|
|
78
|
+
source_cds_end: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FusionSpliceSite(TypedDict):
|
|
82
|
+
type: SpliceSiteType
|
|
83
|
+
side: FusionSideName
|
|
84
|
+
transcript_id: str
|
|
85
|
+
exon_number: int
|
|
86
|
+
genomic_position: int
|
|
87
|
+
premrna_position: int
|
|
88
|
+
local_position: int
|
|
89
|
+
intact: bool
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FusionSide(TypedDict):
|
|
93
|
+
side: FusionSideName
|
|
94
|
+
transcript_id: str
|
|
95
|
+
breakpoint: str
|
|
96
|
+
fusion_terminus: FusionTerminus
|
|
97
|
+
strand: Strand
|
|
98
|
+
inferred_breakpoint_orientation: Literal["L", "R"]
|
|
99
|
+
length: int
|
|
100
|
+
sequence: str
|
|
101
|
+
splice_sites: list[FusionSpliceSite]
|
|
102
|
+
coding_segments: list[FusionCodingSegment]
|
|
103
|
+
protein_features: list[ProteinFeature]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class LayoutSpliceSite(FusionSpliceSite):
|
|
107
|
+
fusion_position: int
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LayoutCodingSegment(FusionCodingSegment):
|
|
111
|
+
fusion_start: int
|
|
112
|
+
fusion_end: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FusionLayout(TypedDict):
|
|
116
|
+
n_terminal_side: FusionSideName
|
|
117
|
+
total_length: int
|
|
118
|
+
sequence: str
|
|
119
|
+
splice_sites: list[LayoutSpliceSite]
|
|
120
|
+
coding_segments: list[LayoutCodingSegment]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class FeatureProductPrediction(TypedDict):
|
|
124
|
+
post_splicing_status: PostSplicingStatus
|
|
125
|
+
post_translation_status: PostTranslationStatus | None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class AnnotatedProteinFeature(ProteinFeature):
|
|
129
|
+
transcript_id: str
|
|
130
|
+
breakpoint_based_status: BreakpointBasedStatus
|
|
131
|
+
breakpoint_retained_percent: float
|
|
132
|
+
post_splicing_status: str | None
|
|
133
|
+
post_translation_status: str | None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ResolvedAnnotatedProteinFeature(AnnotatedProteinFeature):
|
|
137
|
+
resolved_interpro_id: str | None
|
|
138
|
+
member_name: str | None
|
|
139
|
+
member_entry_type: str | None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AggregatedDomain(TypedDict):
|
|
143
|
+
transcript_id: str
|
|
144
|
+
interpro_id: str | None
|
|
145
|
+
name: str | None
|
|
146
|
+
specific_name: NotRequired[str | None]
|
|
147
|
+
domain_type: str | None
|
|
148
|
+
sources: list[str]
|
|
149
|
+
feature_ids: NotRequired[list[str]]
|
|
150
|
+
uniprot_accessions: NotRequired[list[str]]
|
|
151
|
+
uniprot_isoforms: NotRequired[list[str]]
|
|
152
|
+
evidence: NotRequired[list[FeatureEvidence]]
|
|
153
|
+
start: int
|
|
154
|
+
end: int
|
|
155
|
+
breakpoint_based_status: str
|
|
156
|
+
breakpoint_retained_percent: float
|
|
157
|
+
post_splicing_status: str | None
|
|
158
|
+
post_translation_status: str | None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class FunctionalDomain(TypedDict):
|
|
162
|
+
sources: list[str]
|
|
163
|
+
feature_ids: list[str]
|
|
164
|
+
uniprot_accessions: NotRequired[list[str]]
|
|
165
|
+
uniprot_isoforms: NotRequired[list[str]]
|
|
166
|
+
evidence: NotRequired[list[FeatureEvidence]]
|
|
167
|
+
transcript_id: str
|
|
168
|
+
interpro_id: str | None
|
|
169
|
+
name: str
|
|
170
|
+
domain_type: str
|
|
171
|
+
start: int
|
|
172
|
+
end: int
|
|
173
|
+
breakpoint_based_status: str
|
|
174
|
+
breakpoint_retained_percent: float
|
|
175
|
+
post_splicing_status: str | None
|
|
176
|
+
post_translation_status: str | None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class FusionDomainResult(TypedDict):
|
|
180
|
+
frame_status: FusionFrameStatus | None
|
|
181
|
+
domains: list[FunctionalDomain]
|
|
182
|
+
assumed_disruptive: NotRequired[bool]
|
|
183
|
+
translation_start: NotRequired[dict[str, str]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
STOP_CODONS = {"TAA", "TAG", "TGA"}
|
|
187
|
+
RELEVANT_ENTRY_TYPES = {
|
|
188
|
+
"family",
|
|
189
|
+
"domain",
|
|
190
|
+
"homologous_superfamily",
|
|
191
|
+
"binding_site",
|
|
192
|
+
"active_site",
|
|
193
|
+
"conserved_site",
|
|
194
|
+
"motif",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_breakpoint(breakpoint: str) -> tuple[str, int]:
|
|
199
|
+
"""Parse an exact chromosome:position breakpoint."""
|
|
200
|
+
chromosome, position = breakpoint.split(":", 1)
|
|
201
|
+
if "-" in position:
|
|
202
|
+
raise NotImplementedError(f"Breakpoint intervals are not supported: {breakpoint}")
|
|
203
|
+
return chromosome, int(position)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _infer_breakpoint_orientation(
|
|
207
|
+
strand: Strand, fusion_terminus: FusionTerminus
|
|
208
|
+
) -> Literal["L", "R"]:
|
|
209
|
+
"""Infer the retained genomic side from strand and fusion terminus."""
|
|
210
|
+
return "L" if (strand, fusion_terminus) in {(1, "N"), (-1, "C")} else "R"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _genomic_to_premrna(position: int, transcript: TranscriptProteinFeatureResult) -> int:
|
|
214
|
+
"""Convert a genomic position to transcript-oriented pre-mRNA coordinates."""
|
|
215
|
+
start = transcript["transcript_genomic_start"]
|
|
216
|
+
end = transcript["transcript_genomic_end"]
|
|
217
|
+
if not start <= position <= end:
|
|
218
|
+
raise ValueError(f"Position {position} lies outside transcript genomic span {start}-{end}")
|
|
219
|
+
return position - start + 1 if transcript["strand"] == 1 else end - position + 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _build_fusion_side(
|
|
223
|
+
side: FusionSideName,
|
|
224
|
+
transcript_id: str,
|
|
225
|
+
breakpoint: str,
|
|
226
|
+
fusion_terminus: FusionTerminus,
|
|
227
|
+
transcript: TranscriptProteinFeatureResult,
|
|
228
|
+
) -> FusionSide:
|
|
229
|
+
"""Build the retained pre-mRNA portion of one fusion partner."""
|
|
230
|
+
chromosome, breakpoint_position = _parse_breakpoint(breakpoint)
|
|
231
|
+
if str(transcript["chromosome"]).removeprefix("chr") != chromosome.removeprefix("chr"):
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Breakpoint chromosome {chromosome} does not match "
|
|
234
|
+
f"transcript chromosome {transcript['chromosome']}"
|
|
235
|
+
)
|
|
236
|
+
breakpoint_premrna = _genomic_to_premrna(breakpoint_position, transcript)
|
|
237
|
+
if fusion_terminus == "N":
|
|
238
|
+
retained_start, retained_end = 1, breakpoint_premrna
|
|
239
|
+
else:
|
|
240
|
+
retained_start, retained_end = breakpoint_premrna, transcript["premrna_length"]
|
|
241
|
+
splice_sites: list[FusionSpliceSite] = []
|
|
242
|
+
for site in transcript["splice_sites"]:
|
|
243
|
+
premrna_position = site["premrna_position"]
|
|
244
|
+
if retained_start <= premrna_position <= retained_end:
|
|
245
|
+
splice_sites.append(
|
|
246
|
+
{
|
|
247
|
+
"type": site["type"],
|
|
248
|
+
"side": side,
|
|
249
|
+
"transcript_id": transcript_id,
|
|
250
|
+
"exon_number": site["exon_number"],
|
|
251
|
+
"genomic_position": site["genomic_position"],
|
|
252
|
+
"premrna_position": premrna_position,
|
|
253
|
+
"local_position": premrna_position - retained_start + 1,
|
|
254
|
+
"intact": not site["disruption_start"]
|
|
255
|
+
<= breakpoint_position
|
|
256
|
+
<= site["disruption_end"],
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
coding_segments: list[FusionCodingSegment] = []
|
|
260
|
+
for block in transcript["cds_blocks"]:
|
|
261
|
+
start = max(block["premrna_start"], retained_start)
|
|
262
|
+
end = min(block["premrna_end"], retained_end)
|
|
263
|
+
if start > end:
|
|
264
|
+
continue
|
|
265
|
+
coding_segments.append(
|
|
266
|
+
{
|
|
267
|
+
"side": side,
|
|
268
|
+
"transcript_id": transcript_id,
|
|
269
|
+
"fusion_terminus": fusion_terminus,
|
|
270
|
+
"local_start": start - retained_start + 1,
|
|
271
|
+
"local_end": end - retained_start + 1,
|
|
272
|
+
"source_cds_start": block["cds_start"] + start - block["premrna_start"],
|
|
273
|
+
"source_cds_end": block["cds_start"] + end - block["premrna_start"],
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
return {
|
|
277
|
+
"side": side,
|
|
278
|
+
"transcript_id": transcript_id,
|
|
279
|
+
"breakpoint": f"{chromosome}:{breakpoint_position}",
|
|
280
|
+
"fusion_terminus": fusion_terminus,
|
|
281
|
+
"strand": transcript["strand"],
|
|
282
|
+
"inferred_breakpoint_orientation": _infer_breakpoint_orientation(
|
|
283
|
+
transcript["strand"], fusion_terminus
|
|
284
|
+
),
|
|
285
|
+
"length": retained_end - retained_start + 1,
|
|
286
|
+
"sequence": transcript["premrna_sequence"][retained_start - 1 : retained_end],
|
|
287
|
+
"splice_sites": splice_sites,
|
|
288
|
+
"coding_segments": coding_segments,
|
|
289
|
+
"protein_features": transcript["protein_features"],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _build_fusion_layout(
|
|
294
|
+
side1: FusionSide, side2: FusionSide, inserted_sequence: str = ""
|
|
295
|
+
) -> FusionLayout:
|
|
296
|
+
"""Arrange retained partners N-to-C and build the fusion pre-mRNA."""
|
|
297
|
+
if {side1["fusion_terminus"], side2["fusion_terminus"]} != {"N", "C"}:
|
|
298
|
+
raise ValueError("Fusion prediction requires one N-terminal and one C-terminal partner")
|
|
299
|
+
n_side, c_side = (side1, side2) if side1["fusion_terminus"] == "N" else (side2, side1)
|
|
300
|
+
inserted_sequence = inserted_sequence.upper()
|
|
301
|
+
c_offset = n_side["length"] + len(inserted_sequence)
|
|
302
|
+
splice_sites: list[LayoutSpliceSite] = [
|
|
303
|
+
{
|
|
304
|
+
**site,
|
|
305
|
+
"fusion_position": site["local_position"]
|
|
306
|
+
+ (0 if side["fusion_terminus"] == "N" else c_offset),
|
|
307
|
+
}
|
|
308
|
+
for side in (n_side, c_side)
|
|
309
|
+
for site in side["splice_sites"]
|
|
310
|
+
]
|
|
311
|
+
coding_segments: list[LayoutCodingSegment] = [
|
|
312
|
+
{
|
|
313
|
+
**segment,
|
|
314
|
+
"fusion_start": segment["local_start"]
|
|
315
|
+
+ (0 if side["fusion_terminus"] == "N" else c_offset),
|
|
316
|
+
"fusion_end": segment["local_end"]
|
|
317
|
+
+ (0 if side["fusion_terminus"] == "N" else c_offset),
|
|
318
|
+
}
|
|
319
|
+
for side in (n_side, c_side)
|
|
320
|
+
for segment in side["coding_segments"]
|
|
321
|
+
]
|
|
322
|
+
return {
|
|
323
|
+
"n_terminal_side": n_side["side"],
|
|
324
|
+
"total_length": n_side["length"] + len(inserted_sequence) + c_side["length"],
|
|
325
|
+
"sequence": n_side["sequence"] + inserted_sequence + c_side["sequence"],
|
|
326
|
+
"splice_sites": sorted(splice_sites, key=lambda x: x["fusion_position"]),
|
|
327
|
+
"coding_segments": sorted(coding_segments, key=lambda x: x["fusion_start"]),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _build_retained_side_layout(side: FusionSide) -> FusionLayout:
|
|
332
|
+
"""Model the known retained sequence without inventing the unknown partner."""
|
|
333
|
+
return {
|
|
334
|
+
"n_terminal_side": side["side"],
|
|
335
|
+
"total_length": side["length"],
|
|
336
|
+
"sequence": side["sequence"],
|
|
337
|
+
"splice_sites": [
|
|
338
|
+
{**site, "fusion_position": site["local_position"]} for site in side["splice_sites"]
|
|
339
|
+
],
|
|
340
|
+
"coding_segments": [
|
|
341
|
+
{**segment, "fusion_start": segment["local_start"], "fusion_end": segment["local_end"]}
|
|
342
|
+
for segment in side["coding_segments"]
|
|
343
|
+
],
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _generate_splice_patterns(splice_sites: list[LayoutSpliceSite]) -> list[list[LayoutSpliceSite]]:
|
|
348
|
+
"""Generate splice patterns from intact donor/acceptor alternatives."""
|
|
349
|
+
groups: list[list[LayoutSpliceSite]] = []
|
|
350
|
+
for site in sorted(
|
|
351
|
+
(site for site in splice_sites if site["intact"]), key=lambda x: x["fusion_position"]
|
|
352
|
+
):
|
|
353
|
+
if groups and groups[-1][0]["type"] == site["type"]:
|
|
354
|
+
groups[-1].append(site)
|
|
355
|
+
else:
|
|
356
|
+
groups.append([site])
|
|
357
|
+
if groups and groups[0][0]["type"] == "acceptor":
|
|
358
|
+
groups.pop(0)
|
|
359
|
+
if groups and groups[-1][0]["type"] == "donor":
|
|
360
|
+
groups.pop()
|
|
361
|
+
return [list(pattern) for pattern in product(*groups)] if groups else [[]]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _subtract_intervals(
|
|
365
|
+
start: int, end: int, intervals: list[tuple[int, int]]
|
|
366
|
+
) -> list[tuple[int, int]]:
|
|
367
|
+
"""Subtract inclusive intervals from another inclusive interval."""
|
|
368
|
+
segments = [(start, end)]
|
|
369
|
+
for remove_start, remove_end in intervals:
|
|
370
|
+
updated = []
|
|
371
|
+
for segment_start, segment_end in segments:
|
|
372
|
+
if remove_end < segment_start or remove_start > segment_end:
|
|
373
|
+
updated.append((segment_start, segment_end))
|
|
374
|
+
continue
|
|
375
|
+
if segment_start < remove_start:
|
|
376
|
+
updated.append((segment_start, remove_start - 1))
|
|
377
|
+
if segment_end > remove_end:
|
|
378
|
+
updated.append((remove_end + 1, segment_end))
|
|
379
|
+
segments = updated
|
|
380
|
+
return segments
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _get_product_frame_status(
|
|
384
|
+
translation_start: int | None, coding_fragments: list[CodingFragment]
|
|
385
|
+
) -> FrameStatus:
|
|
386
|
+
"""Determine whether retained CDS fragments preserve source codon phase."""
|
|
387
|
+
if translation_start is None:
|
|
388
|
+
return "out_of_frame"
|
|
389
|
+
translated_fragments = [
|
|
390
|
+
fragment for fragment in coding_fragments if fragment["product_end"] >= translation_start
|
|
391
|
+
]
|
|
392
|
+
if not translated_fragments:
|
|
393
|
+
return "out_of_frame"
|
|
394
|
+
return (
|
|
395
|
+
"in_frame"
|
|
396
|
+
if all(
|
|
397
|
+
(fragment["product_start"] - translation_start) % 3
|
|
398
|
+
== (fragment["source_cds_start"] - 1) % 3
|
|
399
|
+
for fragment in translated_fragments
|
|
400
|
+
)
|
|
401
|
+
else "out_of_frame"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _find_stop_position(sequence: str, translation_start: int | None) -> int | None:
|
|
406
|
+
"""Return the first base of the first in-frame stop codon."""
|
|
407
|
+
if translation_start is None:
|
|
408
|
+
return None
|
|
409
|
+
for position in range(translation_start - 1, len(sequence) - 2, 3):
|
|
410
|
+
if sequence[position : position + 3] in STOP_CODONS:
|
|
411
|
+
return position + 1
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _build_splice_product(
|
|
416
|
+
layout: FusionLayout, splice_pattern: list[LayoutSpliceSite]
|
|
417
|
+
) -> SpliceProduct:
|
|
418
|
+
"""Construct one spliced fusion transcript and determine frame and stop."""
|
|
419
|
+
if len(splice_pattern) % 2:
|
|
420
|
+
raise ValueError("Splice pattern must contain donor/acceptor pairs")
|
|
421
|
+
removed: list[tuple[int, int]] = []
|
|
422
|
+
for donor, acceptor in zip(splice_pattern[::2], splice_pattern[1::2]):
|
|
423
|
+
if donor["type"] != "donor" or acceptor["type"] != "acceptor":
|
|
424
|
+
raise ValueError("Splice pattern must alternate donor and acceptor sites")
|
|
425
|
+
if donor["fusion_position"] >= acceptor["fusion_position"]:
|
|
426
|
+
raise ValueError("Donor must precede acceptor")
|
|
427
|
+
if donor["fusion_position"] + 1 <= acceptor["fusion_position"] - 1:
|
|
428
|
+
removed.append((donor["fusion_position"] + 1, acceptor["fusion_position"] - 1))
|
|
429
|
+
retained: list[tuple[int, int, int, int]] = []
|
|
430
|
+
product_position = 1
|
|
431
|
+
for fusion_start, fusion_end in _subtract_intervals(1, layout["total_length"], removed):
|
|
432
|
+
length = fusion_end - fusion_start + 1
|
|
433
|
+
retained.append((fusion_start, fusion_end, product_position, product_position + length - 1))
|
|
434
|
+
product_position += length
|
|
435
|
+
sequence = "".join(
|
|
436
|
+
layout["sequence"][fusion_start - 1 : fusion_end]
|
|
437
|
+
for fusion_start, fusion_end, _, _ in retained
|
|
438
|
+
)
|
|
439
|
+
coding_fragments: list[CodingFragment] = []
|
|
440
|
+
for segment in layout["coding_segments"]:
|
|
441
|
+
for fusion_start, fusion_end, product_start, _ in retained:
|
|
442
|
+
start = max(segment["fusion_start"], fusion_start)
|
|
443
|
+
end = min(segment["fusion_end"], fusion_end)
|
|
444
|
+
if start > end:
|
|
445
|
+
continue
|
|
446
|
+
coding_fragments.append(
|
|
447
|
+
{
|
|
448
|
+
"side": segment["side"],
|
|
449
|
+
"transcript_id": segment["transcript_id"],
|
|
450
|
+
"source_cds_start": segment["source_cds_start"]
|
|
451
|
+
+ start
|
|
452
|
+
- segment["fusion_start"],
|
|
453
|
+
"source_cds_end": segment["source_cds_start"] + end - segment["fusion_start"],
|
|
454
|
+
"product_start": product_start + start - fusion_start,
|
|
455
|
+
"product_end": product_start + end - fusion_start,
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
coding_fragments.sort(key=lambda x: x["product_start"])
|
|
459
|
+
# Select one native initiation site in the complete spliced product. A
|
|
460
|
+
# retained C-terminal start can supply initiation if the N portion is UTR-only.
|
|
461
|
+
native_candidates = [
|
|
462
|
+
(fragment["product_start"] + 1 - fragment["source_cds_start"], fragment["transcript_id"])
|
|
463
|
+
for fragment in coding_fragments
|
|
464
|
+
if fragment["source_cds_start"] <= 1 <= fragment["source_cds_end"]
|
|
465
|
+
]
|
|
466
|
+
native_candidates = [
|
|
467
|
+
(position, transcript_id)
|
|
468
|
+
for position, transcript_id in native_candidates
|
|
469
|
+
if sequence[position - 1 : position + 2] == "ATG"
|
|
470
|
+
]
|
|
471
|
+
translation_start, start_transcript_id = next(iter(native_candidates), (None, None))
|
|
472
|
+
start_source: Literal["alternative", "not_found"] | None = None
|
|
473
|
+
native_start_retained = translation_start is not None
|
|
474
|
+
if translation_start is None and coding_fragments:
|
|
475
|
+
# Search after splicing, in any phase. Choosing the earliest retained ATG
|
|
476
|
+
# is a heuristic; compatibility with the original protein is checked
|
|
477
|
+
# separately. With no retained CDS there are no original domains to translate.
|
|
478
|
+
start_index = sequence.find("ATG")
|
|
479
|
+
translation_start = start_index + 1 if start_index >= 0 else None
|
|
480
|
+
start_source = "alternative" if translation_start is not None else "not_found"
|
|
481
|
+
result: SpliceProduct = {
|
|
482
|
+
"translation_start": translation_start,
|
|
483
|
+
"stop_position": _find_stop_position(sequence, translation_start),
|
|
484
|
+
"frame_status": _get_product_frame_status(translation_start, coding_fragments),
|
|
485
|
+
"coding_fragments": coding_fragments,
|
|
486
|
+
}
|
|
487
|
+
if start_source is not None:
|
|
488
|
+
result["translation_start_source"] = start_source
|
|
489
|
+
# This describes initiation of this product once, rather than separate
|
|
490
|
+
# outcomes for each occurrence of the same source transcript.
|
|
491
|
+
result["start_status"] = (
|
|
492
|
+
"native_start_retained"
|
|
493
|
+
if native_start_retained
|
|
494
|
+
else "native_start_lost"
|
|
495
|
+
if not coding_fragments
|
|
496
|
+
else "alternative_start_found"
|
|
497
|
+
if translation_start is not None
|
|
498
|
+
else "alternative_start_not_found"
|
|
499
|
+
)
|
|
500
|
+
result["start_transcript_id"] = start_transcript_id
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _covered_length(start: int, end: int, intervals: list[tuple[int, int]]) -> int:
|
|
505
|
+
"""Return the number of bases covered within an inclusive interval."""
|
|
506
|
+
intervals = sorted(
|
|
507
|
+
(max(start, a), min(end, b)) for a, b in intervals if b >= start and a <= end
|
|
508
|
+
)
|
|
509
|
+
if not intervals:
|
|
510
|
+
return 0
|
|
511
|
+
covered = 0
|
|
512
|
+
current_start, current_end = intervals[0]
|
|
513
|
+
for interval_start, interval_end in intervals[1:]:
|
|
514
|
+
if interval_start <= current_end + 1:
|
|
515
|
+
current_end = max(current_end, interval_end)
|
|
516
|
+
else:
|
|
517
|
+
covered += current_end - current_start + 1
|
|
518
|
+
current_start, current_end = interval_start, interval_end
|
|
519
|
+
return covered + current_end - current_start + 1
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _classify_feature(
|
|
523
|
+
feature: ProteinFeature, side: FusionSideName, splice_product: SpliceProduct
|
|
524
|
+
) -> FeatureProductPrediction:
|
|
525
|
+
"""Check the feature portion retained by the breakpoint in one splice product.
|
|
526
|
+
|
|
527
|
+
The caller clips CDS bounds to the retained portion. This lets a disrupted
|
|
528
|
+
domain undergo the same downstream checks as a fully included domain.
|
|
529
|
+
"""
|
|
530
|
+
fragments: list[tuple[int, int, int, int]] = []
|
|
531
|
+
for fragment in splice_product["coding_fragments"]:
|
|
532
|
+
if (
|
|
533
|
+
fragment["side"] != side
|
|
534
|
+
or fragment["source_cds_end"] < feature["cds_start"]
|
|
535
|
+
or fragment["source_cds_start"] > feature["cds_end"]
|
|
536
|
+
):
|
|
537
|
+
continue
|
|
538
|
+
source_start = max(feature["cds_start"], fragment["source_cds_start"])
|
|
539
|
+
source_end = min(feature["cds_end"], fragment["source_cds_end"])
|
|
540
|
+
fragments.append(
|
|
541
|
+
(
|
|
542
|
+
source_start,
|
|
543
|
+
source_end,
|
|
544
|
+
fragment["product_start"] + source_start - fragment["source_cds_start"],
|
|
545
|
+
fragment["product_start"] + source_end - fragment["source_cds_start"],
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
fragments.sort()
|
|
549
|
+
if (
|
|
550
|
+
_covered_length(
|
|
551
|
+
feature["cds_start"],
|
|
552
|
+
feature["cds_end"],
|
|
553
|
+
[(start, end) for start, end, _, _ in fragments],
|
|
554
|
+
)
|
|
555
|
+
< feature["cds_end"] - feature["cds_start"] + 1
|
|
556
|
+
):
|
|
557
|
+
return {"post_splicing_status": "lost", "post_translation_status": None}
|
|
558
|
+
translation_start = splice_product["translation_start"]
|
|
559
|
+
if translation_start is None:
|
|
560
|
+
status: PostTranslationStatus = (
|
|
561
|
+
"translation_start_excluded"
|
|
562
|
+
if splice_product.get("translation_start_source") == "not_found"
|
|
563
|
+
else "frame_disrupted"
|
|
564
|
+
)
|
|
565
|
+
elif translation_start > fragments[-1][3]:
|
|
566
|
+
status = "translation_start_excluded"
|
|
567
|
+
else:
|
|
568
|
+
# An alternative ATG can occur inside a retained feature. Compare phase
|
|
569
|
+
# and continuity only for bases translated from that start onward.
|
|
570
|
+
translated = [
|
|
571
|
+
(
|
|
572
|
+
start + max(0, translation_start - product_start),
|
|
573
|
+
end,
|
|
574
|
+
max(product_start, translation_start),
|
|
575
|
+
product_end,
|
|
576
|
+
)
|
|
577
|
+
for start, end, product_start, product_end in fragments
|
|
578
|
+
if product_end >= translation_start
|
|
579
|
+
]
|
|
580
|
+
if (
|
|
581
|
+
any(
|
|
582
|
+
current[0] != previous[1] + 1 or current[2] != previous[3] + 1
|
|
583
|
+
for previous, current in zip(translated, translated[1:])
|
|
584
|
+
)
|
|
585
|
+
or (translated[0][2] - translation_start) % 3 != (translated[0][0] - 1) % 3
|
|
586
|
+
):
|
|
587
|
+
status = "frame_disrupted"
|
|
588
|
+
elif (
|
|
589
|
+
splice_product["stop_position"] is not None
|
|
590
|
+
and splice_product["stop_position"] <= translated[0][2]
|
|
591
|
+
):
|
|
592
|
+
status = "premature_termination_excluded"
|
|
593
|
+
elif (
|
|
594
|
+
splice_product["stop_position"] is not None
|
|
595
|
+
and splice_product["stop_position"] <= translated[-1][3]
|
|
596
|
+
):
|
|
597
|
+
status = "premature_termination_disrupted"
|
|
598
|
+
elif translation_start > fragments[0][2]:
|
|
599
|
+
status = "translation_start_disrupted"
|
|
600
|
+
else:
|
|
601
|
+
status = "preserved"
|
|
602
|
+
return {"post_splicing_status": "preserved", "post_translation_status": status}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _combine_statuses(statuses: list[str | None], order: tuple[str, ...]) -> str | None:
|
|
606
|
+
"""Combine statuses into a stable slash-delimited summary."""
|
|
607
|
+
observed = {status for value in statuses if value is not None for status in value.split("/")}
|
|
608
|
+
return "/".join(status for status in order if status in observed) or None
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _annotate_feature_statuses(
|
|
612
|
+
side: FusionSide, splice_products: list[SpliceProduct]
|
|
613
|
+
) -> list[AnnotatedProteinFeature]:
|
|
614
|
+
"""Annotate each protein feature independently across predicted products."""
|
|
615
|
+
retained_cds = [
|
|
616
|
+
(segment["source_cds_start"], segment["source_cds_end"])
|
|
617
|
+
for segment in side["coding_segments"]
|
|
618
|
+
]
|
|
619
|
+
annotated: list[AnnotatedProteinFeature] = []
|
|
620
|
+
for feature in side["protein_features"]:
|
|
621
|
+
feature_length = feature["cds_end"] - feature["cds_start"] + 1
|
|
622
|
+
covered = _covered_length(feature["cds_start"], feature["cds_end"], retained_cds)
|
|
623
|
+
breakpoint_status: BreakpointBasedStatus = (
|
|
624
|
+
"excluded" if covered == 0 else "disrupted" if covered < feature_length else "included"
|
|
625
|
+
)
|
|
626
|
+
if covered > 0:
|
|
627
|
+
# An N- or C-terminal breakpoint retains a continuous source CDS
|
|
628
|
+
# interval, even when its genomic sequence spans several exons.
|
|
629
|
+
# Check that interval after splicing without treating bases already
|
|
630
|
+
# removed by the breakpoint as an additional splicing loss.
|
|
631
|
+
retained_feature: ProteinFeature = {
|
|
632
|
+
**feature,
|
|
633
|
+
"cds_start": max(feature["cds_start"], min(start for start, _ in retained_cds)),
|
|
634
|
+
"cds_end": min(feature["cds_end"], max(end for _, end in retained_cds)),
|
|
635
|
+
}
|
|
636
|
+
predictions = [
|
|
637
|
+
_classify_feature(retained_feature, side["side"], splice_product)
|
|
638
|
+
for splice_product in splice_products
|
|
639
|
+
]
|
|
640
|
+
splicing_status = _combine_statuses(
|
|
641
|
+
[x["post_splicing_status"] for x in predictions], ("preserved", "lost")
|
|
642
|
+
)
|
|
643
|
+
sequence_status = _combine_statuses(
|
|
644
|
+
[x["post_translation_status"] for x in predictions],
|
|
645
|
+
(
|
|
646
|
+
"preserved",
|
|
647
|
+
"frame_disrupted",
|
|
648
|
+
"translation_start_disrupted",
|
|
649
|
+
"translation_start_excluded",
|
|
650
|
+
"premature_termination_disrupted",
|
|
651
|
+
"premature_termination_excluded",
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
else:
|
|
655
|
+
splicing_status = None
|
|
656
|
+
sequence_status = None
|
|
657
|
+
annotated.append(
|
|
658
|
+
{
|
|
659
|
+
**feature,
|
|
660
|
+
"transcript_id": side["transcript_id"],
|
|
661
|
+
"breakpoint_based_status": breakpoint_status,
|
|
662
|
+
"breakpoint_retained_percent": round(100 * covered / feature_length, 1),
|
|
663
|
+
"post_splicing_status": splicing_status,
|
|
664
|
+
"post_translation_status": sequence_status,
|
|
665
|
+
}
|
|
666
|
+
)
|
|
667
|
+
return annotated
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _resolve_feature_metadata(
|
|
671
|
+
features: list[AnnotatedProteinFeature],
|
|
672
|
+
) -> list[ResolvedAnnotatedProteinFeature]:
|
|
673
|
+
"""Use metadata already resolved during preprocessing."""
|
|
674
|
+
resolved: list[ResolvedAnnotatedProteinFeature] = []
|
|
675
|
+
for feature in features:
|
|
676
|
+
entry_type = feature.get("feature_type") or feature.get("interpro_entry_type")
|
|
677
|
+
if feature["interpro_id"] and entry_type is None:
|
|
678
|
+
raise ValueError(
|
|
679
|
+
f"Missing InterPro entry type for {feature['interpro_id']}. "
|
|
680
|
+
"Reprocess the database with 'fusion-function prepare-data --preprocess-only DB'. "
|
|
681
|
+
"Metadata is fetched automatically; --interpro-entries FILE supplies a local list."
|
|
682
|
+
)
|
|
683
|
+
resolved.append(
|
|
684
|
+
{
|
|
685
|
+
**feature,
|
|
686
|
+
"resolved_interpro_id": feature["interpro_id"],
|
|
687
|
+
"member_name": feature.get("interpro_name"),
|
|
688
|
+
"member_entry_type": entry_type,
|
|
689
|
+
}
|
|
690
|
+
)
|
|
691
|
+
return resolved
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _aggregate_feature_group(features: list[ResolvedAnnotatedProteinFeature]) -> AggregatedDomain:
|
|
695
|
+
"""Combine identical intervals; keep alternative-product statuses together."""
|
|
696
|
+
interpro_id = next(
|
|
697
|
+
(
|
|
698
|
+
feature["resolved_interpro_id"]
|
|
699
|
+
for feature in features
|
|
700
|
+
if feature["resolved_interpro_id"] is not None
|
|
701
|
+
),
|
|
702
|
+
None,
|
|
703
|
+
)
|
|
704
|
+
result: AggregatedDomain = {
|
|
705
|
+
"transcript_id": features[0]["transcript_id"],
|
|
706
|
+
"interpro_id": interpro_id,
|
|
707
|
+
"name": next(
|
|
708
|
+
(feature["member_name"] for feature in features if feature["member_name"]),
|
|
709
|
+
next((feature["description"] for feature in features if feature["description"]), None),
|
|
710
|
+
),
|
|
711
|
+
"specific_name": next(
|
|
712
|
+
(
|
|
713
|
+
feature.get("panther_subfamily_description")
|
|
714
|
+
for feature in features
|
|
715
|
+
if feature.get("panther_subfamily_description")
|
|
716
|
+
),
|
|
717
|
+
None,
|
|
718
|
+
),
|
|
719
|
+
"domain_type": next(
|
|
720
|
+
(feature["member_entry_type"] for feature in features if feature["member_entry_type"]),
|
|
721
|
+
None,
|
|
722
|
+
),
|
|
723
|
+
"sources": sorted(
|
|
724
|
+
{feature["source"] for feature in features if feature["source"] is not None}
|
|
725
|
+
),
|
|
726
|
+
"feature_ids": sorted(
|
|
727
|
+
{
|
|
728
|
+
cast(str, feature.get("panther_subfamily_id") or feature["feature_id"])
|
|
729
|
+
for feature in features
|
|
730
|
+
if feature.get("panther_subfamily_id") or feature["feature_id"]
|
|
731
|
+
}
|
|
732
|
+
),
|
|
733
|
+
"start": features[0]["start"],
|
|
734
|
+
"end": features[0]["end"],
|
|
735
|
+
"breakpoint_based_status": cast(
|
|
736
|
+
str,
|
|
737
|
+
_combine_statuses(
|
|
738
|
+
[feature["breakpoint_based_status"] for feature in features],
|
|
739
|
+
("included", "disrupted", "excluded"),
|
|
740
|
+
),
|
|
741
|
+
),
|
|
742
|
+
"breakpoint_retained_percent": max(
|
|
743
|
+
feature["breakpoint_retained_percent"] for feature in features
|
|
744
|
+
),
|
|
745
|
+
"post_splicing_status": _combine_statuses(
|
|
746
|
+
[feature["post_splicing_status"] for feature in features], ("preserved", "lost")
|
|
747
|
+
),
|
|
748
|
+
"post_translation_status": _combine_statuses(
|
|
749
|
+
[feature["post_translation_status"] for feature in features],
|
|
750
|
+
(
|
|
751
|
+
"preserved",
|
|
752
|
+
"frame_disrupted",
|
|
753
|
+
"translation_start_disrupted",
|
|
754
|
+
"translation_start_excluded",
|
|
755
|
+
"premature_termination_disrupted",
|
|
756
|
+
"premature_termination_excluded",
|
|
757
|
+
),
|
|
758
|
+
),
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
accessions = sorted(
|
|
762
|
+
{feature["uniprot_accession"] for feature in features if "uniprot_accession" in feature}
|
|
763
|
+
)
|
|
764
|
+
isoforms = sorted(
|
|
765
|
+
{feature["uniprot_isoform"] for feature in features if "uniprot_isoform" in feature}
|
|
766
|
+
)
|
|
767
|
+
if accessions:
|
|
768
|
+
result["uniprot_accessions"] = accessions
|
|
769
|
+
if isoforms:
|
|
770
|
+
result["uniprot_isoforms"] = isoforms
|
|
771
|
+
evidence = {
|
|
772
|
+
tuple(sorted(item.items())) for feature in features for item in feature.get("evidence", [])
|
|
773
|
+
}
|
|
774
|
+
if any("evidence" in feature for feature in features):
|
|
775
|
+
result["evidence"] = [cast(FeatureEvidence, dict(item)) for item in sorted(evidence)]
|
|
776
|
+
return result
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _finalize_domains(domains: list[AggregatedDomain]) -> list[FunctionalDomain]:
|
|
780
|
+
"""Expose functional features with their true types, bounds and provenance.
|
|
781
|
+
|
|
782
|
+
Exact reference duplicates are grouped before this step. Overlap alone does
|
|
783
|
+
not imply equivalence: signatures can disagree on boundaries, and distinct
|
|
784
|
+
active/binding sites can occupy the same residues.
|
|
785
|
+
"""
|
|
786
|
+
unique: dict[tuple[object, ...], AggregatedDomain] = {}
|
|
787
|
+
for feature in domains:
|
|
788
|
+
identity = feature["interpro_id"] or (
|
|
789
|
+
tuple(feature["sources"]),
|
|
790
|
+
tuple(feature.get("feature_ids", [])),
|
|
791
|
+
feature["name"],
|
|
792
|
+
)
|
|
793
|
+
key = (
|
|
794
|
+
feature["transcript_id"],
|
|
795
|
+
identity,
|
|
796
|
+
feature["domain_type"],
|
|
797
|
+
feature["start"],
|
|
798
|
+
feature["end"],
|
|
799
|
+
)
|
|
800
|
+
if key not in unique:
|
|
801
|
+
unique[key] = cast(AggregatedDomain, dict(feature))
|
|
802
|
+
continue
|
|
803
|
+
previous = unique[key]
|
|
804
|
+
previous["sources"] = sorted(set(previous["sources"] + feature["sources"]))
|
|
805
|
+
previous["feature_ids"] = sorted(
|
|
806
|
+
set(previous.get("feature_ids", []) + feature.get("feature_ids", []))
|
|
807
|
+
)
|
|
808
|
+
previous["breakpoint_based_status"] = cast(
|
|
809
|
+
str,
|
|
810
|
+
_combine_statuses(
|
|
811
|
+
[previous["breakpoint_based_status"], feature["breakpoint_based_status"]],
|
|
812
|
+
("included", "disrupted", "excluded"),
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
previous["post_splicing_status"] = _combine_statuses(
|
|
816
|
+
[previous["post_splicing_status"], feature["post_splicing_status"]],
|
|
817
|
+
("preserved", "lost"),
|
|
818
|
+
)
|
|
819
|
+
previous["post_translation_status"] = _combine_statuses(
|
|
820
|
+
[previous["post_translation_status"], feature["post_translation_status"]],
|
|
821
|
+
(
|
|
822
|
+
"preserved",
|
|
823
|
+
"frame_disrupted",
|
|
824
|
+
"translation_start_disrupted",
|
|
825
|
+
"translation_start_excluded",
|
|
826
|
+
"premature_termination_disrupted",
|
|
827
|
+
"premature_termination_excluded",
|
|
828
|
+
),
|
|
829
|
+
)
|
|
830
|
+
final: list[FunctionalDomain] = []
|
|
831
|
+
for feature in unique.values():
|
|
832
|
+
if feature["domain_type"] not in RELEVANT_ENTRY_TYPES:
|
|
833
|
+
continue
|
|
834
|
+
name = feature["name"] or feature["interpro_id"] or "Unnamed functional feature"
|
|
835
|
+
if feature["domain_type"] == "family" and feature.get("specific_name"):
|
|
836
|
+
name = cast(str, feature["specific_name"])
|
|
837
|
+
public: FunctionalDomain = {
|
|
838
|
+
"transcript_id": feature["transcript_id"],
|
|
839
|
+
"interpro_id": feature["interpro_id"],
|
|
840
|
+
"name": name,
|
|
841
|
+
"domain_type": cast(str, feature["domain_type"]),
|
|
842
|
+
"start": feature["start"],
|
|
843
|
+
"end": feature["end"],
|
|
844
|
+
"sources": feature["sources"],
|
|
845
|
+
"feature_ids": feature.get("feature_ids", []),
|
|
846
|
+
"breakpoint_based_status": feature["breakpoint_based_status"],
|
|
847
|
+
"breakpoint_retained_percent": feature["breakpoint_retained_percent"],
|
|
848
|
+
"post_splicing_status": feature["post_splicing_status"],
|
|
849
|
+
"post_translation_status": feature["post_translation_status"],
|
|
850
|
+
}
|
|
851
|
+
if "uniprot_accessions" in feature:
|
|
852
|
+
public["uniprot_accessions"] = feature["uniprot_accessions"]
|
|
853
|
+
if "uniprot_isoforms" in feature:
|
|
854
|
+
public["uniprot_isoforms"] = feature["uniprot_isoforms"]
|
|
855
|
+
if "evidence" in feature:
|
|
856
|
+
public["evidence"] = feature["evidence"]
|
|
857
|
+
final.append(public)
|
|
858
|
+
return sorted(
|
|
859
|
+
final,
|
|
860
|
+
key=lambda item: (
|
|
861
|
+
item["transcript_id"],
|
|
862
|
+
item["start"],
|
|
863
|
+
item["end"],
|
|
864
|
+
item["domain_type"],
|
|
865
|
+
item["name"],
|
|
866
|
+
item["sources"],
|
|
867
|
+
item["feature_ids"],
|
|
868
|
+
),
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _summarize_frame_status(splice_products: list[SpliceProduct]) -> FusionFrameStatus:
|
|
873
|
+
"""Summarize frame status across predicted splice products."""
|
|
874
|
+
statuses = {product["frame_status"] for product in splice_products}
|
|
875
|
+
if statuses == {"in_frame"}:
|
|
876
|
+
return "in_frame"
|
|
877
|
+
if statuses == {"out_of_frame"}:
|
|
878
|
+
return "out_of_frame"
|
|
879
|
+
return "in_frame/out_of_frame"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _summarize_translation_start(products: list[SpliceProduct]) -> str:
|
|
883
|
+
"""Combine distinct product initiation outcomes in a stable slash order."""
|
|
884
|
+
return cast(
|
|
885
|
+
str,
|
|
886
|
+
_combine_statuses(
|
|
887
|
+
[product["start_status"] for product in products], TRANSLATION_START_STATUS_ORDER
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def annotate_fusion_domains(
|
|
893
|
+
transcript1_id: str | None = None,
|
|
894
|
+
transcript2_id: str | None = None,
|
|
895
|
+
breakpoint1: str | None = None,
|
|
896
|
+
breakpoint2: str | None = None,
|
|
897
|
+
gene1_terminus: FusionTerminus | None = None,
|
|
898
|
+
gene2_terminus: FusionTerminus | None = None,
|
|
899
|
+
inserted_sequence: str = "",
|
|
900
|
+
*,
|
|
901
|
+
reference: ReferenceDatabase | None = None,
|
|
902
|
+
database: str | Path | None = None,
|
|
903
|
+
release: int | None = None,
|
|
904
|
+
) -> FusionDomainResult | EnsemblError:
|
|
905
|
+
"""Annotate retained features and reconstruct a fusion when both ends permit it.
|
|
906
|
+
|
|
907
|
+
A single known partner is assumed disruptive and its retained portion is
|
|
908
|
+
spliced and checked. Every product lacking a native start but retaining CDS
|
|
909
|
+
uses its first ATG as a heuristic start. N/N and C/C pairs are unsupported.
|
|
910
|
+
"""
|
|
911
|
+
partners = [
|
|
912
|
+
("transcript1", transcript1_id, breakpoint1, gene1_terminus),
|
|
913
|
+
("transcript2", transcript2_id, breakpoint2, gene2_terminus),
|
|
914
|
+
]
|
|
915
|
+
if (
|
|
916
|
+
transcript1_id
|
|
917
|
+
and transcript2_id
|
|
918
|
+
and gene1_terminus == gene2_terminus
|
|
919
|
+
and gene1_terminus in ("N", "C")
|
|
920
|
+
):
|
|
921
|
+
raise NotImplementedError(
|
|
922
|
+
"N/N and C/C fusions are unsupported; two known partners require one N and one C terminus"
|
|
923
|
+
)
|
|
924
|
+
known_partners: list[tuple[FusionSideName, str, str, FusionTerminus]] = []
|
|
925
|
+
for side_name, transcript_id, breakpoint, terminus in partners:
|
|
926
|
+
if not transcript_id:
|
|
927
|
+
continue
|
|
928
|
+
if not breakpoint or terminus not in ("N", "C"):
|
|
929
|
+
return {
|
|
930
|
+
"error": f"{side_name} ({transcript_id}) requires a breakpoint and "
|
|
931
|
+
f"an N or C terminus; received breakpoint={breakpoint!r}, terminus={terminus!r}"
|
|
932
|
+
}
|
|
933
|
+
known_partners.append(
|
|
934
|
+
(
|
|
935
|
+
cast(FusionSideName, side_name),
|
|
936
|
+
transcript_id,
|
|
937
|
+
breakpoint,
|
|
938
|
+
cast(FusionTerminus, terminus),
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
if not known_partners:
|
|
942
|
+
return {"error": "At least one known transcript is required for feature annotation"}
|
|
943
|
+
|
|
944
|
+
reader = get_reference(reference=reference, database=database, release=release)
|
|
945
|
+
transcripts: dict[str, TranscriptProteinFeatureResult] = {}
|
|
946
|
+
for transcript_id in {partner[1] for partner in known_partners}:
|
|
947
|
+
transcript_result = get_protein_domains(transcript_id, reference=reader)
|
|
948
|
+
if "error" in transcript_result:
|
|
949
|
+
return cast(EnsemblError, transcript_result)
|
|
950
|
+
transcripts[transcript_id] = cast(TranscriptProteinFeatureResult, transcript_result)
|
|
951
|
+
sides: list[FusionSide] = []
|
|
952
|
+
for side_name, transcript_id, breakpoint, terminus in known_partners:
|
|
953
|
+
try:
|
|
954
|
+
sides.append(
|
|
955
|
+
_build_fusion_side(
|
|
956
|
+
side_name, transcript_id, breakpoint, terminus, transcripts[transcript_id]
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
except (ValueError, NotImplementedError) as error:
|
|
960
|
+
return {"error": f"{side_name} ({transcript_id}): {error}"}
|
|
961
|
+
|
|
962
|
+
can_reconstruct = len(sides) == 2 and {side["fusion_terminus"] for side in sides} == {"N", "C"}
|
|
963
|
+
splice_products: list[SpliceProduct] = []
|
|
964
|
+
products_by_side: dict[FusionSideName, list[SpliceProduct]] = {}
|
|
965
|
+
if can_reconstruct:
|
|
966
|
+
layout = _build_fusion_layout(sides[0], sides[1], inserted_sequence)
|
|
967
|
+
splice_products = [
|
|
968
|
+
_build_splice_product(layout, pattern)
|
|
969
|
+
for pattern in _generate_splice_patterns(layout["splice_sites"])
|
|
970
|
+
]
|
|
971
|
+
products_by_side = {side["side"]: splice_products for side in sides}
|
|
972
|
+
else:
|
|
973
|
+
for side in sides:
|
|
974
|
+
layout = _build_retained_side_layout(side)
|
|
975
|
+
products = [
|
|
976
|
+
_build_splice_product(layout, pattern)
|
|
977
|
+
for pattern in _generate_splice_patterns(layout["splice_sites"])
|
|
978
|
+
]
|
|
979
|
+
products_by_side[side["side"]] = products
|
|
980
|
+
splice_products.extend(products)
|
|
981
|
+
# Combine identical source intervals; different boundaries remain separate.
|
|
982
|
+
grouped: dict[tuple[object, ...], list[ResolvedAnnotatedProteinFeature]] = {}
|
|
983
|
+
for side in sides:
|
|
984
|
+
features = _resolve_feature_metadata(
|
|
985
|
+
_annotate_feature_statuses(side, products_by_side[side["side"]])
|
|
986
|
+
)
|
|
987
|
+
for feature in features:
|
|
988
|
+
key = (side["transcript_id"], *feature_identity(feature))
|
|
989
|
+
grouped.setdefault(key, []).append(feature)
|
|
990
|
+
domains = [_aggregate_feature_group(features) for features in grouped.values()]
|
|
991
|
+
result: FusionDomainResult = {
|
|
992
|
+
"frame_status": (
|
|
993
|
+
_summarize_frame_status(splice_products)
|
|
994
|
+
if can_reconstruct
|
|
995
|
+
or any(
|
|
996
|
+
product["translation_start"] is not None
|
|
997
|
+
and any(
|
|
998
|
+
fragment["product_end"] >= product["translation_start"]
|
|
999
|
+
for fragment in product["coding_fragments"]
|
|
1000
|
+
)
|
|
1001
|
+
for product in splice_products
|
|
1002
|
+
)
|
|
1003
|
+
else None
|
|
1004
|
+
),
|
|
1005
|
+
"domains": _finalize_domains(domains),
|
|
1006
|
+
}
|
|
1007
|
+
# Each complete product has one initiation prediction. Key it by the
|
|
1008
|
+
# transcript supplying the selected native start, or the intended initiating
|
|
1009
|
+
# transcript when that start is lost or an alternative start is used.
|
|
1010
|
+
default_start_transcript = next(
|
|
1011
|
+
(side["transcript_id"] for side in sides if side["fusion_terminus"] == "N"),
|
|
1012
|
+
sides[0]["transcript_id"],
|
|
1013
|
+
)
|
|
1014
|
+
start_products: dict[str, list[SpliceProduct]] = {}
|
|
1015
|
+
for splice_product in splice_products:
|
|
1016
|
+
transcript_id = splice_product["start_transcript_id"] or default_start_transcript
|
|
1017
|
+
start_products.setdefault(transcript_id, []).append(splice_product)
|
|
1018
|
+
result["translation_start"] = {
|
|
1019
|
+
transcript_id: _summarize_translation_start(products)
|
|
1020
|
+
for transcript_id, products in start_products.items()
|
|
1021
|
+
}
|
|
1022
|
+
if not can_reconstruct:
|
|
1023
|
+
result["assumed_disruptive"] = True
|
|
1024
|
+
return result
|