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/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Reference ORFs and evidence, independent of disease and patient variants."""
|
|
2
|
+
|
|
3
|
+
from .catalog import (
|
|
4
|
+
EVIDENCE_METHODS,
|
|
5
|
+
ORFCatalog,
|
|
6
|
+
ORFEvidence,
|
|
7
|
+
ORFRecord,
|
|
8
|
+
PeptideEvidence,
|
|
9
|
+
UORFCatalog,
|
|
10
|
+
UORFPeptide,
|
|
11
|
+
UORFRecord,
|
|
12
|
+
load_catalog,
|
|
13
|
+
load_uorf_catalog,
|
|
14
|
+
)
|
|
15
|
+
from .initiation_context import KOZAK_PREFERENCES, InitiationContext, annotate_initiation_context
|
|
16
|
+
from .model import ORFModel, ReferenceORF
|
|
17
|
+
from .version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"EVIDENCE_METHODS",
|
|
22
|
+
"ORFCatalog",
|
|
23
|
+
"ORFRecord",
|
|
24
|
+
"ORFEvidence",
|
|
25
|
+
"PeptideEvidence",
|
|
26
|
+
"ORFModel",
|
|
27
|
+
"ReferenceORF",
|
|
28
|
+
"load_catalog",
|
|
29
|
+
"UORFCatalog",
|
|
30
|
+
"UORFRecord",
|
|
31
|
+
"UORFPeptide",
|
|
32
|
+
"load_uorf_catalog",
|
|
33
|
+
"KOZAK_PREFERENCES",
|
|
34
|
+
"InitiationContext",
|
|
35
|
+
"annotate_initiation_context",
|
|
36
|
+
]
|
orfdata/build.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""Rebuild the offline uORF reference from checksum-pinned public source data.
|
|
2
|
+
|
|
3
|
+
Run as ``python -m orfdata.build --help`` from the checkout.
|
|
4
|
+
Only building needs openpyxl; preparing the author's Rdata also needs Rscript.
|
|
5
|
+
The installed catalogue and query API need neither, and never access the network.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import csv
|
|
10
|
+
import gzip
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import tarfile
|
|
17
|
+
import tempfile
|
|
18
|
+
import urllib.request
|
|
19
|
+
import zipfile
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from orfdata.catalog import load_catalog, load_uorf_catalog
|
|
24
|
+
from orfdata.initiation_context import InitiationContext
|
|
25
|
+
from orfdata.sequence import translate_reference
|
|
26
|
+
|
|
27
|
+
COMMIT = "b65ae0c09a53e9efe083d806d617581e6139bdde"
|
|
28
|
+
AUTHOR = "https://raw.githubusercontent.com/VanHeeschLab/deutsch_kok_et_al_2024/" + COMMIT + "/raw/"
|
|
29
|
+
PINS = {
|
|
30
|
+
"ensembl101.ncrna.fa.gz": "6bbf091adb1214942b6f258aa830393b27ad7cc49f8ee2fe18260a7b46a0cc88",
|
|
31
|
+
"ensembl101.gtf.gz": "6f63da87a7f2f7b5e6940f7e2bd9dd2e7aee37494430e6ac7274f7ef8dc214ba",
|
|
32
|
+
"ensembl101.cdna.fa.gz": "519cb4398f7a7904531a0eaaf16a8252f0487f101e00087f6316017bd51a1093",
|
|
33
|
+
"ncorf_list.xlsx": "ca2e2f9389052c62f48ec6f81d53ca88c64152edb5940e556813d4a7bf1e6c62",
|
|
34
|
+
"filtered_peptides.tar.bz2": "a97d73b08b4ee7376a064f9fee3480b3d73639ebe0bd238a6dd0099df6a9e30c",
|
|
35
|
+
"peptide_sample_msrun_counts.tar.bz2": "290658de32de2579d17f0b75c3fcc39b4d50c5e6ce1ceea9ce9f411ccc8a98ba",
|
|
36
|
+
"author-hla-mappings.tsv": "7f97f4ea675ac6abc97217c865019d73135358f448533ad8d98c9479fee9fb8f",
|
|
37
|
+
"author-hla-counts.json": "b1e8e79e74e90d205224cc86663ebf9d64ed97cf0e0290cd86117756ee46a4b6",
|
|
38
|
+
"media-4_with_dot_products.csv": "9e2cbb54765f8a9886cdcbf0bdfefa772f4623f54291add2bf3c47c26b17250f",
|
|
39
|
+
}
|
|
40
|
+
ENSEMBL_INPUTS = {
|
|
41
|
+
"ensembl101.gtf.gz": "https://ftp.ensembl.org/pub/release-101/gtf/homo_sapiens/Homo_sapiens.GRCh38.101.gtf.gz",
|
|
42
|
+
"ensembl101.cdna.fa.gz": "https://ftp.ensembl.org/pub/release-101/fasta/homo_sapiens/cdna/Homo_sapiens.GRCh38.cdna.all.fa.gz",
|
|
43
|
+
}
|
|
44
|
+
NCRNA_INPUTS = {
|
|
45
|
+
"ensembl101.ncrna.fa.gz": "https://ftp.ensembl.org/pub/release-101/fasta/homo_sapiens/ncrna/Homo_sapiens.GRCh38.ncrna.fa.gz",
|
|
46
|
+
}
|
|
47
|
+
RIBOSOME = {
|
|
48
|
+
"Ji_et_al_2015": "26687005",
|
|
49
|
+
"Calviello_et_al_2016": "26657557",
|
|
50
|
+
"Raj_et_al_2016": "27232982",
|
|
51
|
+
"VanHeesch_et_al_2019": "31155234",
|
|
52
|
+
"Martinez_et_al_2020": "31819274",
|
|
53
|
+
"Chen_et_al_2020": "32139545",
|
|
54
|
+
"Gaertner_et_al_2020": "32744504",
|
|
55
|
+
}
|
|
56
|
+
# These legacy aggregate annotations establish whole-proteome MS/MS only.
|
|
57
|
+
# Mixed HLA/whole-proteome or targeted/shotgun studies remain unclassified.
|
|
58
|
+
SHOTGUN_STUDIES = {
|
|
59
|
+
"Vanderperre_et_al_2013",
|
|
60
|
+
"Kim_et_al_2014",
|
|
61
|
+
"Calviello_et_al_2016",
|
|
62
|
+
"Raj_et_al_2016",
|
|
63
|
+
"Zhu_et_al_2018",
|
|
64
|
+
"Cao_et_al_2020",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def digest(path):
|
|
69
|
+
value = hashlib.sha256()
|
|
70
|
+
with path.open("rb") as stream:
|
|
71
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
72
|
+
value.update(block)
|
|
73
|
+
return value.hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verify(path):
|
|
77
|
+
if digest(path) != PINS[path.name]:
|
|
78
|
+
raise ValueError(f"Source checksum mismatch: {path}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RemoteZip(io.RawIOBase):
|
|
82
|
+
"""Read small ZIP members with HTTP ranges, never the 4.8 GB archive."""
|
|
83
|
+
|
|
84
|
+
size = 4797315346
|
|
85
|
+
url = "https://ndownloader.figshare.com/files/57979642"
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
self.position = 0
|
|
89
|
+
|
|
90
|
+
def seekable(self):
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def tell(self):
|
|
94
|
+
return self.position
|
|
95
|
+
|
|
96
|
+
def seek(self, offset, whence=0):
|
|
97
|
+
self.position = (
|
|
98
|
+
offset if whence == 0 else self.position + offset if whence == 1 else self.size + offset
|
|
99
|
+
)
|
|
100
|
+
return self.position
|
|
101
|
+
|
|
102
|
+
def read(self, count=-1):
|
|
103
|
+
if count < 0:
|
|
104
|
+
count = self.size - self.position
|
|
105
|
+
if not 0 <= count <= 2_000_000:
|
|
106
|
+
raise ValueError("Refusing a large archive range")
|
|
107
|
+
if not count:
|
|
108
|
+
return b""
|
|
109
|
+
request = urllib.request.Request(
|
|
110
|
+
self.url, headers={"Range": f"bytes={self.position}-{self.position + count - 1}"}
|
|
111
|
+
)
|
|
112
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
113
|
+
if response.status != 206:
|
|
114
|
+
raise ValueError("Source server does not support byte ranges")
|
|
115
|
+
data = response.read(count)
|
|
116
|
+
self.position += len(data)
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def prepare_sources(directory, *, all_orfs=False):
|
|
121
|
+
"""Download ~246 MB; extract small tables and stream the count archive."""
|
|
122
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
inputs = {**ENSEMBL_INPUTS, **NCRNA_INPUTS} if all_orfs else ENSEMBL_INPUTS
|
|
124
|
+
for name, url in inputs.items():
|
|
125
|
+
target = directory / name
|
|
126
|
+
if not target.exists():
|
|
127
|
+
with urllib.request.urlopen(url, timeout=120) as response, target.open("wb") as out:
|
|
128
|
+
while block := response.read(1024 * 1024):
|
|
129
|
+
out.write(block)
|
|
130
|
+
verify(target)
|
|
131
|
+
for name in (
|
|
132
|
+
"ncorf_list.xlsx",
|
|
133
|
+
"filtered_peptides.tar.bz2",
|
|
134
|
+
"peptide_sample_msrun_counts.tar.bz2",
|
|
135
|
+
):
|
|
136
|
+
target = directory / name
|
|
137
|
+
if not target.exists():
|
|
138
|
+
with (
|
|
139
|
+
urllib.request.urlopen(AUTHOR + name, timeout=120) as response,
|
|
140
|
+
target.open("wb") as out,
|
|
141
|
+
):
|
|
142
|
+
while block := response.read(1024 * 1024):
|
|
143
|
+
out.write(block)
|
|
144
|
+
verify(target)
|
|
145
|
+
mappings = directory / "author-hla-mappings.tsv"
|
|
146
|
+
if not mappings.exists():
|
|
147
|
+
# The source Rdata is ~825 MiB. Load in an isolated R environment,
|
|
148
|
+
# export only the small HLA mapping table, then remove this scratch file.
|
|
149
|
+
with tempfile.TemporaryDirectory(prefix="uorf-rdata-", dir=directory) as scratch:
|
|
150
|
+
rdata = Path(scratch) / "filtered_peptides.Rdata"
|
|
151
|
+
with (
|
|
152
|
+
tarfile.open(directory / "filtered_peptides.tar.bz2", "r:bz2") as archive,
|
|
153
|
+
archive.extractfile("filtered_peptides.Rdata") as source,
|
|
154
|
+
rdata.open("wb") as out,
|
|
155
|
+
):
|
|
156
|
+
while block := source.read(1024 * 1024):
|
|
157
|
+
out.write(block)
|
|
158
|
+
script = (
|
|
159
|
+
"e<-new.env();load(commandArgs(TRUE)[1],envir=e);"
|
|
160
|
+
'write.table(e$pep_nonc_long,commandArgs(TRUE)[2],sep="\\t",'
|
|
161
|
+
'quote=FALSE,row.names=FALSE,na="")'
|
|
162
|
+
)
|
|
163
|
+
subprocess.run(["Rscript", "-e", script, str(rdata), str(mappings)], check=True)
|
|
164
|
+
verify(mappings)
|
|
165
|
+
counts_path = directory / "author-hla-counts.json"
|
|
166
|
+
if not counts_path.exists():
|
|
167
|
+
with mappings.open() as stream:
|
|
168
|
+
peptides = {r["peptide"] for r in csv.DictReader(stream, delimiter="\t")}
|
|
169
|
+
counts, datasets, runs = defaultdict(int), defaultdict(set), defaultdict(set)
|
|
170
|
+
with tarfile.open(directory / "peptide_sample_msrun_counts.tar.bz2", "r|bz2") as archive:
|
|
171
|
+
for member in archive:
|
|
172
|
+
if member.name.endswith("peptide_sample_msrun_counts.tsv"):
|
|
173
|
+
for raw in archive.extractfile(member):
|
|
174
|
+
peptide, sample, count, dataset, _tag, run = (
|
|
175
|
+
raw.decode().rstrip("\n").split("\t")
|
|
176
|
+
)
|
|
177
|
+
if peptide in peptides:
|
|
178
|
+
counts[peptide] += int(count)
|
|
179
|
+
datasets[peptide].add(dataset)
|
|
180
|
+
runs[peptide].add((sample, run))
|
|
181
|
+
result = {
|
|
182
|
+
p: {
|
|
183
|
+
"spectrum_count": counts[p],
|
|
184
|
+
"datasets": sorted(datasets[p]),
|
|
185
|
+
"run_count": len(runs[p]),
|
|
186
|
+
}
|
|
187
|
+
for p in sorted(counts)
|
|
188
|
+
}
|
|
189
|
+
counts_path.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n")
|
|
190
|
+
verify(counts_path)
|
|
191
|
+
benchmark = directory / "media-4_with_dot_products.csv"
|
|
192
|
+
if not benchmark.exists():
|
|
193
|
+
with zipfile.ZipFile(RemoteZip()) as archive:
|
|
194
|
+
benchmark.write_bytes(archive.read("input_files/media-4_with_dot_products.csv"))
|
|
195
|
+
verify(benchmark)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def split(value):
|
|
199
|
+
return tuple(x for x in str(value or "").split(";") if x)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def intervals(protein, peptide):
|
|
203
|
+
protein, peptide = protein.replace("I", "L"), peptide.replace("I", "L")
|
|
204
|
+
result, start = [], 0
|
|
205
|
+
while (position := protein.find(peptide, start)) >= 0:
|
|
206
|
+
result.append([position + 1, position + len(peptide)])
|
|
207
|
+
start = position + 1
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def peptide_record(model, peptide, **values):
|
|
212
|
+
locations = intervals(model["protein_sequence"], peptide)
|
|
213
|
+
if not locations:
|
|
214
|
+
raise ValueError("Peptide absent from {}: {}".format(model["orf_id"], peptide))
|
|
215
|
+
return dict(
|
|
216
|
+
sequence=peptide,
|
|
217
|
+
protein_intervals=locations,
|
|
218
|
+
sequence_match="exact"
|
|
219
|
+
if all(model["protein_sequence"][a - 1 : b] == peptide for a, b in locations)
|
|
220
|
+
else "I_L_equivalent",
|
|
221
|
+
**values,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_transcript_reference(gtf, fasta, transcript_ids):
|
|
226
|
+
"""Stream just the requested transcript models from pinned GTF/cDNA."""
|
|
227
|
+
references = {}
|
|
228
|
+
with gzip.open(gtf, "rt") as stream:
|
|
229
|
+
for line in stream:
|
|
230
|
+
if line.startswith("#"):
|
|
231
|
+
continue
|
|
232
|
+
fields = line.rstrip().split("\t")
|
|
233
|
+
if fields[2] != "exon":
|
|
234
|
+
continue
|
|
235
|
+
tid = re.search(r'transcript_id "([^"]+)"', fields[8]).group(1)
|
|
236
|
+
if tid not in transcript_ids:
|
|
237
|
+
continue
|
|
238
|
+
version = re.search(r'transcript_version "([^"]+)"', fields[8]).group(1)
|
|
239
|
+
reference = references.setdefault(
|
|
240
|
+
tid,
|
|
241
|
+
{
|
|
242
|
+
"contig": fields[0],
|
|
243
|
+
"strand": fields[6],
|
|
244
|
+
"transcript_id": tid + "." + version,
|
|
245
|
+
"exons": [],
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
if (reference["contig"], reference["strand"], reference["transcript_id"]) != (
|
|
249
|
+
fields[0],
|
|
250
|
+
fields[6],
|
|
251
|
+
tid + "." + version,
|
|
252
|
+
):
|
|
253
|
+
raise ValueError("Conflicting reference transcript identity")
|
|
254
|
+
reference["exons"].append((int(fields[3]), int(fields[4])))
|
|
255
|
+
|
|
256
|
+
def keep(header, sequence):
|
|
257
|
+
tid = header.split(".")[0]
|
|
258
|
+
if tid in references:
|
|
259
|
+
reference = references[tid]
|
|
260
|
+
if header != reference["transcript_id"]:
|
|
261
|
+
raise ValueError("GTF/cDNA transcript versions disagree")
|
|
262
|
+
reference["sequence"] = "".join(sequence)
|
|
263
|
+
|
|
264
|
+
for fasta_path in fasta if isinstance(fasta, (tuple, list)) else [fasta]:
|
|
265
|
+
header, sequence = "", []
|
|
266
|
+
with gzip.open(fasta_path, "rt") as stream:
|
|
267
|
+
for line in stream:
|
|
268
|
+
if line.startswith(">"):
|
|
269
|
+
keep(header, sequence)
|
|
270
|
+
header, sequence = line[1:].split()[0], []
|
|
271
|
+
elif header.split(".")[0] in references:
|
|
272
|
+
sequence.append(line.strip().upper())
|
|
273
|
+
keep(header, sequence)
|
|
274
|
+
for reference in references.values():
|
|
275
|
+
reference["exons"].sort()
|
|
276
|
+
return references
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def transcript_positions(exons, strand, start, end):
|
|
280
|
+
"""Return genomic bases only for a requested spliced interval [start,end)."""
|
|
281
|
+
offset, positions = 0, []
|
|
282
|
+
for left, right in exons if strand == "+" else exons[::-1]:
|
|
283
|
+
length = right - left + 1
|
|
284
|
+
a, b = max(0, start - offset), min(length, end - offset)
|
|
285
|
+
if a < b:
|
|
286
|
+
positions.extend(
|
|
287
|
+
range(left + a, left + b) if strand == "+" else range(right - a, right - b, -1)
|
|
288
|
+
)
|
|
289
|
+
offset += length
|
|
290
|
+
if offset >= end:
|
|
291
|
+
break
|
|
292
|
+
return positions
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def model_initiation_context(model, reference):
|
|
296
|
+
"""Attach context only after validating the complete ORF path/protein."""
|
|
297
|
+
|
|
298
|
+
def unavailable(reason):
|
|
299
|
+
return InitiationContext("", "", "", source="ensembl101", unavailable_reason=reason)
|
|
300
|
+
|
|
301
|
+
if reference is None or "sequence" not in reference:
|
|
302
|
+
return unavailable("transcript_not_in_reference")
|
|
303
|
+
if (reference["contig"], reference["strand"]) != (model["contig"], model["strand"]):
|
|
304
|
+
return unavailable("transcript_locus_mismatch")
|
|
305
|
+
exons, strand, sequence = reference["exons"], model["strand"], reference["sequence"]
|
|
306
|
+
if len(sequence) != sum(b - a + 1 for a, b in exons):
|
|
307
|
+
return unavailable("transcript_sequence_length_mismatch")
|
|
308
|
+
wanted = [p for a, b in model["coding_blocks"] for p in range(a, b + 1)]
|
|
309
|
+
if strand == "-":
|
|
310
|
+
wanted.reverse()
|
|
311
|
+
start, offset = None, 0
|
|
312
|
+
for a, b in exons if strand == "+" else exons[::-1]:
|
|
313
|
+
if a <= wanted[0] <= b:
|
|
314
|
+
start = offset + (wanted[0] - a if strand == "+" else b - wanted[0])
|
|
315
|
+
break
|
|
316
|
+
offset += b - a + 1
|
|
317
|
+
if start is None or transcript_positions(exons, strand, start, start + len(wanted)) != wanted:
|
|
318
|
+
return unavailable("ORF_splice_path_mismatch")
|
|
319
|
+
coding = sequence[start : start + len(wanted)]
|
|
320
|
+
if any(b not in "ACGT" for b in coding):
|
|
321
|
+
return unavailable("ORF_sequence_ambiguous")
|
|
322
|
+
protein, stop = translate_reference(coding)
|
|
323
|
+
if not stop or protein != model["protein_sequence"]:
|
|
324
|
+
return unavailable("ORF_protein_mismatch")
|
|
325
|
+
left, right = max(0, start - 9), min(len(sequence), start + 9)
|
|
326
|
+
return InitiationContext(
|
|
327
|
+
sequence[left:start],
|
|
328
|
+
sequence[start : start + 3],
|
|
329
|
+
sequence[start + 3 : right],
|
|
330
|
+
genomic_positions=tuple(transcript_positions(exons, strand, left, right)),
|
|
331
|
+
source="ensembl101",
|
|
332
|
+
source_transcript_id=reference["transcript_id"],
|
|
333
|
+
start_offset=start,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def build(source_dir, destination, *, all_orfs=False):
|
|
338
|
+
import openpyxl
|
|
339
|
+
|
|
340
|
+
inputs = {**ENSEMBL_INPUTS, **NCRNA_INPUTS} if all_orfs else ENSEMBL_INPUTS
|
|
341
|
+
for name in (
|
|
342
|
+
"ncorf_list.xlsx",
|
|
343
|
+
"author-hla-mappings.tsv",
|
|
344
|
+
"author-hla-counts.json",
|
|
345
|
+
"media-4_with_dot_products.csv",
|
|
346
|
+
*inputs,
|
|
347
|
+
):
|
|
348
|
+
verify(source_dir / name)
|
|
349
|
+
workbook = openpyxl.load_workbook(
|
|
350
|
+
source_dir / "ncorf_list.xlsx", read_only=True, data_only=True
|
|
351
|
+
)
|
|
352
|
+
models, aliases, excluded = {}, {}, []
|
|
353
|
+
for sheet in ("S2. PHASE I Ribo-seq ORFs", "S3. Single-study Ribo-seq ORFs"):
|
|
354
|
+
rows = workbook[sheet].iter_rows(values_only=True)
|
|
355
|
+
header = next(rows)
|
|
356
|
+
for values in rows:
|
|
357
|
+
row = dict(zip(header, values))
|
|
358
|
+
if not all_orfs and row.get("orf_biotype") not in ("uORF", "uoORF"):
|
|
359
|
+
continue
|
|
360
|
+
protein = row["orf_sequence"]
|
|
361
|
+
if not protein.endswith("*") or "*" in protein[:-1]:
|
|
362
|
+
raise ValueError("Unexpected unterminated ORF: {}".format(row["orf_name"]))
|
|
363
|
+
model = {
|
|
364
|
+
"orf_id": row["orf_name"],
|
|
365
|
+
"gene_id": row["gene_id"],
|
|
366
|
+
"gene_name": row["gene_name"],
|
|
367
|
+
"transcript_id": row["transcript"],
|
|
368
|
+
"contig": str(row["chrm"]),
|
|
369
|
+
"strand": row["strand"],
|
|
370
|
+
"coding_blocks": [
|
|
371
|
+
list(x)
|
|
372
|
+
for x in zip(map(int, split(row["starts"])), map(int, split(row["ends"])))
|
|
373
|
+
],
|
|
374
|
+
"protein_sequence": protein[:-1],
|
|
375
|
+
"biotype": row["orf_biotype"],
|
|
376
|
+
"genome_build": "GRCh38",
|
|
377
|
+
"ribosome_studies": [s for s in RIBOSOME if row[s] == 1],
|
|
378
|
+
"peptides": [],
|
|
379
|
+
}
|
|
380
|
+
if all_orfs:
|
|
381
|
+
model.update(
|
|
382
|
+
taxon_id=9606,
|
|
383
|
+
species="homo_sapiens",
|
|
384
|
+
translation_table=1,
|
|
385
|
+
source="gencode_phase1",
|
|
386
|
+
)
|
|
387
|
+
models[model["orf_id"]] = model
|
|
388
|
+
aliases[model["orf_id"]] = set(split(row["literature_ORF_names"])) | {model["orf_id"]}
|
|
389
|
+
datasets = sorted(set(split(row["MS_datasets"])))
|
|
390
|
+
method = (
|
|
391
|
+
"shotgun" if datasets and set(datasets) <= SHOTGUN_STUDIES else "ms_unclassified"
|
|
392
|
+
)
|
|
393
|
+
for peptide in sorted(set(split(row["MS_peptides"]))):
|
|
394
|
+
if not intervals(model["protein_sequence"], peptide):
|
|
395
|
+
excluded.append(
|
|
396
|
+
{
|
|
397
|
+
"orf_id": model["orf_id"],
|
|
398
|
+
"sequence": peptide,
|
|
399
|
+
"source": "gencode_phase1",
|
|
400
|
+
"reason": "sequence_not_in_exact_model",
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
continue
|
|
404
|
+
model["peptides"].append(
|
|
405
|
+
peptide_record(
|
|
406
|
+
model,
|
|
407
|
+
peptide,
|
|
408
|
+
method=method,
|
|
409
|
+
source="gencode_phase1",
|
|
410
|
+
accession=model["orf_id"],
|
|
411
|
+
datasets=datasets,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
workbook.close()
|
|
415
|
+
references = load_transcript_reference(
|
|
416
|
+
source_dir / "ensembl101.gtf.gz",
|
|
417
|
+
[source_dir / name for name in inputs if name.endswith(".fa.gz")],
|
|
418
|
+
{m["transcript_id"] for m in models.values()},
|
|
419
|
+
)
|
|
420
|
+
for model in models.values():
|
|
421
|
+
model["initiation_context"] = model_initiation_context(
|
|
422
|
+
model, references.get(model["transcript_id"])
|
|
423
|
+
).to_dict()
|
|
424
|
+
counts = json.loads((source_dir / "author-hla-counts.json").read_text())
|
|
425
|
+
with (source_dir / "author-hla-mappings.tsv").open() as stream:
|
|
426
|
+
seen = set()
|
|
427
|
+
for row in csv.DictReader(stream, delimiter="\t"):
|
|
428
|
+
model = models.get(row["orf_name"])
|
|
429
|
+
key = (row["orf_name"], row["peptide_accession"])
|
|
430
|
+
if model is None or key in seen:
|
|
431
|
+
continue
|
|
432
|
+
seen.add(key)
|
|
433
|
+
if model["protein_sequence"].replace("I", "L") != row["protein"].replace("I", "L"):
|
|
434
|
+
excluded.append(
|
|
435
|
+
{
|
|
436
|
+
"orf_id": model["orf_id"],
|
|
437
|
+
"sequence": row["peptide"],
|
|
438
|
+
"source": "author_hla",
|
|
439
|
+
"reason": "source_protein_differs_from_exact_model",
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
continue
|
|
443
|
+
count = counts.get(row["peptide"], {})
|
|
444
|
+
model["peptides"].append(
|
|
445
|
+
peptide_record(
|
|
446
|
+
model,
|
|
447
|
+
row["peptide"],
|
|
448
|
+
method="hla",
|
|
449
|
+
source="author_hla",
|
|
450
|
+
accession=row["peptide_accession"],
|
|
451
|
+
mapping_count=int(row["n_pep"]),
|
|
452
|
+
spectrum_count=count.get("spectrum_count"),
|
|
453
|
+
datasets=count.get("datasets", []),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
# Keep each spectrum and its reviewers together. A compatible sequence in
|
|
457
|
+
# another isoform is explicit, and never receives a uniqueness claim.
|
|
458
|
+
reviews = defaultdict(list)
|
|
459
|
+
with (source_dir / "media-4_with_dot_products.csv").open(encoding="utf-8-sig") as stream:
|
|
460
|
+
for row in csv.DictReader(stream):
|
|
461
|
+
if row["pmid"] != "Control" and row["Rating..1.5."] not in ("", "NA"):
|
|
462
|
+
reviews[row["USI"]].append(row)
|
|
463
|
+
for usi, rows in sorted(reviews.items()):
|
|
464
|
+
first = rows[0]
|
|
465
|
+
scores = sorted((r["curator"], int(r["Rating..1.5."])) for r in rows)
|
|
466
|
+
quality = (
|
|
467
|
+
"reviewed_support"
|
|
468
|
+
if all(s >= 4 for _, s in scores)
|
|
469
|
+
else "reviewed_low_quality"
|
|
470
|
+
if all(s < 4 for _, s in scores)
|
|
471
|
+
else "reviewed_mixed"
|
|
472
|
+
)
|
|
473
|
+
for model in models.values():
|
|
474
|
+
if intervals(model["protein_sequence"], first["peptide"]):
|
|
475
|
+
model["peptides"].append(
|
|
476
|
+
peptide_record(
|
|
477
|
+
model,
|
|
478
|
+
first["peptide"],
|
|
479
|
+
method="hla" if first["HLA"] == "TRUE" else "shotgun",
|
|
480
|
+
source="microprotein_benchmark",
|
|
481
|
+
accession=usi,
|
|
482
|
+
spectrum_ids=[usi],
|
|
483
|
+
quality=quality,
|
|
484
|
+
review_ratings=scores,
|
|
485
|
+
datasets=[usi.split(":")[1]],
|
|
486
|
+
attribution="source_orf"
|
|
487
|
+
if first["orf"] in aliases[model["orf_id"]]
|
|
488
|
+
else "sequence_compatible",
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
for model in models.values():
|
|
492
|
+
model["peptides"].sort(key=lambda p: (p["source"], p["sequence"], p["accession"]))
|
|
493
|
+
metadata = {
|
|
494
|
+
"dataset_version": "2026-10-08.2",
|
|
495
|
+
"genome_build": "GRCh38",
|
|
496
|
+
"coordinates": "1-based-inclusive",
|
|
497
|
+
"model_scope": "3771 stopped uORF/uoORF models from the GENCODE Phase I 2022 reference (minimum 16 aa)",
|
|
498
|
+
"evidence_scope": "Legacy reported MS, author HLA mapping export, and separately licensed community spectrum reviews; not the complete 2026 Nature curated shotgun tables",
|
|
499
|
+
"missing_evidence": "not_assessed_or_not_reported; never evidence of absence",
|
|
500
|
+
"mutant_translation_evidence": "not_assessed",
|
|
501
|
+
"benchmark_review_policy": "All reviewers score 4 or 5: reviewed_support; all below 4: reviewed_low_quality; otherwise reviewed_mixed",
|
|
502
|
+
"ribosome_study_pmids": RIBOSOME,
|
|
503
|
+
"initiation_policy": "Loose positional Kozak preferences; no model filtering or calibrated initiation probability. Missing positions remain unknown; transcript boundaries are not verified cap sites.",
|
|
504
|
+
"sources": [
|
|
505
|
+
{
|
|
506
|
+
"id": "ensembl101",
|
|
507
|
+
"citation": "Ensembl release 101 human GRCh38 exon annotation and cDNA sequences",
|
|
508
|
+
"inputs": [{"url": url, "sha256": PINS[name]} for name, url in inputs.items()],
|
|
509
|
+
"license": "Ensembl data available without restriction; https://www.ensembl.org/info/about/legal/disclaimer.html",
|
|
510
|
+
"mapping_scope": "Exact entire ORF splice path and protein validated; reference context only, biological 5-prime completeness unverified",
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
"id": "kozak_context",
|
|
514
|
+
"citation": "Kozak 1986, Point mutations define a sequence flanking the AUG initiator codon that modulates translation by eukaryotic ribosomes",
|
|
515
|
+
"doi": "10.1016/0092-8674(86)90762-2",
|
|
516
|
+
"consensus_doi": "10.1093/nar/15.20.8125",
|
|
517
|
+
"short_leader_pmid": "1820208",
|
|
518
|
+
"mapping_scope": "Sequence prior, not measured initiation, expression, or an exclusion threshold",
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
"id": "gencode_phase1",
|
|
522
|
+
"citation": "Mudge et al. 2022, Standardized annotation of translated open reading frames",
|
|
523
|
+
"doi": "10.1038/s41587-022-01369-0",
|
|
524
|
+
"url": AUTHOR + "ncorf_list.xlsx",
|
|
525
|
+
"sha256": PINS["ncorf_list.xlsx"],
|
|
526
|
+
"license": "MIT (author repository)",
|
|
527
|
+
"mapping_scope": "No peptide uniqueness counts; legacy MS datasets are ORF-level pooled annotations, not peptide-specific sample attribution",
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
"id": "author_hla",
|
|
531
|
+
"citation": "Deutsch et al. 2026, Expanding the human proteome with microproteins and peptideins",
|
|
532
|
+
"doi": "10.1038/s41586-026-10459-x",
|
|
533
|
+
"url": AUTHOR + "filtered_peptides.tar.bz2",
|
|
534
|
+
"sha256": PINS["filtered_peptides.tar.bz2"],
|
|
535
|
+
"counts_url": AUTHOR + "peptide_sample_msrun_counts.tar.bz2",
|
|
536
|
+
"counts_sha256": PINS["peptide_sample_msrun_counts.tar.bz2"],
|
|
537
|
+
"license": "MIT (author repository)",
|
|
538
|
+
"mapping_scope": "n_pep in author's filtered HLA reference mappings, excluding canonical peptides; not global proteome uniqueness",
|
|
539
|
+
"count_scope": "Peptide PSMs across source MS runs, shared across ORF mappings; not RNA abundance or independent patient samples",
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
"id": "microprotein_benchmark",
|
|
543
|
+
"citation": "Wacholder et al. 2026, Community benchmarking and evaluation of human unannotated microprotein detection by mass spectrometry based proteomics",
|
|
544
|
+
"doi": "10.1038/s41467-025-68002-x",
|
|
545
|
+
"dataset_doi": "10.6084/m9.figshare.30131869.v1",
|
|
546
|
+
"url": RemoteZip.url,
|
|
547
|
+
"member": "input_files/media-4_with_dot_products.csv",
|
|
548
|
+
"sha256": PINS["media-4_with_dot_products.csv"],
|
|
549
|
+
"license": "CC-BY-4.0 (Figshare dataset)",
|
|
550
|
+
"mapping_scope": "Sequence-compatible Phase I models; source_orf only when a published ORF alias also matches; uniqueness unknown",
|
|
551
|
+
"correction": "10.1038/s41467-026-73431-3 corrects figure labels, not source spectra",
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
}
|
|
555
|
+
if all_orfs:
|
|
556
|
+
metadata.update(
|
|
557
|
+
dataset_version="2026-10-11.1",
|
|
558
|
+
taxon_id=9606,
|
|
559
|
+
species="homo_sapiens",
|
|
560
|
+
model_scope="All 7264 complete stopped GENCODE Phase I and single-study ncORFs, including noncoding-transcript, internal and downstream ORFs",
|
|
561
|
+
)
|
|
562
|
+
data = {
|
|
563
|
+
"schema": "orfdata.catalog.v1" if all_orfs else "isovar.uorf_evidence.v1",
|
|
564
|
+
"metadata": metadata,
|
|
565
|
+
"records": sorted(models.values(), key=lambda m: m["orf_id"]),
|
|
566
|
+
}
|
|
567
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
568
|
+
target = destination / "catalog.json.gz"
|
|
569
|
+
target.write_bytes(
|
|
570
|
+
gzip.compress(
|
|
571
|
+
(json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n").encode(), mtime=0
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
catalog = load_catalog(target) if all_orfs else load_uorf_catalog(target)
|
|
575
|
+
stats = dict(
|
|
576
|
+
records=len(catalog.records),
|
|
577
|
+
peptides=sum(len(r.peptides) for r in catalog.records),
|
|
578
|
+
initiation_context_complete=sum(
|
|
579
|
+
r.initiation_context.assessment["status"] == "complete" for r in catalog.records
|
|
580
|
+
),
|
|
581
|
+
initiation_context_partial=sum(
|
|
582
|
+
r.initiation_context.assessment["status"] == "partial" for r in catalog.records
|
|
583
|
+
),
|
|
584
|
+
initiation_context_unavailable=sum(
|
|
585
|
+
r.initiation_context.assessment["status"] == "unavailable" for r in catalog.records
|
|
586
|
+
),
|
|
587
|
+
excluded_legacy_assignments=len(excluded),
|
|
588
|
+
**{
|
|
589
|
+
method + "_orfs": len(catalog.query(evidence=method))
|
|
590
|
+
for method in ("ribosome", "shotgun", "hla", "ms")
|
|
591
|
+
},
|
|
592
|
+
reviewed_ms_orfs=len(catalog.query(evidence="ms", require_reviewed=True)),
|
|
593
|
+
)
|
|
594
|
+
manifest = {
|
|
595
|
+
"schema": "orfdata.manifest.v1" if all_orfs else "isovar.uorf_manifest.v1",
|
|
596
|
+
"dataset_version": metadata["dataset_version"],
|
|
597
|
+
"catalog_sha256": digest(target),
|
|
598
|
+
"statistics": stats,
|
|
599
|
+
"source_sha256": {k: v for k, v in PINS.items() if all_orfs or k not in NCRNA_INPUTS},
|
|
600
|
+
"excluded_assignments": excluded,
|
|
601
|
+
}
|
|
602
|
+
(destination / "manifest.json").write_text(
|
|
603
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n"
|
|
604
|
+
)
|
|
605
|
+
print(json.dumps(stats, sort_keys=True))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main():
|
|
609
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
610
|
+
parser.add_argument("--source-dir", type=Path, required=True)
|
|
611
|
+
parser.add_argument("--all-orfs", action="store_true", help="Include all source ORF classes")
|
|
612
|
+
parser.add_argument(
|
|
613
|
+
"--prepare-sources",
|
|
614
|
+
action="store_true",
|
|
615
|
+
help="Download checksum-pinned inputs; requires Rscript",
|
|
616
|
+
)
|
|
617
|
+
parser.add_argument("--output", type=Path, default=Path("orfdata/data/uorf-evidence"))
|
|
618
|
+
args = parser.parse_args()
|
|
619
|
+
if args.prepare_sources:
|
|
620
|
+
prepare_sources(args.source_dir, all_orfs=args.all_orfs)
|
|
621
|
+
build(args.source_dir, args.output, all_orfs=args.all_orfs)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
if __name__ == "__main__":
|
|
625
|
+
main()
|