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