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 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()