orfdata 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (56) hide show
  1. {orfdata-0.3.0/orfdata.egg-info → orfdata-0.4.0}/PKG-INFO +1 -1
  2. {orfdata-0.3.0 → orfdata-0.4.0}/docs/measurements.md +3 -3
  3. orfdata-0.4.0/docs/pairing.md +84 -0
  4. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/__init__.py +14 -0
  5. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/dataset.py +118 -4
  6. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/dataset_cli.py +17 -1
  7. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/measurements.py +1 -0
  8. orfdata-0.4.0/orfdata/pairing.py +329 -0
  9. orfdata-0.4.0/orfdata/version.py +1 -0
  10. {orfdata-0.3.0 → orfdata-0.4.0/orfdata.egg-info}/PKG-INFO +1 -1
  11. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata.egg-info/SOURCES.txt +4 -0
  12. orfdata-0.4.0/tests/conftest.py +45 -0
  13. {orfdata-0.3.0 → orfdata-0.4.0}/tests/test_measurements.py +2 -33
  14. orfdata-0.4.0/tests/test_pairing.py +269 -0
  15. orfdata-0.3.0/orfdata/version.py +0 -1
  16. {orfdata-0.3.0 → orfdata-0.4.0}/LICENSE +0 -0
  17. {orfdata-0.3.0 → orfdata-0.4.0}/MANIFEST.in +0 -0
  18. {orfdata-0.3.0 → orfdata-0.4.0}/README.md +0 -0
  19. {orfdata-0.3.0 → orfdata-0.4.0}/deploy.sh +0 -0
  20. {orfdata-0.3.0 → orfdata-0.4.0}/docs/design.md +0 -0
  21. {orfdata-0.3.0 → orfdata-0.4.0}/docs/reference.md +0 -0
  22. {orfdata-0.3.0 → orfdata-0.4.0}/docs/species.md +0 -0
  23. {orfdata-0.3.0 → orfdata-0.4.0}/lint.sh +0 -0
  24. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/build.py +0 -0
  25. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/catalog.py +0 -0
  26. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/cli.py +0 -0
  27. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/datasets.json +0 -0
  28. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/orf-evidence/AUTHOR-LICENSE.txt +0 -0
  29. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/orf-evidence/NOTICE.txt +0 -0
  30. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/orf-evidence/catalog.json.gz +0 -0
  31. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/orf-evidence/manifest.json +0 -0
  32. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/uorf-evidence/AUTHOR-LICENSE.txt +0 -0
  33. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/uorf-evidence/NOTICE.txt +0 -0
  34. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/uorf-evidence/catalog.json.gz +0 -0
  35. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/data/uorf-evidence/manifest.json +0 -0
  36. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/datasets.py +0 -0
  37. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/initiation_context.py +0 -0
  38. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/model.py +0 -0
  39. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/reference.py +0 -0
  40. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata/sequence.py +0 -0
  41. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata.egg-info/dependency_links.txt +0 -0
  42. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata.egg-info/entry_points.txt +0 -0
  43. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata.egg-info/requires.txt +0 -0
  44. {orfdata-0.3.0 → orfdata-0.4.0}/orfdata.egg-info/top_level.txt +0 -0
  45. {orfdata-0.3.0 → orfdata-0.4.0}/pyproject.toml +0 -0
  46. {orfdata-0.3.0 → orfdata-0.4.0}/setup.cfg +0 -0
  47. {orfdata-0.3.0 → orfdata-0.4.0}/test.sh +0 -0
  48. {orfdata-0.3.0 → orfdata-0.4.0}/tests/data/references/README.md +0 -0
  49. {orfdata-0.3.0 → orfdata-0.4.0}/tests/data/references/canis_lupus_familiaris.json +0 -0
  50. {orfdata-0.3.0 → orfdata-0.4.0}/tests/data/references/homo_sapiens.json +0 -0
  51. {orfdata-0.3.0 → orfdata-0.4.0}/tests/data/references/mus_musculus.json +0 -0
  52. {orfdata-0.3.0 → orfdata-0.4.0}/tests/data/references/saccharomyces_cerevisiae.json +0 -0
  53. {orfdata-0.3.0 → orfdata-0.4.0}/tests/test_initiation_context.py +0 -0
  54. {orfdata-0.3.0 → orfdata-0.4.0}/tests/test_reference_context.py +0 -0
  55. {orfdata-0.3.0 → orfdata-0.4.0}/tests/test_reference_orf.py +0 -0
  56. {orfdata-0.3.0 → orfdata-0.4.0}/tests/test_uorf_catalog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orfdata
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Reference open reading frames and experimental translation evidence on annotated transcripts
5
5
  Author: OpenVax
6
6
  License-Expression: Apache-2.0
@@ -80,9 +80,9 @@ records technical replicates, runs, fractions, TMT plex/channel/bridge,
80
80
  normalization and calibration. A sample's strain/breed belongs in its attributes;
81
81
  it need not be the strain used to assemble its reference genome. `TargetMapping`
82
82
  retains all candidate assignments or an unmapped reason. A protein-group
83
- representative does not become a uniquely measured protein. Explicit sample
84
- pairing and orthology comparisons follow in
85
- [issue #6](https://github.com/openvax/orfdata/issues/6).
83
+ representative does not become a uniquely measured protein. See
84
+ [pairing and orthology comparisons](pairing.md) for explicit experimental
85
+ relationships and derived RNA/protein ratios.
86
86
 
87
87
  ## Importing a source
88
88
 
@@ -0,0 +1,84 @@
1
+ # Comparing RNA, ribosome and protein abundance
2
+
3
+ Use declared experimental relationships to compare assays. A tissue label alone
4
+ does not make an RNA sample and a protein sample a matched pair.
5
+
6
+ ```python
7
+ for match in dataset.matched_measurements(allowed_bases=["same_material"]):
8
+ row = match.to_table_row()
9
+ print(row["rna_abundance"], row["protein_abundance"])
10
+ ```
11
+
12
+ Rows include named RNA, ribosome occupancy, translation-efficiency, protein,
13
+ peptide and spectrum-count columns, each containing original measurement
14
+ records with units, missingness and provenance. Missing columns are empty lists.
15
+ Multiple values remain multiple values; the API does not average them or create
16
+ a Cartesian product of technical replicates. Each row retains its assay pairing
17
+ and target relationship. A three-assay experiment can declare several pairwise
18
+ comparisons; sharing an RNA measurement does not create independent replicates.
19
+
20
+ Pairing bases are `same_material`, `adjacent_donor_tissue`,
21
+ `biological_replicate`, `independent_cultures`, `tissue_aggregate` and
22
+ `condition_aggregate`. The caller must choose the allowed bases. By default,
23
+ only explicitly concurrent assays are returned; opt into `longitudinal` or
24
+ `unknown` with `allowed_temporal`. Missing mates stay explicit. Technical runs,
25
+ TMT channels and bridges remain in the assays and do not become biological
26
+ sample counts.
27
+
28
+ The CLI streams the same table rows:
29
+
30
+ ```sh
31
+ orfdata datasets pairs DATASET --version RELEASE --basis same_material
32
+ ```
33
+
34
+ ## Which targets are being compared?
35
+
36
+ `TargetRelation` links exact, reference-scoped targets with mapping evidence
37
+ and cardinality. `same_target` compares one target across assays/samples.
38
+ `canonical_translation` links a gene/transcript to its annotated protein.
39
+ `parent_rna_context` links parent RNA to an ORF but keeps the RNA value attached
40
+ to its original gene/transcript. `shared_group_member` records an ambiguous
41
+ protein-group assignment; the group value stays a group value.
42
+
43
+ Importers pass `AssayPair` and `TargetRelation` iterators as `pairings=` and
44
+ `relations=` to `QuantitativeDataset.create`. IDs belong to the dataset release
45
+ and reference context. Invalid assay references, contradictory known donor
46
+ identities and conflicting one-to-one assignments fail the import. Original
47
+ storage-version-1 datasets remain readable and have no declared relationships.
48
+
49
+ ## Ratios are derived measurements
50
+
51
+ ```python
52
+ ratio = match.ratio(
53
+ selected_protein_measurement, selected_rna_measurement,
54
+ measurement_id="ratio-1", method="native protein/RNA ratio",
55
+ normalization={"protein": "source bridge normalization", "rna": "source TPM"},
56
+ )
57
+ ```
58
+
59
+ Select actual measurements from opposite sides of the match. Ratios require
60
+ one-to-one target attribution and linear inputs. Parent-RNA/ORF and shared-group
61
+ links cannot produce target-specific ratios. There is no implicit log conversion,
62
+ unit conversion, replicate averaging or uncertainty propagation.
63
+
64
+ An iBAQ/TPM ratio keeps `(iBAQ)/(TPM)` units. Copies-per-cell values also retain
65
+ their native composite units and the calibration recorded on their source
66
+ assays. Missing, censored or nonpositive denominators produce an unresolved
67
+ `not_measured` result with its reason, never infinity or a measured zero. RPF/RNA
68
+ ratios are source-defined translation-efficiency measures, not measured flux.
69
+ All derived values retain their input IDs, pairing, target attribution and
70
+ normalization assumptions.
71
+
72
+ ## Across species
73
+
74
+ `compare_orthologs(left_dataset, right_dataset, mappings)` is separate and
75
+ opt-in. Each `OrthologyMapping` pins its source file, release, both reference
76
+ contexts, cardinality, confidence definition and evidence. The default accepts
77
+ one-to-one canonical gene mappings; one-to-many relationships require explicit
78
+ permission through `allowed_cardinalities`.
79
+
80
+ The result contains each species' original observations. It neither merges
81
+ identities nor transfers abundance or protein-existence evidence. ORF comparisons
82
+ additionally require their own sequence, frame and positional evidence and
83
+ `allow_orfs=True`; a gene ortholog cannot establish an orthologous ncORF.
84
+ Human, mouse, dog, yeast and arbitrary other taxa use this same API.
@@ -17,6 +17,14 @@ from .datasets import DatasetRegistry, build_dataset
17
17
  from .initiation_context import KOZAK_PREFERENCES, InitiationContext, annotate_initiation_context
18
18
  from .measurements import Assay, DatasetManifest, Measurement, Sample, SourceAsset, TargetMapping
19
19
  from .model import ORFModel, ReferenceORF
20
+ from .pairing import (
21
+ AssayPair,
22
+ MatchedMeasurements,
23
+ OrthologyComparison,
24
+ OrthologyMapping,
25
+ TargetRelation,
26
+ compare_orthologs,
27
+ )
20
28
  from .reference import ReferenceContext, ScopedTarget
21
29
  from .version import __version__
22
30
 
@@ -40,6 +48,12 @@ __all__ = [
40
48
  "Assay",
41
49
  "Measurement",
42
50
  "TargetMapping",
51
+ "AssayPair",
52
+ "TargetRelation",
53
+ "MatchedMeasurements",
54
+ "OrthologyMapping",
55
+ "OrthologyComparison",
56
+ "compare_orthologs",
43
57
  "load_catalog",
44
58
  "UORFCatalog",
45
59
  "UORFRecord",
@@ -16,6 +16,14 @@ from .measurements import (
16
16
  Sample,
17
17
  TargetMapping,
18
18
  )
19
+ from .pairing import (
20
+ PAIRING_BASES,
21
+ TEMPORAL_RELATIONS,
22
+ AssayPair,
23
+ MatchedMeasurements,
24
+ TargetRelation,
25
+ _check_one_to_one,
26
+ )
19
27
  from .reference import ReferenceContext, ScopedTarget
20
28
 
21
29
  _SCHEMA = """
@@ -51,6 +59,18 @@ CREATE TABLE input_measurement (
51
59
  CREATE INDEX target_lookup ON target(reference_key, namespace, kind, identifier);
52
60
  CREATE INDEX observation_lookup ON measurement(reference_key, target_key, sample_id, assay_id);
53
61
  CREATE INDEX assay_lookup ON measurement(reference_key, assay_id, quantity, status);
62
+ CREATE TABLE assay_pair (
63
+ reference_key TEXT NOT NULL REFERENCES reference(key), id TEXT NOT NULL,
64
+ left_assay_id TEXT, right_assay_id TEXT, record TEXT NOT NULL,
65
+ PRIMARY KEY(reference_key, id),
66
+ FOREIGN KEY(reference_key, left_assay_id) REFERENCES assay(reference_key, id),
67
+ FOREIGN KEY(reference_key, right_assay_id) REFERENCES assay(reference_key, id));
68
+ CREATE TABLE target_relation (
69
+ reference_key TEXT NOT NULL, id TEXT NOT NULL,
70
+ left_target_key TEXT NOT NULL, right_target_key TEXT NOT NULL, record TEXT NOT NULL,
71
+ PRIMARY KEY(reference_key, id),
72
+ FOREIGN KEY(reference_key, left_target_key) REFERENCES target(reference_key, key),
73
+ FOREIGN KEY(reference_key, right_target_key) REFERENCES target(reference_key, key));
54
74
  """
55
75
 
56
76
 
@@ -73,7 +93,8 @@ class QuantitativeDataset:
73
93
  def __init__(self, path):
74
94
  self.path = Path(path).resolve()
75
95
  with closing(self._connect()) as connection:
76
- if connection.execute("PRAGMA user_version").fetchone()[0] != 1:
96
+ self._storage_version = connection.execute("PRAGMA user_version").fetchone()[0]
97
+ if self._storage_version not in (1, 2):
77
98
  raise ValueError("Unsupported quantitative database schema")
78
99
  row = connection.execute("SELECT value FROM metadata WHERE name='manifest'").fetchone()
79
100
  if row is None:
@@ -88,7 +109,8 @@ class QuantitativeDataset:
88
109
  return connection
89
110
 
90
111
  @classmethod
91
- def create(cls, path, manifest, *, samples, assays, measurements, mappings=()):
112
+ def create(cls, path, manifest, *, samples, assays, measurements, mappings=(),
113
+ pairings=(), relations=()):
92
114
  """Write a new dataset from iterators, validating scope and foreign keys.
93
115
 
94
116
  Never overwrite an existing file. An interrupted/invalid build removes
@@ -106,7 +128,7 @@ class QuantitativeDataset:
106
128
  connection = sqlite3.connect(path)
107
129
  connection.execute("PRAGMA foreign_keys=ON")
108
130
  connection.executescript(_SCHEMA)
109
- connection.execute("PRAGMA user_version=1")
131
+ connection.execute("PRAGMA user_version=2")
110
132
  connection.execute("INSERT INTO metadata VALUES ('manifest', ?)",
111
133
  (_json(manifest.to_dict()),))
112
134
  for reference in manifest.references:
@@ -125,6 +147,45 @@ class QuantitativeDataset:
125
147
  connection.execute("INSERT INTO assay VALUES (?, ?, ?, ?)",
126
148
  (assay.reference.key, assay.assay_id, assay.sample_id,
127
149
  _json(assay.to_dict())))
150
+ for pair in pairings:
151
+ if not isinstance(pair, AssayPair):
152
+ raise TypeError("pairings must contain AssayPairs")
153
+ if (pair.dataset_id, pair.dataset_version) != (manifest.dataset_id, manifest.version):
154
+ raise ValueError("Pairing belongs to a different dataset release")
155
+ if pair.basis in ("same_material", "adjacent_donor_tissue"):
156
+ units = []
157
+ for identifier in (pair.left_assay_id, pair.right_assay_id):
158
+ row = connection.execute(
159
+ "SELECT s.record FROM assay a JOIN sample s ON a.reference_key=s.reference_key "
160
+ "AND a.sample_id=s.id WHERE a.reference_key=? AND a.id=?",
161
+ (pair.reference.key, identifier)).fetchone()
162
+ if row:
163
+ unit = json.loads(row[0]).get("biological_unit_id")
164
+ if unit is not None:
165
+ units.append(unit)
166
+ if len(set(units)) > 1:
167
+ raise ValueError("Declared pairing contradicts biological-unit identities")
168
+ connection.execute("INSERT INTO assay_pair VALUES (?, ?, ?, ?, ?)",
169
+ (pair.reference.key, pair.pair_id, pair.left_assay_id,
170
+ pair.right_assay_id, _json(pair.to_dict())))
171
+ relation_links = ({}, {})
172
+ for relation in relations:
173
+ if not isinstance(relation, TargetRelation):
174
+ raise TypeError("relations must contain TargetRelations")
175
+ if (relation.dataset_id, relation.dataset_version) != (manifest.dataset_id, manifest.version):
176
+ raise ValueError("Target relation belongs to a different dataset release")
177
+ for target in (relation.left, relation.right):
178
+ connection.execute("INSERT OR IGNORE INTO target VALUES (?, ?, ?, ?, ?)",
179
+ (target.reference.key, _target_key(target), target.kind,
180
+ target.namespace, target.identifier))
181
+ for index, (first, second) in enumerate(((relation.left, relation.right), (relation.right, relation.left))):
182
+ _check_one_to_one(relation_links[index],
183
+ (relation.relation, first.key, second.kind, second.namespace), second.key,
184
+ relation.cardinality == "one_to_one")
185
+ connection.execute("INSERT INTO target_relation VALUES (?, ?, ?, ?, ?)",
186
+ (relation.left.reference.key, relation.relation_id,
187
+ _target_key(relation.left), _target_key(relation.right),
188
+ _json(relation.to_dict())))
128
189
  for mapping in mappings:
129
190
  if not isinstance(mapping, TargetMapping):
130
191
  raise TypeError("mappings must contain TargetMappings")
@@ -164,7 +225,8 @@ class QuantitativeDataset:
164
225
  ((reference_key, observation.measurement_id, identifier)
165
226
  for identifier in observation.input_measurements))
166
227
  counts = {table: connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
167
- for table in ("sample", "assay", "mapping", "target", "measurement")}
228
+ for table in ("sample", "assay", "mapping", "target", "measurement",
229
+ "assay_pair", "target_relation")}
168
230
  connection.execute("INSERT INTO metadata VALUES ('counts', ?)", (_json(counts),))
169
231
  connection.commit()
170
232
  connection.close()
@@ -205,6 +267,58 @@ class QuantitativeDataset:
205
267
  def mappings(self, *, reference=None):
206
268
  return self._records("mapping", TargetMapping, reference=reference)
207
269
 
270
+ def pairings(self, *, reference=None):
271
+ """Iterate source-declared pairings; legacy releases have none."""
272
+ if self._storage_version == 1:
273
+ self._scope(reference)
274
+ return iter(())
275
+ return self._records("assay_pair", AssayPair, reference=reference)
276
+
277
+ def relations(self, *, reference=None):
278
+ """Iterate source-backed within-reference target relationships."""
279
+ if self._storage_version == 1:
280
+ self._scope(reference)
281
+ return iter(())
282
+ return self._records("target_relation", TargetRelation, reference=reference)
283
+
284
+ def matched_measurements(self, *, allowed_bases, allowed_temporal=("concurrent",),
285
+ reference=None, pair_ids=None, relation_ids=None):
286
+ """Iterate explicit assay/target matches, preserving all values and missing mates.
287
+
288
+ Defaults require concurrent assays; opting into longitudinal or unknown
289
+ timing is explicit. No matching by tissue names or inferred orthology.
290
+ Each side contains its own original observations, never a Cartesian
291
+ multiplication or an aggregate over technical replicates/quantities.
292
+ """
293
+ bases, temporal = set(allowed_bases), set(allowed_temporal)
294
+ if isinstance(pair_ids, str) or isinstance(relation_ids, str):
295
+ raise TypeError("Relationship ID selections must be sequences, not a single string")
296
+ if not bases or bases - set(PAIRING_BASES):
297
+ raise ValueError("Specify valid allowed pairing bases explicitly")
298
+ if not temporal or temporal - set(TEMPORAL_RELATIONS):
299
+ raise ValueError("Specify valid allowed temporal relationships")
300
+ if (pair_ids or relation_ids) and reference is None and len(self._references) > 1:
301
+ raise ValueError("Relationship ID queries across species require a reference")
302
+ relations = tuple(relation for relation in self.relations(reference=reference)
303
+ if relation_ids is None or relation.relation_id in relation_ids)
304
+ for pair in self.pairings(reference=reference):
305
+ if pair.basis not in bases or pair.temporal_relation not in temporal:
306
+ continue
307
+ if pair_ids is not None and pair.pair_id not in pair_ids:
308
+ continue
309
+ by_target = []
310
+ for assay in (pair.left_assay_id, pair.right_assay_id):
311
+ values = {}
312
+ if assay is not None:
313
+ for value in self.measurements(reference=pair.reference, assay_id=assay):
314
+ values.setdefault(value.target.key, []).append(value)
315
+ by_target.append(values)
316
+ for relation in relations:
317
+ if relation.left.reference.key == pair.reference.key:
318
+ yield MatchedMeasurements(pair, relation,
319
+ tuple(by_target[0].get(relation.left.key, ())),
320
+ tuple(by_target[1].get(relation.right.key, ())))
321
+
208
322
  def measurements(self, *, target=None, reference=None, sample_id=None,
209
323
  assay_id=None, quantity=None, status=None):
210
324
  """Iterate native observations; a target match is exact, never orthology.
@@ -18,7 +18,7 @@ def run(argv=None, prog=None):
18
18
  listing.add_argument("--species")
19
19
  listing.add_argument("--taxon-id", type=int)
20
20
  listing.add_argument("--assay")
21
- for command in ("info", "download", "inspect", "query"):
21
+ for command in ("info", "download", "inspect", "query", "pairs"):
22
22
  subparser = commands.add_parser(command)
23
23
  subparser.add_argument("name")
24
24
  subparser.add_argument("--version")
@@ -36,6 +36,14 @@ def run(argv=None, prog=None):
36
36
  subparser.add_argument("--target")
37
37
  subparser.add_argument("--kind")
38
38
  subparser.add_argument("--namespace")
39
+ if command == "pairs":
40
+ subparser.add_argument("--basis", action="append", required=True,
41
+ help="Allowed pairing basis; repeat for multiple bases")
42
+ subparser.add_argument("--temporal", action="append",
43
+ help="Allowed temporal relation; default concurrent")
44
+ subparser.add_argument("--reference")
45
+ subparser.add_argument("--pair-id", action="append")
46
+ subparser.add_argument("--relation-id", action="append")
39
47
  args = parser.parse_args(argv)
40
48
  try:
41
49
  definitions = None
@@ -60,6 +68,14 @@ def run(argv=None, prog=None):
60
68
  sources = {"dataset.sqlite": args.source_path} if args.source_path else None
61
69
  result = registry.download(args.name, args.version, force=args.force,
62
70
  source_paths=sources, **options)
71
+ elif args.command == "pairs":
72
+ dataset = registry.open(args.name, args.version)
73
+ for match in dataset.matched_measurements(
74
+ allowed_bases=args.basis, allowed_temporal=args.temporal or ["concurrent"],
75
+ reference=args.reference, pair_ids=args.pair_id, relation_ids=args.relation_id,
76
+ ):
77
+ sys.stdout.write(json.dumps(match.to_table_row()) + "\n")
78
+ return
63
79
  else:
64
80
  dataset = registry.open(args.name, args.version)
65
81
  target = None
@@ -13,6 +13,7 @@ ASSAY_KINDS = ("rna_seq", "ribo_seq", "digest_ms", "mhc_ms", "targeted_ms")
13
13
  QUANTITIES = (
14
14
  "rna_abundance", "ribosome_occupancy", "translation_efficiency",
15
15
  "protein_abundance", "peptide_abundance", "spectrum_count", "turnover",
16
+ "protein_rna_ratio", "abundance_ratio",
16
17
  )
17
18
  MEASUREMENT_STATUSES = ("measured", "below_detection", "not_measured", "not_mapped", "missing")
18
19
  MEASUREMENT_ORIGINS = ("observed", "derived", "predicted")
@@ -0,0 +1,329 @@
1
+ """Explicit assay relationships and comparisons of original observations."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ from .measurements import Measurement, SourceAsset, _identifier, _metadata, _number, _Record
8
+ from .reference import ReferenceContext, ScopedTarget
9
+
10
+ PAIRING_BASES = (
11
+ "same_material", "adjacent_donor_tissue", "biological_replicate",
12
+ "independent_cultures", "tissue_aggregate", "condition_aggregate",
13
+ )
14
+ TEMPORAL_RELATIONS = ("concurrent", "longitudinal", "unknown")
15
+ TARGET_RELATIONS = ("same_target", "canonical_translation", "parent_rna_context", "shared_group_member")
16
+
17
+
18
+ def _target(data):
19
+ values = dict(data)
20
+ values["reference"] = ReferenceContext(**values["reference"])
21
+ return ScopedTarget(**values)
22
+
23
+
24
+ def _release(record):
25
+ for name in ("dataset_id", "dataset_version"):
26
+ _identifier(getattr(record, name), name)
27
+
28
+
29
+ def _check_one_to_one(seen, source_key, target_key, one_to_one):
30
+ """Check a claimed one-to-one edge against every supplied candidate edge."""
31
+ targets, strict = seen.setdefault(source_key, (set(), False))
32
+ targets.add(target_key)
33
+ strict = strict or one_to_one
34
+ if strict and len(targets) > 1:
35
+ raise ValueError("Conflicting one-to-one target relationships")
36
+ seen[source_key] = (targets, strict)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AssayPair(_Record):
41
+ """A source-backed assay pairing, including an explicitly missing mate.
42
+
43
+ The pairing basis and temporal relationship are independent. A shared tissue
44
+ label supplies neither. Replicate/aliquot identity remains in the original
45
+ samples and assays, rather than being counted from the number of pair rows.
46
+ """
47
+
48
+ pair_id: str
49
+ reference: ReferenceContext
50
+ dataset_id: str
51
+ dataset_version: str
52
+ left_assay_id: Optional[str]
53
+ right_assay_id: Optional[str]
54
+ basis: str
55
+ evidence: Mapping
56
+ temporal_relation: str = "unknown"
57
+ interval: Optional[float] = None
58
+ interval_unit: Optional[str] = None
59
+
60
+ def __post_init__(self):
61
+ _identifier(self.pair_id, "pair_id")
62
+ _release(self)
63
+ if not isinstance(self.reference, ReferenceContext):
64
+ raise TypeError("Pair requires a ReferenceContext")
65
+ for identifier in (self.left_assay_id, self.right_assay_id):
66
+ if identifier is not None:
67
+ _identifier(identifier, "assay ID")
68
+ if self.left_assay_id is None and self.right_assay_id is None:
69
+ raise ValueError("A pairing requires at least one declared assay")
70
+ if self.left_assay_id == self.right_assay_id:
71
+ raise ValueError("A pairing requires distinct assays")
72
+ if self.basis not in PAIRING_BASES:
73
+ raise ValueError("Unknown assay pairing basis")
74
+ if self.temporal_relation not in TEMPORAL_RELATIONS:
75
+ raise ValueError("Unknown temporal relationship")
76
+ if not self.evidence:
77
+ raise ValueError("Pairing requires source evidence")
78
+ if self.interval is not None:
79
+ _number(self.interval, "interval")
80
+ _identifier(self.interval_unit, "interval_unit")
81
+ if self.temporal_relation == "concurrent" and self.interval != 0:
82
+ raise ValueError("Concurrent assays cannot have a nonzero interval")
83
+ elif self.interval_unit is not None:
84
+ raise ValueError("Interval units require a value")
85
+ _metadata(self, "evidence")
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class TargetRelation(_Record):
90
+ """Explain why two exact targets can be compared without changing either ID."""
91
+
92
+ relation_id: str
93
+ dataset_id: str
94
+ dataset_version: str
95
+ left: ScopedTarget
96
+ right: ScopedTarget
97
+ relation: str
98
+ cardinality: str
99
+ evidence: Mapping
100
+
101
+ def __post_init__(self):
102
+ _identifier(self.relation_id, "relation_id")
103
+ _release(self)
104
+ if not isinstance(self.left, ScopedTarget) or not isinstance(self.right, ScopedTarget):
105
+ raise TypeError("Target relations require ScopedTargets")
106
+ if self.left.reference.key != self.right.reference.key:
107
+ raise ValueError("Within-dataset target relations cannot cross reference contexts")
108
+ if self.relation not in TARGET_RELATIONS:
109
+ raise ValueError("Unknown target relationship")
110
+ if self.cardinality not in ("one_to_one", "one_to_many", "many_to_one", "many_to_many", "unknown"):
111
+ raise ValueError("Unknown target relationship cardinality")
112
+ if not self.evidence:
113
+ raise ValueError("Target relations require mapping evidence")
114
+ kinds = (self.left.kind, self.right.kind)
115
+ if self.relation == "same_target" and self.left.key != self.right.key:
116
+ raise ValueError("same_target requires identical scoped target identities")
117
+ if self.relation == "same_target" and self.cardinality != "one_to_one":
118
+ raise ValueError("An identical target has one-to-one cardinality")
119
+ if self.relation == "canonical_translation" and kinds not in (("gene", "protein"), ("transcript", "protein")):
120
+ raise ValueError("Canonical translation links a gene/transcript to a protein")
121
+ if self.relation == "parent_rna_context" and kinds not in (("gene", "orf"), ("transcript", "orf")):
122
+ raise ValueError("Parent RNA context links a gene/transcript to an ORF")
123
+ if self.relation == "shared_group_member" and kinds not in (("protein_group", "protein"), ("protein_group", "orf")):
124
+ raise ValueError("Group membership links a group to one candidate member")
125
+ _metadata(self, "evidence")
126
+
127
+ @classmethod
128
+ def from_dict(cls, data):
129
+ values = dict(data)
130
+ values["left"], values["right"] = _target(values["left"]), _target(values["right"])
131
+ return cls(**values)
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class MatchedMeasurements(_Record):
136
+ """One declared relationship with all original values and explicit missingness.
137
+
138
+ Tuples avoid multiplying technical/quantity records into pseudo-replicates.
139
+ For parent_rna_context, left values remain measurements of the parent RNA.
140
+ They are never measurements of the ORF named on the right.
141
+ """
142
+
143
+ pairing: AssayPair
144
+ relation: TargetRelation
145
+ left: tuple
146
+ right: tuple
147
+
148
+ @classmethod
149
+ def from_dict(cls, data):
150
+ return cls(AssayPair.from_dict(data["pairing"]), TargetRelation.from_dict(data["relation"]),
151
+ tuple(Measurement.from_dict(value) for value in data["left"]),
152
+ tuple(Measurement.from_dict(value) for value in data["right"]))
153
+
154
+ def __post_init__(self):
155
+ pair, relation = self.pairing, self.relation
156
+ if not isinstance(pair, AssayPair) or not isinstance(relation, TargetRelation):
157
+ raise TypeError("A match requires an AssayPair and TargetRelation")
158
+ if (pair.dataset_id, pair.dataset_version) != (relation.dataset_id, relation.dataset_version):
159
+ raise ValueError("Pairing and target relation belong to different dataset releases")
160
+ if pair.reference.key != relation.left.reference.key:
161
+ raise ValueError("Pairing and target relation have different references")
162
+ for side in ("left", "right"):
163
+ values = tuple(getattr(self, side))
164
+ target = getattr(relation, side)
165
+ assay_id = getattr(pair, side + "_assay_id")
166
+ seen = set()
167
+ for value in values:
168
+ if not isinstance(value, Measurement):
169
+ raise TypeError("Matched values must be Measurements")
170
+ if (value.dataset_id, value.dataset_version) != (pair.dataset_id, pair.dataset_version):
171
+ raise ValueError("Matched measurement belongs to a different dataset release")
172
+ if value.target.key != target.key or value.assay_id != assay_id:
173
+ raise ValueError("Matched observation disagrees with its declared target/assay")
174
+ if value.measurement_id in seen:
175
+ raise ValueError("Duplicate matched measurement")
176
+ seen.add(value.measurement_id)
177
+ object.__setattr__(self, side, values)
178
+
179
+ def to_table_row(self):
180
+ """Return named assay-quantity columns with original units and provenance.
181
+
182
+ Columns contain lists, preserving multiple measurements without silently
183
+ averaging them. Empty columns are missing observations, not measured zero.
184
+ Pairing and target relationships remain present to qualify comparisons.
185
+ """
186
+ columns = {name: [] for name in (
187
+ "rna_abundance", "ribosome_occupancy", "translation_efficiency",
188
+ "protein_abundance", "peptide_abundance", "spectrum_count", "other")}
189
+ for value in self.left + self.right:
190
+ key = value.quantity if value.quantity in columns else "other"
191
+ columns[key].append(value.to_dict())
192
+ return dict(dataset_id=self.pairing.dataset_id, dataset_version=self.pairing.dataset_version,
193
+ pairing=self.pairing.to_dict(), target_relation=self.relation.to_dict(), **columns)
194
+
195
+ def ratio(self, numerator, denominator, *, measurement_id, method, normalization):
196
+ """Derive a named native-unit ratio from explicitly selected observations.
197
+
198
+ No implicit log conversion, replicate aggregation, unit conversion or
199
+ uncertainty propagation. Undefined ratios are not_measured with a reason.
200
+ Parent RNA and ambiguous group membership cannot produce ORF ratios.
201
+ """
202
+ _identifier(method, "ratio method")
203
+ if not isinstance(normalization, Mapping) or not normalization:
204
+ raise ValueError("Ratio normalization and assumptions must be explicit")
205
+ if self.relation.relation not in ("same_target", "canonical_translation"):
206
+ raise ValueError("Parent RNA/group membership cannot establish target-specific ratios")
207
+ if self.relation.cardinality != "one_to_one":
208
+ raise ValueError("A target-specific ratio requires one-to-one attribution")
209
+ if not ((numerator in self.left and denominator in self.right)
210
+ or (numerator in self.right and denominator in self.left)):
211
+ raise ValueError("Ratio inputs must be original measurements on opposite sides")
212
+ if numerator.scale != "linear" or denominator.scale != "linear":
213
+ raise ValueError("Ratio inputs must be linear; any log conversion must be explicit")
214
+ if (numerator.quantity, denominator.quantity) == ("protein_abundance", "rna_abundance"):
215
+ quantity = "protein_rna_ratio"
216
+ elif (numerator.quantity, denominator.quantity) == ("ribosome_occupancy", "rna_abundance"):
217
+ quantity = "translation_efficiency"
218
+ elif numerator.quantity == denominator.quantity:
219
+ quantity = "abundance_ratio"
220
+ else:
221
+ raise ValueError("Unsupported ratio quantity relationship")
222
+ reason = None
223
+ if numerator.status != "measured" or denominator.status != "measured":
224
+ reason = "input_not_measured"
225
+ elif denominator.value <= 0:
226
+ reason = "nonpositive_denominator"
227
+ derivation = dict(method=method, normalization=dict(normalization),
228
+ pairing=self.pairing.to_dict(), target_relation=self.relation.to_dict(),
229
+ uncertainty="not_propagated", unresolved_reason=reason)
230
+ return Measurement(
231
+ measurement_id=measurement_id, target=numerator.target,
232
+ sample_id=numerator.sample_id, assay_id=numerator.assay_id, quantity=quantity,
233
+ value=None if reason else numerator.value / denominator.value,
234
+ unit=f"({numerator.unit})/({denominator.unit})",
235
+ dataset_id=numerator.dataset_id, dataset_version=numerator.dataset_version,
236
+ status="not_measured" if reason else "measured", origin="derived",
237
+ input_measurements=(numerator.measurement_id, denominator.measurement_id),
238
+ derivation=derivation,
239
+ )
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class OrthologyMapping(_Record):
244
+ """Pinned cross-species mapping; it never transfers measurements or evidence."""
245
+
246
+ mapping_id: str
247
+ left: ScopedTarget
248
+ right: ScopedTarget
249
+ source: SourceAsset
250
+ source_release: str
251
+ cardinality: str
252
+ confidence: Mapping
253
+ evidence: Mapping
254
+
255
+ def __post_init__(self):
256
+ _identifier(self.mapping_id, "mapping_id")
257
+ _identifier(self.source_release, "source_release")
258
+ if not isinstance(self.left, ScopedTarget) or not isinstance(self.right, ScopedTarget):
259
+ raise TypeError("Orthology requires ScopedTargets")
260
+ if self.left.reference.taxon_id == self.right.reference.taxon_id:
261
+ raise ValueError("Orthology comparison requires different species")
262
+ if not isinstance(self.source, SourceAsset):
263
+ raise TypeError("Orthology source must have a pinned SourceAsset")
264
+ if self.cardinality not in ("one_to_one", "one_to_many", "many_to_one", "many_to_many"):
265
+ raise ValueError("Unknown orthology cardinality")
266
+ if (self.left.kind, self.right.kind) not in (("gene", "gene"), ("orf", "orf")):
267
+ raise ValueError("Orthology maps canonical genes or independently supported ORFs")
268
+ if not self.evidence or not self.confidence:
269
+ raise ValueError("Orthology evidence and source confidence definition are required")
270
+ if self.left.kind == "orf" and not all(self.evidence.get(key) for key in
271
+ ("sequence", "frame", "position")):
272
+ raise ValueError("ORF orthology requires its own sequence, frame and position evidence")
273
+ _metadata(self, "confidence", "evidence")
274
+
275
+ @classmethod
276
+ def from_dict(cls, data):
277
+ values = dict(data)
278
+ values["left"], values["right"] = _target(values["left"]), _target(values["right"])
279
+ values["source"] = SourceAsset.from_dict(values["source"])
280
+ return cls(**values)
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class OrthologyComparison(_Record):
285
+ mapping: OrthologyMapping
286
+ left: tuple
287
+ right: tuple
288
+
289
+ @classmethod
290
+ def from_dict(cls, data):
291
+ return cls(OrthologyMapping.from_dict(data["mapping"]),
292
+ tuple(Measurement.from_dict(value) for value in data["left"]),
293
+ tuple(Measurement.from_dict(value) for value in data["right"]))
294
+
295
+ def __post_init__(self):
296
+ if not isinstance(self.mapping, OrthologyMapping):
297
+ raise TypeError("Comparison requires an OrthologyMapping")
298
+ for side in ("left", "right"):
299
+ values = tuple(getattr(self, side))
300
+ target = getattr(self.mapping, side)
301
+ if any(not isinstance(value, Measurement) or value.target.key != target.key for value in values):
302
+ raise ValueError("Orthology observations must retain their original scoped targets")
303
+ object.__setattr__(self, side, values)
304
+
305
+
306
+ def compare_orthologs(left_dataset, right_dataset, mappings, *,
307
+ allowed_cardinalities=("one_to_one",), allow_orfs=False,
308
+ left_filters=None, right_filters=None):
309
+ """Return both species' original observations for explicitly opted-in mappings."""
310
+ allowed = set(allowed_cardinalities)
311
+ valid = {"one_to_one", "one_to_many", "many_to_one", "many_to_many"}
312
+ if not allowed or allowed - valid:
313
+ raise ValueError("Explicit valid orthology cardinalities are required")
314
+ mappings = tuple(mappings)
315
+ seen = ({}, {})
316
+ for mapping in mappings:
317
+ if not isinstance(mapping, OrthologyMapping):
318
+ raise TypeError("Expected OrthologyMapping records")
319
+ for index, (source, target) in enumerate(((mapping.left, mapping.right), (mapping.right, mapping.left))):
320
+ _check_one_to_one(seen[index], (source.key, target.reference.key, target.namespace), target.key,
321
+ mapping.cardinality == "one_to_one")
322
+ for mapping in mappings:
323
+ if mapping.cardinality not in allowed or (mapping.left.kind == "orf" and not allow_orfs):
324
+ continue
325
+ yield OrthologyComparison(
326
+ mapping,
327
+ tuple(left_dataset.measurements(target=mapping.left, **(left_filters or {}))),
328
+ tuple(right_dataset.measurements(target=mapping.right, **(right_filters or {}))),
329
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orfdata
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Reference open reading frames and experimental translation evidence on annotated transcripts
5
5
  Author: OpenVax
6
6
  License-Expression: Apache-2.0
@@ -7,6 +7,7 @@ pyproject.toml
7
7
  test.sh
8
8
  docs/design.md
9
9
  docs/measurements.md
10
+ docs/pairing.md
10
11
  docs/reference.md
11
12
  docs/species.md
12
13
  orfdata/__init__.py
@@ -19,6 +20,7 @@ orfdata/datasets.py
19
20
  orfdata/initiation_context.py
20
21
  orfdata/measurements.py
21
22
  orfdata/model.py
23
+ orfdata/pairing.py
22
24
  orfdata/reference.py
23
25
  orfdata/sequence.py
24
26
  orfdata/version.py
@@ -37,8 +39,10 @@ orfdata/data/uorf-evidence/AUTHOR-LICENSE.txt
37
39
  orfdata/data/uorf-evidence/NOTICE.txt
38
40
  orfdata/data/uorf-evidence/catalog.json.gz
39
41
  orfdata/data/uorf-evidence/manifest.json
42
+ tests/conftest.py
40
43
  tests/test_initiation_context.py
41
44
  tests/test_measurements.py
45
+ tests/test_pairing.py
42
46
  tests/test_reference_context.py
43
47
  tests/test_reference_orf.py
44
48
  tests/test_uorf_catalog.py
@@ -0,0 +1,45 @@
1
+ """Shared synthetic quantitative fixtures; these are not experimental evidence."""
2
+
3
+ import hashlib
4
+
5
+ import pytest
6
+
7
+ from orfdata import (
8
+ Assay,
9
+ DatasetManifest,
10
+ Measurement,
11
+ ReferenceContext,
12
+ Sample,
13
+ ScopedTarget,
14
+ SourceAsset,
15
+ )
16
+
17
+
18
+ @pytest.fixture
19
+ def experiment(tmp_path):
20
+ source = tmp_path / "input.tsv"
21
+ source.write_text("source fixture\n")
22
+ contexts = tuple(ReferenceContext(taxon, species, assembly, "ensembl", "110")
23
+ for taxon, species, assembly in (
24
+ (9606, "human", "GRCh38"), (10090, "mouse", "GRCm39"),
25
+ (9615, "dog", "ROS_Cfam_1.0"), (4932, "yeast", "R64-1-1")))
26
+ manifest = DatasetManifest(
27
+ "fixture", "1", "Synthetic four-species measurement validation", contexts,
28
+ (SourceAsset("input.tsv", source.as_uri(), hashlib.sha256(source.read_bytes()).hexdigest(),
29
+ source.stat().st_size, "Synthetic test data; Apache-2.0", redistributable=True),),
30
+ ("Synthetic test fixture, not experimental evidence",), ("rna_seq", "digest_ms", "ribo_seq"),
31
+ dict(searched="Deliberately colliding test identifiers", exclusions="No biological inference"),
32
+ dict(software="orfdata tests", version="1", parameters={} ),
33
+ )
34
+ samples = tuple(Sample("sample", context, biological_unit_id="animal",
35
+ attributes={"source_taxon": context.taxon_id}) for context in contexts)
36
+ assays = tuple(Assay("rna", "sample", "rna_seq", context, technical_replicate="1")
37
+ for context in contexts)
38
+ observations = tuple(Measurement(
39
+ "value", ScopedTarget(context, "gene", "same_id", "ensembl:110"),
40
+ "sample", "rna", "rna_abundance", index, "TPM", "fixture", "1",
41
+ provenance=dict(source_asset="input.tsv", row=index + 1),
42
+ ) for index, context in enumerate(contexts))
43
+ return manifest, samples, assays, observations
44
+
45
+
@@ -9,15 +9,11 @@ from types import SimpleNamespace
9
9
  import pytest
10
10
 
11
11
  from orfdata import (
12
- Assay,
13
12
  DatasetManifest,
14
13
  DatasetRegistry,
15
14
  Measurement,
16
15
  QuantitativeDataset,
17
- ReferenceContext,
18
- Sample,
19
16
  ScopedTarget,
20
- SourceAsset,
21
17
  TargetMapping,
22
18
  build_dataset,
23
19
  )
@@ -25,34 +21,6 @@ from orfdata.cli import run
25
21
  from orfdata.measurements import MEASUREMENT_STATUSES
26
22
 
27
23
 
28
- @pytest.fixture
29
- def experiment(tmp_path):
30
- source = tmp_path / "input.tsv"
31
- source.write_text("source fixture\n")
32
- contexts = tuple(ReferenceContext(taxon, species, assembly, "ensembl", "110")
33
- for taxon, species, assembly in (
34
- (9606, "human", "GRCh38"), (10090, "mouse", "GRCm39"),
35
- (9615, "dog", "ROS_Cfam_1.0"), (4932, "yeast", "R64-1-1")))
36
- manifest = DatasetManifest(
37
- "fixture", "1", "Synthetic four-species measurement validation", contexts,
38
- (SourceAsset("input.tsv", source.as_uri(), hashlib.sha256(source.read_bytes()).hexdigest(),
39
- source.stat().st_size, "Synthetic test data; Apache-2.0", redistributable=True),),
40
- ("Synthetic test fixture, not experimental evidence",), ("rna_seq", "digest_ms", "ribo_seq"),
41
- dict(searched="Deliberately colliding test identifiers", exclusions="No biological inference"),
42
- dict(software="orfdata tests", version="1", parameters={} ),
43
- )
44
- samples = tuple(Sample("sample", context, biological_unit_id="animal",
45
- attributes={"source_taxon": context.taxon_id}) for context in contexts)
46
- assays = tuple(Assay("rna", "sample", "rna_seq", context, technical_replicate="1")
47
- for context in contexts)
48
- observations = tuple(Measurement(
49
- "value", ScopedTarget(context, "gene", "same_id", "ensembl:110"),
50
- "sample", "rna", "rna_abundance", index, "TPM", "fixture", "1",
51
- provenance=dict(source_asset="input.tsv", row=index + 1),
52
- ) for index, context in enumerate(contexts))
53
- return manifest, samples, assays, observations
54
-
55
-
56
24
  def write_dataset(path, experiment, **kwargs):
57
25
  manifest, samples, assays, observations = experiment
58
26
  return QuantitativeDataset.create(path, manifest, samples=samples, assays=assays,
@@ -77,7 +45,8 @@ def test_four_species_id_collisions_and_scoped_queries(tmp_path, experiment):
77
45
  assert len(observations) == 4
78
46
  assert {o.target.reference.taxon_id for o in observations} == {9606, 10090, 9615, 4932}
79
47
  assert {o.dataset_id for o in observations} == {"fixture"}
80
- assert dataset.counts == dict(sample=4, assay=4, mapping=0, target=4, measurement=4)
48
+ assert dataset.counts == dict(sample=4, assay=4, mapping=0, target=4, measurement=4,
49
+ assay_pair=0, target_relation=0)
81
50
  for expected in experiment[3]:
82
51
  assert list(dataset.measurements(target=expected.target)) == [expected]
83
52
  assert list(dataset.measurements(reference=expected.target.reference, sample_id="sample")) == [expected]
@@ -0,0 +1,269 @@
1
+ """No manufactured sample matches, ORF specificity, replicates or molecule ratios."""
2
+
3
+ import json
4
+ import sqlite3
5
+ from dataclasses import replace
6
+
7
+ import pytest
8
+
9
+ from orfdata import (
10
+ Assay,
11
+ AssayPair,
12
+ DatasetRegistry,
13
+ MatchedMeasurements,
14
+ Measurement,
15
+ OrthologyComparison,
16
+ OrthologyMapping,
17
+ QuantitativeDataset,
18
+ ScopedTarget,
19
+ TargetRelation,
20
+ compare_orthologs,
21
+ )
22
+ from orfdata.cli import run
23
+ from orfdata.pairing import PAIRING_BASES
24
+
25
+
26
+ @pytest.fixture
27
+ def paired(experiment, tmp_path):
28
+ manifest, samples, assays, observations = experiment
29
+ proteins, pairs, relations = [], [], []
30
+ assays = list(assays)
31
+ for index, reference in enumerate(manifest.references):
32
+ target = ScopedTarget(reference, "protein", "same_protein", "source-fasta:1")
33
+ assays.append(Assay("protein", "sample", "digest_ms", reference, run_id="run-1",
34
+ technical_replicate="1", channel="126", plex="plex-1", bridge="131C",
35
+ normalization={"method": "source TMT bridge"}))
36
+ proteins.append(Measurement("protein-value", target, "sample", "protein", "protein_abundance",
37
+ 100 + index, "iBAQ", "fixture", "1",
38
+ provenance={"source_asset": "input.tsv", "row": index + 9}))
39
+ pairs.append(AssayPair("pair", reference, "fixture", "1", "rna", "protein", "same_material",
40
+ {"source_asset": "input.tsv", "source_pair_id": index}, "concurrent"))
41
+ relations.append(TargetRelation("encoding", "fixture", "1", observations[index].target,
42
+ target, "canonical_translation", "one_to_one",
43
+ {"source_asset": "input.tsv", "method": "test fixture"}))
44
+ values = dict(samples=samples, assays=assays, measurements=(*observations, *proteins),
45
+ pairings=pairs, relations=relations)
46
+ return manifest, values
47
+
48
+
49
+ def create(tmp_path, paired, **changes):
50
+ manifest, values = paired
51
+ return QuantitativeDataset.create(tmp_path / "paired.sqlite", manifest, **dict(values, **changes))
52
+
53
+
54
+ def test_four_species_native_observations_and_tmt_lineage(tmp_path, paired):
55
+ dataset = create(tmp_path, paired)
56
+ matches = list(dataset.matched_measurements(allowed_bases=["same_material"]))
57
+ assert len(matches) == 4
58
+ for match in matches:
59
+ assert match.left[0].target.kind == "gene"
60
+ assert match.right[0].target.kind == "protein"
61
+ assert match.left[0].target.reference.key == match.right[0].target.reference.key
62
+ assert len(match.left) == len(match.right) == 1
63
+ assert match.left[0].unit == "TPM" and match.right[0].unit == "iBAQ"
64
+ assert AssayPair.from_dict(match.pairing.to_dict()) == match.pairing
65
+ assert TargetRelation.from_dict(match.relation.to_dict()) == match.relation
66
+ assert MatchedMeasurements.from_dict(match.to_dict()) == match
67
+ assay = next(a for a in dataset.assays() if a.kind == "digest_ms")
68
+ assert (assay.plex, assay.channel, assay.bridge, assay.technical_replicate) == ("plex-1", "126", "131C", "1")
69
+ with pytest.raises(TypeError):
70
+ list(dataset.matched_measurements())
71
+ with pytest.raises(ValueError, match="across species"):
72
+ list(dataset.matched_measurements(allowed_bases=["same_material"], pair_ids=["pair"]))
73
+
74
+
75
+ @pytest.mark.parametrize("basis", PAIRING_BASES)
76
+ def test_pairing_basis_must_be_allowed_explicitly(tmp_path, paired, basis):
77
+ pair = replace(paired[1]["pairings"][0], basis=basis)
78
+ dataset = create(tmp_path, paired, pairings=[pair])
79
+ assert len(list(dataset.matched_measurements(allowed_bases=[basis]))) == 1
80
+ excluded = "same_material" if basis != "same_material" else "independent_cultures"
81
+ assert list(dataset.matched_measurements(allowed_bases=[excluded])) == []
82
+ with pytest.raises(ValueError, match="valid allowed"):
83
+ list(dataset.matched_measurements(allowed_bases=[]))
84
+
85
+
86
+ @pytest.mark.parametrize("temporal", ("unknown", "longitudinal"))
87
+ def test_time_mismatch_requires_opt_in(tmp_path, paired, temporal):
88
+ pair = replace(paired[1]["pairings"][0], temporal_relation=temporal)
89
+ dataset = create(tmp_path, paired, pairings=[pair])
90
+ assert list(dataset.matched_measurements(allowed_bases=["same_material"])) == []
91
+ assert len(list(dataset.matched_measurements(allowed_bases=["same_material"],
92
+ allowed_temporal=[temporal]))) == 1
93
+ with pytest.raises(ValueError, match="nonzero interval"):
94
+ replace(pair, temporal_relation="concurrent", interval=1, interval_unit="day")
95
+
96
+
97
+ def test_missing_mates_and_multiple_values_do_not_create_replicates(tmp_path, paired):
98
+ pair = replace(paired[1]["pairings"][0], right_assay_id=None)
99
+ original = paired[1]["measurements"][0]
100
+ extra = replace(original, measurement_id="rna-count", unit="counts", value=99)
101
+ dataset = create(tmp_path, paired, pairings=[pair], measurements=(*paired[1]["measurements"], extra))
102
+ match = next(dataset.matched_measurements(allowed_bases=["same_material"]))
103
+ assert len(match.left) == 2 and match.right == ()
104
+ assert {v.sample_id for v in match.left} == {"sample"}
105
+ assert {v.assay_id for v in match.left} == {"rna"}
106
+
107
+
108
+ def test_same_tissue_does_not_make_a_pair(tmp_path, paired):
109
+ samples = tuple(replace(sample, tissue="liver") for sample in paired[1]["samples"])
110
+ dataset = create(tmp_path, paired, samples=samples, pairings=[])
111
+ assert list(dataset.matched_measurements(allowed_bases=PAIRING_BASES)) == []
112
+
113
+
114
+ def test_pairing_cannot_disagree_with_known_donors_or_release(tmp_path, paired):
115
+ sample = replace(paired[1]["samples"][0], sample_id="other", biological_unit_id="other-animal")
116
+ assays = [replace(a, sample_id="other") if a.assay_id == "protein" and a.reference == sample.reference else a
117
+ for a in paired[1]["assays"]]
118
+ with pytest.raises(ValueError, match="biological-unit"):
119
+ create(tmp_path, paired, samples=(*paired[1]["samples"], sample), assays=assays)
120
+ pair = replace(paired[1]["pairings"][0], dataset_version="different")
121
+ with pytest.raises(ValueError, match="different dataset release"):
122
+ create(tmp_path, paired, pairings=[pair])
123
+ pair = replace(paired[1]["pairings"][0], right_assay_id="missing")
124
+ with pytest.raises(sqlite3.IntegrityError):
125
+ create(tmp_path, paired, pairings=[pair])
126
+
127
+
128
+ def test_native_ratios_keep_units_inputs_and_undefined_denominators(tmp_path, paired):
129
+ dataset = create(tmp_path, paired)
130
+ matches = list(dataset.matched_measurements(allowed_bases=["same_material"]))
131
+ for match in matches:
132
+ numerator, denominator = match.right[0], match.left[0]
133
+ value = match.ratio(numerator, denominator, measurement_id="ratio", method="native_ratio",
134
+ normalization={"protein": "source bridge", "rna": "source TPM"})
135
+ assert value.unit == "(iBAQ)/(TPM)"
136
+ assert value.quantity == "protein_rna_ratio"
137
+ assert value.input_measurements == (numerator.measurement_id, denominator.measurement_id)
138
+ assert value.derivation["uncertainty"] == "not_propagated"
139
+ if denominator.value == 0:
140
+ assert value.value is None and value.status == "not_measured"
141
+ assert value.derivation["unresolved_reason"] == "nonpositive_denominator"
142
+ else:
143
+ assert value.value == numerator.value / denominator.value
144
+ original = matches[0]
145
+ for status in ("below_detection", "missing", "not_measured", "not_mapped"):
146
+ denominator = replace(original.left[0], value=None, status=status)
147
+ match = replace(original, left=(denominator,))
148
+ value = match.ratio(match.right[0], denominator, measurement_id="ratio", method="test",
149
+ normalization={"method": "unchanged"})
150
+ assert value.value is None and value.status == "not_measured"
151
+ logged = replace(original.left[0], scale="log", log_base=2, value=1)
152
+ match = replace(original, left=(logged,))
153
+ with pytest.raises(ValueError, match="must be linear"):
154
+ match.ratio(match.right[0], logged, measurement_id="ratio", method="test",
155
+ normalization={"method": "unchanged"})
156
+
157
+
158
+ def test_parent_rna_and_ambiguous_groups_cannot_become_orf_ratios(paired):
159
+ pair, relation = paired[1]["pairings"][0], paired[1]["relations"][0]
160
+ left, right = paired[1]["measurements"][0], paired[1]["measurements"][4]
161
+ orf = replace(right.target, kind="orf")
162
+ relation = replace(relation, right=orf, relation="parent_rna_context", cardinality="one_to_many")
163
+ match = MatchedMeasurements(pair, relation, (left,), (replace(right, target=orf),))
164
+ assert match.left[0].target.kind == "gene"
165
+ with pytest.raises(ValueError, match="Parent RNA/group"):
166
+ match.ratio(match.right[0], left, measurement_id="ratio", method="test", normalization={"method": "none"})
167
+ group = replace(left.target, kind="protein_group")
168
+ group_relation = replace(relation, left=group, relation="shared_group_member")
169
+ match = MatchedMeasurements(pair, group_relation, (replace(left, target=group),), match.right)
170
+ with pytest.raises(ValueError, match="Parent RNA/group"):
171
+ match.ratio(match.right[0], match.left[0], measurement_id="ratio", method="test", normalization={"method": "none"})
172
+
173
+
174
+ def test_duplicate_isoform_attribution_is_not_one_to_one(tmp_path, paired):
175
+ relation = paired[1]["relations"][0]
176
+ other = replace(relation, relation_id="isoform", right=replace(relation.right, identifier="isoform2"))
177
+ with pytest.raises(ValueError, match="Conflicting one-to-one"):
178
+ create(tmp_path, paired, relations=[relation, other])
179
+ with pytest.raises(ValueError, match="Conflicting one-to-one"):
180
+ create(tmp_path, paired, relations=[relation, replace(other, cardinality="one_to_many")])
181
+ pair = paired[1]["pairings"][0]
182
+ left, right = paired[1]["measurements"][0], paired[1]["measurements"][4]
183
+ match = MatchedMeasurements(pair, replace(relation, cardinality="one_to_many"), (left,), (right,))
184
+ with pytest.raises(ValueError, match="one-to-one attribution"):
185
+ match.ratio(right, left, measurement_id="ratio", method="test", normalization={"method": "none"})
186
+
187
+
188
+ def test_gene_and_transcript_links_are_distinct_attribution_axes(tmp_path, paired):
189
+ gene_relation = paired[1]["relations"][0]
190
+ transcript_relation = replace(gene_relation, relation_id="transcript-encoding",
191
+ left=replace(gene_relation.left, kind="transcript", identifier="rna"))
192
+ dataset = create(tmp_path, paired, relations=[gene_relation, transcript_relation])
193
+ assert len(list(dataset.relations())) == 2
194
+
195
+
196
+ def test_same_target_across_samples_stays_a_separate_comparison(tmp_path, paired):
197
+ manifest, values = paired
198
+ original = values["measurements"][0]
199
+ sample = replace(values["samples"][0], sample_id="second", biological_unit_id="animal-2")
200
+ assay = Assay("rna-2", "second", "rna_seq", sample.reference)
201
+ other = replace(original, measurement_id="second-value", sample_id="second", assay_id="rna-2", value=2)
202
+ relation = TargetRelation("same", "fixture", "1", original.target, original.target,
203
+ "same_target", "one_to_one", {"identity": "exact scoped target"})
204
+ pair = replace(values["pairings"][0], right_assay_id="rna-2", basis="biological_replicate")
205
+ dataset = create(tmp_path, paired, samples=(*values["samples"], sample),
206
+ assays=(*values["assays"], assay), measurements=(*values["measurements"], other),
207
+ pairings=[pair], relations=[relation])
208
+ match = next(dataset.matched_measurements(allowed_bases=["biological_replicate"]))
209
+ assert match.left[0].target == match.right[0].target
210
+ assert match.left[0].sample_id != match.right[0].sample_id
211
+
212
+
213
+ def test_old_storage_has_no_inferred_relationships(tmp_path, paired):
214
+ dataset = create(tmp_path, paired)
215
+ with sqlite3.connect(dataset.path) as connection:
216
+ connection.execute("DROP TABLE assay_pair")
217
+ connection.execute("DROP TABLE target_relation")
218
+ connection.execute("PRAGMA user_version=1")
219
+ old = QuantitativeDataset(dataset.path)
220
+ assert list(old.pairings()) == list(old.relations()) == []
221
+ assert list(old.matched_measurements(allowed_bases=PAIRING_BASES)) == []
222
+ assert len(list(old.measurements())) == 8
223
+
224
+
225
+ def test_orthology_keeps_both_species_and_never_transfers_abundance(tmp_path, paired):
226
+ dataset = create(tmp_path, paired)
227
+ observations = paired[1]["measurements"]
228
+ mappings = [OrthologyMapping(str(index), observations[0].target, observations[index].target,
229
+ paired[0].sources[0], "test:1", "one_to_one",
230
+ {"definition": "synthetic fixture, not biological evidence"},
231
+ {"method": "test fixture"}) for index in (1, 2, 3)]
232
+ comparisons = list(compare_orthologs(dataset, dataset, mappings))
233
+ assert len(comparisons) == 3
234
+ for index, comparison in enumerate(comparisons, 1):
235
+ assert comparison.left == (observations[0],)
236
+ assert comparison.right == (observations[index],)
237
+ assert comparison.left[0].value != comparison.right[0].value
238
+ assert OrthologyMapping.from_dict(comparison.mapping.to_dict()) == comparison.mapping
239
+ assert OrthologyComparison.from_dict(comparison.to_dict()) == comparison
240
+ assert list(compare_orthologs(dataset, dataset, [replace(mappings[0], cardinality="one_to_many")])) == []
241
+ alternate = replace(mappings[0], mapping_id="other", right=replace(mappings[0].right, identifier="other"))
242
+ with pytest.raises(ValueError, match="Conflicting one-to-one"):
243
+ list(compare_orthologs(dataset, dataset, [mappings[0], alternate]))
244
+ with pytest.raises(ValueError, match="sequence, frame and position"):
245
+ replace(mappings[0], left=replace(mappings[0].left, kind="orf"), right=replace(mappings[0].right, kind="orf"))
246
+ orf_mapping = replace(mappings[0], left=replace(mappings[0].left, kind="orf"),
247
+ right=replace(mappings[0].right, kind="orf"),
248
+ evidence={"sequence": "pinned alignment", "frame": "same frame", "position": "synteny"})
249
+ assert list(compare_orthologs(dataset, dataset, [orf_mapping])) == []
250
+ comparison = next(compare_orthologs(dataset, dataset, [orf_mapping], allow_orfs=True))
251
+ assert comparison.left == comparison.right == () # Gene values are not ORF values.
252
+
253
+
254
+ def test_paired_table_cli_preserves_rna_protein_and_missing_ribo(tmp_path, paired, capsys):
255
+ from test_measurements import definition
256
+
257
+ dataset = create(tmp_path, paired)
258
+ metadata = definition(dataset)
259
+ cache_root = tmp_path / "cache"
260
+ DatasetRegistry(metadata, cache_root=cache_root).download("fixture", show_progress=False)
261
+ registry_path = tmp_path / "registry.json"
262
+ registry_path.write_text(json.dumps(metadata))
263
+ run(["datasets", "--registry", str(registry_path), "--cache-root", str(cache_root),
264
+ "pairs", "fixture", "--basis", "same_material"])
265
+ rows = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
266
+ assert len(rows) == 4
267
+ assert all(row["ribosome_occupancy"] == [] for row in rows)
268
+ assert all(row["rna_abundance"][0]["unit"] == "TPM" for row in rows)
269
+ assert all(row["protein_abundance"][0]["unit"] == "iBAQ" for row in rows)
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes