orfdata 0.1.0__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.
- orfdata/__init__.py +36 -0
- orfdata/build.py +625 -0
- orfdata/catalog.py +637 -0
- orfdata/cli.py +156 -0
- orfdata/data/orf-evidence/AUTHOR-LICENSE.txt +21 -0
- orfdata/data/orf-evidence/NOTICE.txt +32 -0
- orfdata/data/orf-evidence/catalog.json.gz +0 -0
- orfdata/data/orf-evidence/manifest.json +30 -0
- orfdata/data/uorf-evidence/AUTHOR-LICENSE.txt +21 -0
- orfdata/data/uorf-evidence/NOTICE.txt +32 -0
- orfdata/data/uorf-evidence/catalog.json.gz +0 -0
- orfdata/data/uorf-evidence/manifest.json +29 -0
- orfdata/initiation_context.py +178 -0
- orfdata/model.py +188 -0
- orfdata/sequence.py +30 -0
- orfdata/version.py +1 -0
- orfdata-0.1.0.dist-info/METADATA +98 -0
- orfdata-0.1.0.dist-info/RECORD +22 -0
- orfdata-0.1.0.dist-info/WHEEL +5 -0
- orfdata-0.1.0.dist-info/entry_points.txt +2 -0
- orfdata-0.1.0.dist-info/licenses/LICENSE +201 -0
- orfdata-0.1.0.dist-info/top_level.txt +1 -0
orfdata/catalog.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""Versioned reference ORFs and source-specific translation evidence.
|
|
2
|
+
|
|
3
|
+
Genomic coordinates are 1-based inclusive on the explicitly named assembly.
|
|
4
|
+
These are reference observations, not evidence for a patient's mutant protein.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import gzip
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from dataclasses import asdict, dataclass, replace
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .initiation_context import KOZAK_PREFERENCES, InitiationContext
|
|
17
|
+
from .model import ORFModel, ReferenceORF, _species
|
|
18
|
+
from .sequence import CODONS, reverse_complement_dna
|
|
19
|
+
|
|
20
|
+
EVIDENCE_METHODS = ("ribosome", "shotgun", "mhc", "hla", "ms_unclassified", "ms")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _positive_integer(value, name):
|
|
24
|
+
if type(value) is not int or value < 1:
|
|
25
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _contig(value):
|
|
29
|
+
value = str(value)
|
|
30
|
+
if value.startswith("chr"):
|
|
31
|
+
value = value[3:]
|
|
32
|
+
return "MT" if value in ("M", "MT") else value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PeptideEvidence:
|
|
37
|
+
"""A reported MS peptide, with every compatible protein interval.
|
|
38
|
+
|
|
39
|
+
``protein_intervals`` are 1-based inclusive. ``mapping_count`` and
|
|
40
|
+
spectrum counts have the scope of the named source, not all human proteins
|
|
41
|
+
or independent biological specimens. Missing counts remain unknown.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
method: str
|
|
45
|
+
sequence: str
|
|
46
|
+
protein_intervals: tuple
|
|
47
|
+
source: str
|
|
48
|
+
accession: str
|
|
49
|
+
mapping_count: Optional[int] = None
|
|
50
|
+
spectrum_count: Optional[int] = None
|
|
51
|
+
datasets: tuple = ()
|
|
52
|
+
sequence_match: str = "exact"
|
|
53
|
+
attribution: str = "source_orf"
|
|
54
|
+
quality: str = "reported"
|
|
55
|
+
review_ratings: tuple = ()
|
|
56
|
+
spectrum_ids: tuple = ()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def unique_in_source(self):
|
|
60
|
+
"""Whether the source reports one protein mapping (not global novelty)."""
|
|
61
|
+
return self.mapping_count == 1
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_supporting(self):
|
|
65
|
+
"""Reported or convincingly reviewed, excluding low/mixed reviews."""
|
|
66
|
+
return self.quality in ("reported", "reviewed_support")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ORFEvidence:
|
|
71
|
+
"""Observations attached to one exact ORF, not a binary protein-existence call."""
|
|
72
|
+
|
|
73
|
+
ribosome_studies: tuple = ()
|
|
74
|
+
peptides: tuple = ()
|
|
75
|
+
|
|
76
|
+
def to_dict(self):
|
|
77
|
+
return asdict(self)
|
|
78
|
+
|
|
79
|
+
def has_evidence(self, method, *, require_unique=False, require_reviewed=False):
|
|
80
|
+
"""Select reference observations by assay, without clinical promotion."""
|
|
81
|
+
if method not in EVIDENCE_METHODS:
|
|
82
|
+
raise ValueError(f"Unknown evidence method: {method}")
|
|
83
|
+
if method == "ribosome":
|
|
84
|
+
if require_unique or require_reviewed:
|
|
85
|
+
raise ValueError("MS filters do not apply to ribosome evidence")
|
|
86
|
+
return bool(self.ribosome_studies)
|
|
87
|
+
return any(
|
|
88
|
+
(method == "ms" or p.method == method or (method == "mhc" and p.method == "hla"))
|
|
89
|
+
and p.is_supporting
|
|
90
|
+
and (not require_unique or p.unique_in_source)
|
|
91
|
+
and (not require_reviewed or p.quality == "reviewed_support")
|
|
92
|
+
for p in self.peptides
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ORFRecord:
|
|
98
|
+
"""One reported, spliced reference ORF, not a gene-wide evidence label."""
|
|
99
|
+
|
|
100
|
+
orf_id: str
|
|
101
|
+
gene_id: str
|
|
102
|
+
gene_name: str
|
|
103
|
+
transcript_id: str
|
|
104
|
+
contig: str
|
|
105
|
+
strand: str
|
|
106
|
+
coding_blocks: tuple
|
|
107
|
+
protein_sequence: str
|
|
108
|
+
biotype: str
|
|
109
|
+
ribosome_studies: tuple
|
|
110
|
+
peptides: tuple
|
|
111
|
+
genome_build: str = "GRCh38"
|
|
112
|
+
initiation_context: Optional[InitiationContext] = None
|
|
113
|
+
taxon_id: Optional[int] = None
|
|
114
|
+
species: Optional[str] = None
|
|
115
|
+
translation_table: int = 1
|
|
116
|
+
source: str = ""
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def model(self):
|
|
120
|
+
"""Source-defined ORF identity, independent of its supporting evidence."""
|
|
121
|
+
return ORFModel(
|
|
122
|
+
orf_id=self.orf_id,
|
|
123
|
+
transcript_id=self.transcript_id,
|
|
124
|
+
gene_id=self.gene_id,
|
|
125
|
+
gene_name=self.gene_name,
|
|
126
|
+
contig=self.contig,
|
|
127
|
+
strand=self.strand,
|
|
128
|
+
coding_blocks=self.coding_blocks,
|
|
129
|
+
protein_sequence=self.protein_sequence,
|
|
130
|
+
orf_type=self.biotype,
|
|
131
|
+
genome_build=self.genome_build,
|
|
132
|
+
taxon_id=self.taxon_id,
|
|
133
|
+
species=self.species,
|
|
134
|
+
translation_table=self.translation_table,
|
|
135
|
+
source=self.source,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def evidence(self):
|
|
140
|
+
return ORFEvidence(self.ribosome_studies, self.peptides)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_model(cls, model, evidence=None, *, initiation_context=None):
|
|
144
|
+
"""Construct a record from the generic model and independent evidence."""
|
|
145
|
+
evidence = ORFEvidence() if evidence is None else evidence
|
|
146
|
+
fields = model.to_dict()
|
|
147
|
+
fields["biotype"] = fields.pop("orf_type")
|
|
148
|
+
return cls(
|
|
149
|
+
**fields,
|
|
150
|
+
ribosome_studies=evidence.ribosome_studies,
|
|
151
|
+
peptides=evidence.peptides,
|
|
152
|
+
initiation_context=initiation_context,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def bind(self, transcript):
|
|
156
|
+
"""Validate and bind this ORF to a PyEnsembl transcript without relabelling it."""
|
|
157
|
+
return ReferenceORF(self, transcript)
|
|
158
|
+
|
|
159
|
+
def to_dict(self):
|
|
160
|
+
"""Return a serializable copy of this record."""
|
|
161
|
+
result = asdict(self)
|
|
162
|
+
if self.initiation_context is not None:
|
|
163
|
+
result["initiation_context"] = self.initiation_context.to_dict()
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def map_initiation_position(self, position, *, genome_build):
|
|
167
|
+
"""Map a 1-based genomic base into the spliced initiation context.
|
|
168
|
+
|
|
169
|
+
Kozak numbering has A of ATG at +1 and no zero. Return None for
|
|
170
|
+
unavailable context or a base outside the retained window. Upstream
|
|
171
|
+
context, start and coding-body roles remain separate; +4 has both
|
|
172
|
+
initiation-context and coding consequences.
|
|
173
|
+
"""
|
|
174
|
+
_positive_integer(position, "position")
|
|
175
|
+
if genome_build != self.genome_build:
|
|
176
|
+
raise ValueError(f"Expected genome build {self.genome_build}, got {genome_build}")
|
|
177
|
+
context = self.initiation_context
|
|
178
|
+
if context is None or position not in context.genomic_positions:
|
|
179
|
+
return None
|
|
180
|
+
index = context.genomic_positions.index(position)
|
|
181
|
+
relative = context.relative_positions[index]
|
|
182
|
+
sequence = context.upstream + context.start_codon + context.downstream
|
|
183
|
+
coding = self.map_genomic_position(position, genome_build=genome_build)
|
|
184
|
+
roles = ["initiation_context"]
|
|
185
|
+
if relative in KOZAK_PREFERENCES:
|
|
186
|
+
roles.append("kozak_preference_position")
|
|
187
|
+
if coding is not None:
|
|
188
|
+
roles.append(coding["role"])
|
|
189
|
+
return {
|
|
190
|
+
"orf_id": self.orf_id,
|
|
191
|
+
"genome_build": self.genome_build,
|
|
192
|
+
"contig": self.contig,
|
|
193
|
+
"position": position,
|
|
194
|
+
"context_position": relative,
|
|
195
|
+
"roles": roles,
|
|
196
|
+
"transcript_base": sequence[index],
|
|
197
|
+
"genomic_ref": sequence[index]
|
|
198
|
+
if self.strand == "+"
|
|
199
|
+
else reverse_complement_dna(sequence[index]),
|
|
200
|
+
"coding_annotation": coding,
|
|
201
|
+
"initiation_assessment": context.assessment,
|
|
202
|
+
"mutant_translation_evidence": "not_assessed",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def annotate_initiation_snv(self, position, ref, alt, *, genome_build):
|
|
206
|
+
"""Compare REF-validated reference/alternate initiation contexts.
|
|
207
|
+
|
|
208
|
+
Alleles are single A/C/G/T bases on the forward genomic strand.
|
|
209
|
+
REF mismatch and unavailable/outside context raise ValueError. This
|
|
210
|
+
deliberately does not reconstruct indels, splice events or fusions.
|
|
211
|
+
Directional preference changes are sequence priors, not measured
|
|
212
|
+
initiation changes or proof of a translated mutant protein.
|
|
213
|
+
"""
|
|
214
|
+
if ref not in ("A", "C", "G", "T") or alt not in ("A", "C", "G", "T") or ref == alt:
|
|
215
|
+
raise ValueError("Provide distinct single-base forward-genomic REF and ALT")
|
|
216
|
+
annotation = self.map_initiation_position(position, genome_build=genome_build)
|
|
217
|
+
if annotation is None:
|
|
218
|
+
raise ValueError("Initiation context is unavailable or does not cover position")
|
|
219
|
+
if ref != annotation["genomic_ref"]:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"REF mismatch: expected {}, got {}".format(annotation["genomic_ref"], ref)
|
|
222
|
+
)
|
|
223
|
+
context = self.initiation_context
|
|
224
|
+
index = context.genomic_positions.index(position)
|
|
225
|
+
transcript_alt = alt if self.strand == "+" else reverse_complement_dna(alt)
|
|
226
|
+
sequence = context.upstream + context.start_codon + context.downstream
|
|
227
|
+
altered = sequence[:index] + transcript_alt + sequence[index + 1 :]
|
|
228
|
+
start = len(context.upstream)
|
|
229
|
+
mutant = replace(
|
|
230
|
+
context,
|
|
231
|
+
upstream=altered[:start],
|
|
232
|
+
start_codon=altered[start : start + 3],
|
|
233
|
+
downstream=altered[start + 3 :],
|
|
234
|
+
)
|
|
235
|
+
changes = []
|
|
236
|
+
relative = annotation["context_position"]
|
|
237
|
+
if 1 <= relative <= 3:
|
|
238
|
+
changes.append(
|
|
239
|
+
"ATG_lost"
|
|
240
|
+
if context.start_codon == "ATG"
|
|
241
|
+
else "ATG_gained"
|
|
242
|
+
if mutant.start_codon == "ATG"
|
|
243
|
+
else "start_codon_changed"
|
|
244
|
+
)
|
|
245
|
+
if relative in KOZAK_PREFERENCES:
|
|
246
|
+
preferred = KOZAK_PREFERENCES[relative]
|
|
247
|
+
before, after = sequence[index] in preferred, transcript_alt in preferred
|
|
248
|
+
changes.append(
|
|
249
|
+
"kozak_preference_gained"
|
|
250
|
+
if not before and after
|
|
251
|
+
else "kozak_preference_lost"
|
|
252
|
+
if before and not after
|
|
253
|
+
else "kozak_preference_unchanged"
|
|
254
|
+
)
|
|
255
|
+
codons = None
|
|
256
|
+
if relative > 0:
|
|
257
|
+
offset = start + (relative - 1) // 3 * 3
|
|
258
|
+
before, after = sequence[offset : offset + 3], altered[offset : offset + 3]
|
|
259
|
+
if len(before) == 3 and all(b in "ACGT" for b in before + after):
|
|
260
|
+
if self.translation_table != 1:
|
|
261
|
+
raise ValueError("Unsupported ORF translation table")
|
|
262
|
+
table = CODONS
|
|
263
|
+
codons = {
|
|
264
|
+
"reference_codon": before,
|
|
265
|
+
"alternate_codon": after,
|
|
266
|
+
"reference_codon_translation": table[before],
|
|
267
|
+
"alternate_codon_translation": table[after],
|
|
268
|
+
"interpretation": "codon_translation_if_frame_retained; initiation_not_demonstrated",
|
|
269
|
+
}
|
|
270
|
+
return dict(
|
|
271
|
+
**annotation,
|
|
272
|
+
variant={"ref": ref, "alt": alt, "transcript_alt": transcript_alt},
|
|
273
|
+
sequence_changes=changes,
|
|
274
|
+
reference_context=context.to_dict(),
|
|
275
|
+
alternate_context=mutant.to_dict(),
|
|
276
|
+
codon_consequence=codons,
|
|
277
|
+
translation_change="not_measured",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def has_evidence(self, method, *, require_unique=False, require_reviewed=False):
|
|
281
|
+
return self.evidence.has_evidence(
|
|
282
|
+
method, require_unique=require_unique, require_reviewed=require_reviewed
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _coding_offset(self, position):
|
|
286
|
+
"""Map a placed base without constructing peptide evidence exports."""
|
|
287
|
+
offset = 0
|
|
288
|
+
blocks = self.coding_blocks if self.strand == "+" else self.coding_blocks[::-1]
|
|
289
|
+
for start, end in blocks:
|
|
290
|
+
if start <= position <= end:
|
|
291
|
+
return offset + (position - start if self.strand == "+" else end - position)
|
|
292
|
+
offset += end - start + 1
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def map_genomic_position(self, position, *, genome_build):
|
|
296
|
+
"""Map one genomic base into the spliced ORF and reference MS coverage.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
position : int
|
|
301
|
+
A 1-based genomic position on this record's contig.
|
|
302
|
+
genome_build : str
|
|
303
|
+
Must equal the record's assembly. No implicit liftover is done.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
dict or None
|
|
308
|
+
Coding nucleotide offset (0-based), residue position (1-based),
|
|
309
|
+
reference residue, start/stop/body role and covering reference
|
|
310
|
+
peptides. An intronic or outside position returns None. This does
|
|
311
|
+
not compute a mutant sequence or demonstrate mutant translation.
|
|
312
|
+
"""
|
|
313
|
+
_positive_integer(position, "position")
|
|
314
|
+
if genome_build != self.genome_build:
|
|
315
|
+
raise ValueError(f"Expected genome build {self.genome_build}, got {genome_build}")
|
|
316
|
+
offset = self._coding_offset(position)
|
|
317
|
+
if offset is None:
|
|
318
|
+
return None
|
|
319
|
+
aa = offset // 3 + 1
|
|
320
|
+
stop = aa > len(self.protein_sequence)
|
|
321
|
+
covered = [p for p in self.peptides if any(a <= aa <= b for a, b in p.protein_intervals)]
|
|
322
|
+
return {
|
|
323
|
+
"orf_id": self.orf_id,
|
|
324
|
+
"genome_build": self.genome_build,
|
|
325
|
+
"coding_nucleotide_offset": offset,
|
|
326
|
+
"amino_acid_position": aa,
|
|
327
|
+
"reference_amino_acid": "*" if stop else self.protein_sequence[aa - 1],
|
|
328
|
+
"role": "stop_codon" if stop else "start_codon" if offset < 3 else "body",
|
|
329
|
+
"reference_peptides": [asdict(p) for p in covered],
|
|
330
|
+
"reference_ms_support": any(p.is_supporting for p in covered),
|
|
331
|
+
"mutant_translation_evidence": "not_assessed",
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class ORFCatalog:
|
|
336
|
+
"""Offline ORF records for one explicitly identified species and assembly."""
|
|
337
|
+
|
|
338
|
+
def __init__(self, records, metadata):
|
|
339
|
+
self.records = tuple(records)
|
|
340
|
+
self._metadata = deepcopy(metadata)
|
|
341
|
+
self.genome_build = metadata["genome_build"]
|
|
342
|
+
self.taxon_id = metadata["taxon_id"]
|
|
343
|
+
self.species = metadata["species"]
|
|
344
|
+
self._by_id = {r.orf_id: r for r in self.records}
|
|
345
|
+
if len(self._by_id) != len(self.records):
|
|
346
|
+
raise ValueError("Duplicate ORF identifiers")
|
|
347
|
+
for r in self.records:
|
|
348
|
+
if r.genome_build != self.genome_build or r.strand not in ("+", "-"):
|
|
349
|
+
raise ValueError(f"Invalid assembly or strand for {r.orf_id}")
|
|
350
|
+
r.model # Validate identity and geometry, including non-upstream ORFs.
|
|
351
|
+
if (r.taxon_id, r.species) != (self.taxon_id, self.species):
|
|
352
|
+
raise ValueError("Catalogue and ORF species disagree")
|
|
353
|
+
if (
|
|
354
|
+
not r.coding_blocks
|
|
355
|
+
or not r.protein_sequence
|
|
356
|
+
or any(a < 1 or b < a for a, b in r.coding_blocks)
|
|
357
|
+
or any(b >= c for (_, b), (c, _) in zip(r.coding_blocks, r.coding_blocks[1:]))
|
|
358
|
+
or sum(b - a + 1 for a, b in r.coding_blocks) != 3 * (len(r.protein_sequence) + 1)
|
|
359
|
+
):
|
|
360
|
+
raise ValueError(f"Invalid spliced coding blocks for {r.orf_id}")
|
|
361
|
+
context = r.initiation_context
|
|
362
|
+
if (
|
|
363
|
+
context is not None
|
|
364
|
+
and context.source_transcript_id is not None
|
|
365
|
+
and context.source_transcript_id.split(".")[0] != r.transcript_id
|
|
366
|
+
):
|
|
367
|
+
raise ValueError("Initiation-context transcript identity disagrees with ORF")
|
|
368
|
+
if context is not None and context.genomic_positions:
|
|
369
|
+
positions = context.genomic_positions
|
|
370
|
+
if any(
|
|
371
|
+
(b <= a if r.strand == "+" else b >= a)
|
|
372
|
+
for a, b in zip(positions, positions[1:])
|
|
373
|
+
):
|
|
374
|
+
raise ValueError("Initiation-context placements disagree with strand")
|
|
375
|
+
for relative, position in zip(context.relative_positions, positions):
|
|
376
|
+
mapped = r._coding_offset(position)
|
|
377
|
+
if (relative < 0 and mapped is not None) or (
|
|
378
|
+
relative > 0 and mapped != relative - 1
|
|
379
|
+
):
|
|
380
|
+
raise ValueError("Initiation-context placements disagree with coding path")
|
|
381
|
+
for p in r.peptides:
|
|
382
|
+
if p.method == "hla" and r.taxon_id != 9606:
|
|
383
|
+
raise ValueError("Use mhc for non-human immunopeptidomics evidence")
|
|
384
|
+
if (
|
|
385
|
+
p.method not in ("shotgun", "mhc", "hla", "ms_unclassified")
|
|
386
|
+
or not p.protein_intervals
|
|
387
|
+
):
|
|
388
|
+
raise ValueError(f"Invalid peptide evidence for {r.orf_id}")
|
|
389
|
+
if (
|
|
390
|
+
p.quality
|
|
391
|
+
not in (
|
|
392
|
+
"reported",
|
|
393
|
+
"reviewed_support",
|
|
394
|
+
"reviewed_low_quality",
|
|
395
|
+
"reviewed_mixed",
|
|
396
|
+
)
|
|
397
|
+
or p.attribution not in ("source_orf", "sequence_compatible")
|
|
398
|
+
or p.sequence_match not in ("exact", "I_L_equivalent")
|
|
399
|
+
or not p.source
|
|
400
|
+
or not p.accession
|
|
401
|
+
):
|
|
402
|
+
raise ValueError(f"Invalid peptide provenance for {r.orf_id}")
|
|
403
|
+
for count in (p.mapping_count, p.spectrum_count):
|
|
404
|
+
if count is not None:
|
|
405
|
+
_positive_integer(count, "evidence count")
|
|
406
|
+
for curator, rating in p.review_ratings:
|
|
407
|
+
if not curator or type(rating) is not int or not 1 <= rating <= 5:
|
|
408
|
+
raise ValueError("Invalid spectrum review rating")
|
|
409
|
+
if p.quality != "reported" and not p.review_ratings:
|
|
410
|
+
raise ValueError("Reviewed evidence requires review ratings")
|
|
411
|
+
for a, b in p.protein_intervals:
|
|
412
|
+
if not 1 <= a <= b <= len(r.protein_sequence):
|
|
413
|
+
raise ValueError(f"Peptide interval outside {r.orf_id}")
|
|
414
|
+
if r.protein_sequence[a - 1 : b].replace("I", "L") != p.sequence.replace(
|
|
415
|
+
"I", "L"
|
|
416
|
+
):
|
|
417
|
+
raise ValueError(f"Peptide sequence does not match {r.orf_id}")
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def metadata(self):
|
|
421
|
+
"""Return a copy of dataset version, sources, scope and provenance."""
|
|
422
|
+
return deepcopy(self._metadata)
|
|
423
|
+
|
|
424
|
+
def get(self, orf_id):
|
|
425
|
+
"""Return an exact source ORF ID; unknown IDs raise KeyError."""
|
|
426
|
+
return self._by_id[orf_id]
|
|
427
|
+
|
|
428
|
+
def for_transcript(self, transcript, **filters):
|
|
429
|
+
"""Return validated ORFs on this transcript, with optional evidence filters.
|
|
430
|
+
|
|
431
|
+
A source model that disagrees with the selected annotation raises rather
|
|
432
|
+
than silently transferring its evidence to a different protein.
|
|
433
|
+
"""
|
|
434
|
+
if transcript.genome.reference_name != self.genome_build:
|
|
435
|
+
raise ValueError("Catalogue transcript assembly mismatch")
|
|
436
|
+
species = getattr(transcript.genome, "species", None)
|
|
437
|
+
if species is not None and _species(species) != _species(self.species):
|
|
438
|
+
raise ValueError("Catalogue transcript species mismatch")
|
|
439
|
+
return tuple(
|
|
440
|
+
record.bind(transcript)
|
|
441
|
+
for record in self.query(**filters)
|
|
442
|
+
if record.transcript_id.split(".")[0] == str(transcript.id).split(".")[0]
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def to_dict(self):
|
|
446
|
+
"""Portable catalogue with explicit species and source provenance."""
|
|
447
|
+
return dict(
|
|
448
|
+
schema="orfdata.catalog.v1",
|
|
449
|
+
metadata=self.metadata,
|
|
450
|
+
records=[r.to_dict() for r in self.records],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def query(
|
|
454
|
+
self,
|
|
455
|
+
*,
|
|
456
|
+
gene=None,
|
|
457
|
+
transcript_id=None,
|
|
458
|
+
contig=None,
|
|
459
|
+
start=None,
|
|
460
|
+
end=None,
|
|
461
|
+
genome_build=None,
|
|
462
|
+
evidence=None,
|
|
463
|
+
require_unique=False,
|
|
464
|
+
require_reviewed=False,
|
|
465
|
+
biotype=None,
|
|
466
|
+
include_initiation_context=False,
|
|
467
|
+
):
|
|
468
|
+
"""Filter records; genomic intervals are 1-based inclusive and spliced.
|
|
469
|
+
|
|
470
|
+
A locus query requires an explicit assembly. Gene and transcript lookup
|
|
471
|
+
is a discovery aid, not evidence transfer between transcript isoforms.
|
|
472
|
+
Transcript identifiers match exactly as supplied in the source; these
|
|
473
|
+
are unversioned identifiers. Empty results do not prove no translation.
|
|
474
|
+
``require_unique`` means unique within the evidence source's mapping
|
|
475
|
+
scope, not unique in every normal human proteome.
|
|
476
|
+
``include_initiation_context`` also intersects placed spliced start
|
|
477
|
+
flanks, for example an upstream Kozak SNV. The default remains coding
|
|
478
|
+
blocks only. It does not include introns between context bases.
|
|
479
|
+
"""
|
|
480
|
+
locus = any(x is not None for x in (contig, start, end))
|
|
481
|
+
if locus:
|
|
482
|
+
if contig is None or start is None or end is None or genome_build is None:
|
|
483
|
+
raise ValueError("Locus queries require contig, start, end and genome_build")
|
|
484
|
+
_positive_integer(start, "start")
|
|
485
|
+
_positive_integer(end, "end")
|
|
486
|
+
if end < start:
|
|
487
|
+
raise ValueError("end must be >= start")
|
|
488
|
+
contig = _contig(contig)
|
|
489
|
+
if genome_build is not None and genome_build != self.genome_build:
|
|
490
|
+
raise ValueError(f"Expected genome build {self.genome_build}, got {genome_build}")
|
|
491
|
+
if evidence is not None and evidence not in EVIDENCE_METHODS:
|
|
492
|
+
raise ValueError(f"Unknown evidence method: {evidence}")
|
|
493
|
+
if require_unique and evidence not in EVIDENCE_METHODS[1:]:
|
|
494
|
+
raise ValueError("require_unique applies to MS evidence only")
|
|
495
|
+
if require_reviewed and evidence not in EVIDENCE_METHODS[1:]:
|
|
496
|
+
raise ValueError("require_reviewed applies to MS evidence only")
|
|
497
|
+
|
|
498
|
+
return tuple(
|
|
499
|
+
r
|
|
500
|
+
for r in self.records
|
|
501
|
+
if (gene is None or gene in (r.gene_name, r.gene_id))
|
|
502
|
+
and (transcript_id is None or r.transcript_id == transcript_id)
|
|
503
|
+
and (
|
|
504
|
+
not locus
|
|
505
|
+
or (
|
|
506
|
+
_contig(r.contig) == contig
|
|
507
|
+
and (
|
|
508
|
+
any(a <= end and b >= start for a, b in r.coding_blocks)
|
|
509
|
+
or (
|
|
510
|
+
include_initiation_context
|
|
511
|
+
and r.initiation_context is not None
|
|
512
|
+
and any(
|
|
513
|
+
start <= p <= end for p in r.initiation_context.genomic_positions
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
and (biotype is None or r.biotype == biotype)
|
|
520
|
+
and (
|
|
521
|
+
evidence is None
|
|
522
|
+
or r.has_evidence(
|
|
523
|
+
evidence, require_unique=require_unique, require_reviewed=require_reviewed
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _load_catalog(path=None, *, expected_sha256=None, resource="orf-evidence"):
|
|
530
|
+
"""Load the packaged reference offline, or a compatible JSON/JSON.gz file.
|
|
531
|
+
|
|
532
|
+
No genome downloads, spreadsheet dependencies or LLM are required. The
|
|
533
|
+
catalogue stores protein sequences, not genomic REF alleles; callers must
|
|
534
|
+
validate alleles against their own assembly before reconstructing mutants.
|
|
535
|
+
"""
|
|
536
|
+
raw = (
|
|
537
|
+
files("orfdata").joinpath("data/" + resource + "/catalog.json.gz").read_bytes()
|
|
538
|
+
if path is None
|
|
539
|
+
else Path(path).read_bytes()
|
|
540
|
+
)
|
|
541
|
+
if path is None:
|
|
542
|
+
manifest = json.loads(
|
|
543
|
+
files("orfdata").joinpath("data/" + resource + "/manifest.json").read_text()
|
|
544
|
+
)
|
|
545
|
+
if hashlib.sha256(raw).hexdigest() != manifest["catalog_sha256"]:
|
|
546
|
+
raise ValueError("Packaged uORF catalogue checksum mismatch")
|
|
547
|
+
if expected_sha256 is not None and hashlib.sha256(raw).hexdigest() != expected_sha256:
|
|
548
|
+
raise ValueError("uORF catalogue checksum mismatch")
|
|
549
|
+
if raw.startswith(b"\x1f\x8b"):
|
|
550
|
+
raw = gzip.decompress(raw)
|
|
551
|
+
data = json.loads(raw)
|
|
552
|
+
if data.get("schema") not in ("isovar.uorf_evidence.v1", "orfdata.catalog.v1"):
|
|
553
|
+
raise ValueError("Unsupported uORF evidence schema")
|
|
554
|
+
if data["metadata"].get("coordinates") != "1-based-inclusive":
|
|
555
|
+
raise ValueError("Unsupported uORF coordinate convention")
|
|
556
|
+
legacy = data["schema"] == "isovar.uorf_evidence.v1"
|
|
557
|
+
metadata = data["metadata"]
|
|
558
|
+
if legacy:
|
|
559
|
+
if metadata.get("genome_build") != "GRCh38":
|
|
560
|
+
raise ValueError("Legacy Isovar data are explicitly human GRCh38 only")
|
|
561
|
+
metadata = dict(metadata, taxon_id=9606, species="homo_sapiens")
|
|
562
|
+
elif not metadata.get("sources"):
|
|
563
|
+
raise ValueError("Generic catalogues require source provenance")
|
|
564
|
+
records = []
|
|
565
|
+
for row in data["records"]:
|
|
566
|
+
row = dict(row)
|
|
567
|
+
if legacy:
|
|
568
|
+
row.update(
|
|
569
|
+
taxon_id=9606, species="homo_sapiens", translation_table=1, source="gencode_phase1"
|
|
570
|
+
)
|
|
571
|
+
elif not all(
|
|
572
|
+
k in row for k in ("taxon_id", "species", "translation_table", "genome_build", "source")
|
|
573
|
+
):
|
|
574
|
+
raise ValueError(
|
|
575
|
+
"Generic ORF records require explicit species, assembly, genetic code and source"
|
|
576
|
+
)
|
|
577
|
+
row["coding_blocks"] = tuple(tuple(x) for x in row["coding_blocks"])
|
|
578
|
+
row["ribosome_studies"] = tuple(row["ribosome_studies"])
|
|
579
|
+
if row.get("initiation_context") is not None:
|
|
580
|
+
context = dict(row["initiation_context"])
|
|
581
|
+
assessment = context.pop("assessment", None)
|
|
582
|
+
context["genomic_positions"] = tuple(context.get("genomic_positions", ()))
|
|
583
|
+
row["initiation_context"] = InitiationContext(**context)
|
|
584
|
+
if assessment is not None and assessment != row["initiation_context"].assessment:
|
|
585
|
+
raise ValueError("Initiation-context assessment disagrees with sequence")
|
|
586
|
+
peptides = []
|
|
587
|
+
for p in row["peptides"]:
|
|
588
|
+
p = dict(p)
|
|
589
|
+
p["protein_intervals"] = tuple(tuple(x) for x in p["protein_intervals"])
|
|
590
|
+
p["datasets"] = tuple(p.get("datasets", ()))
|
|
591
|
+
p["review_ratings"] = tuple(tuple(x) for x in p.get("review_ratings", ()))
|
|
592
|
+
p["spectrum_ids"] = tuple(p.get("spectrum_ids", ()))
|
|
593
|
+
peptides.append(PeptideEvidence(**p))
|
|
594
|
+
row["peptides"] = tuple(peptides)
|
|
595
|
+
records.append(ORFRecord(**row))
|
|
596
|
+
return ORFCatalog(records, metadata)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# The released Isovar API was explicitly human/GRCh38 and upstream-only. Keep
|
|
600
|
+
# that compatibility surface separate from the generic constructors and loader.
|
|
601
|
+
@dataclass(frozen=True)
|
|
602
|
+
class UORFRecord(ORFRecord):
|
|
603
|
+
taxon_id: int = 9606
|
|
604
|
+
species: str = "homo_sapiens"
|
|
605
|
+
source: str = "gencode_phase1"
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class UORFCatalog(ORFCatalog):
|
|
609
|
+
def __init__(self, records, metadata):
|
|
610
|
+
records = tuple(records)
|
|
611
|
+
if any(r.biotype not in ("uORF", "uoORF") for r in records):
|
|
612
|
+
raise ValueError("Invalid upstream ORF/evidence")
|
|
613
|
+
super().__init__(records, {"taxon_id": 9606, "species": "homo_sapiens", **metadata})
|
|
614
|
+
|
|
615
|
+
def query(self, *, biotype=None, **kwargs):
|
|
616
|
+
if biotype is not None and biotype not in ("uORF", "uoORF"):
|
|
617
|
+
raise ValueError(f"Unknown upstream ORF biotype: {biotype}")
|
|
618
|
+
return super().query(biotype=biotype, **kwargs)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
UORFPeptide = PeptideEvidence
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def load_uorf_catalog(path=None, *, expected_sha256=None):
|
|
625
|
+
"""Load the released human upstream-ORF API; use load_catalog for all ORFs."""
|
|
626
|
+
catalog = _load_catalog(path, expected_sha256=expected_sha256, resource="uorf-evidence")
|
|
627
|
+
records = tuple(r for r in catalog.records if r.biotype in ("uORF", "uoORF"))
|
|
628
|
+
return UORFCatalog(records, catalog.metadata)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def load_catalog(path=None, *, expected_sha256=None):
|
|
632
|
+
"""Load all bundled human ncORF classes, or an explicit local catalogue.
|
|
633
|
+
|
|
634
|
+
Other species use the same schema and API, with an explicit local file;
|
|
635
|
+
absence from the bundled human reference is never evidence of absence.
|
|
636
|
+
"""
|
|
637
|
+
return _load_catalog(path, expected_sha256=expected_sha256)
|