orfdata 0.2.0__tar.gz → 0.3.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 (52) hide show
  1. {orfdata-0.2.0/orfdata.egg-info → orfdata-0.3.0}/PKG-INFO +9 -2
  2. {orfdata-0.2.0 → orfdata-0.3.0}/README.md +3 -1
  3. orfdata-0.3.0/docs/measurements.md +134 -0
  4. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/__init__.py +12 -0
  5. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/cli.py +8 -1
  6. orfdata-0.3.0/orfdata/data/datasets.json +1 -0
  7. orfdata-0.3.0/orfdata/dataset.py +281 -0
  8. orfdata-0.3.0/orfdata/dataset_cli.py +80 -0
  9. orfdata-0.3.0/orfdata/datasets.py +172 -0
  10. orfdata-0.3.0/orfdata/measurements.py +364 -0
  11. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/reference.py +25 -0
  12. orfdata-0.3.0/orfdata/version.py +1 -0
  13. {orfdata-0.2.0 → orfdata-0.3.0/orfdata.egg-info}/PKG-INFO +9 -2
  14. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata.egg-info/SOURCES.txt +7 -0
  15. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata.egg-info/requires.txt +7 -0
  16. {orfdata-0.2.0 → orfdata-0.3.0}/pyproject.toml +4 -2
  17. orfdata-0.3.0/tests/test_measurements.py +356 -0
  18. orfdata-0.2.0/orfdata/version.py +0 -1
  19. {orfdata-0.2.0 → orfdata-0.3.0}/LICENSE +0 -0
  20. {orfdata-0.2.0 → orfdata-0.3.0}/MANIFEST.in +0 -0
  21. {orfdata-0.2.0 → orfdata-0.3.0}/deploy.sh +0 -0
  22. {orfdata-0.2.0 → orfdata-0.3.0}/docs/design.md +0 -0
  23. {orfdata-0.2.0 → orfdata-0.3.0}/docs/reference.md +0 -0
  24. {orfdata-0.2.0 → orfdata-0.3.0}/docs/species.md +0 -0
  25. {orfdata-0.2.0 → orfdata-0.3.0}/lint.sh +0 -0
  26. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/build.py +0 -0
  27. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/catalog.py +0 -0
  28. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/orf-evidence/AUTHOR-LICENSE.txt +0 -0
  29. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/orf-evidence/NOTICE.txt +0 -0
  30. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/orf-evidence/catalog.json.gz +0 -0
  31. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/orf-evidence/manifest.json +0 -0
  32. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/uorf-evidence/AUTHOR-LICENSE.txt +0 -0
  33. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/uorf-evidence/NOTICE.txt +0 -0
  34. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/uorf-evidence/catalog.json.gz +0 -0
  35. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/data/uorf-evidence/manifest.json +0 -0
  36. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/initiation_context.py +0 -0
  37. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/model.py +0 -0
  38. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata/sequence.py +0 -0
  39. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata.egg-info/dependency_links.txt +0 -0
  40. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata.egg-info/entry_points.txt +0 -0
  41. {orfdata-0.2.0 → orfdata-0.3.0}/orfdata.egg-info/top_level.txt +0 -0
  42. {orfdata-0.2.0 → orfdata-0.3.0}/setup.cfg +0 -0
  43. {orfdata-0.2.0 → orfdata-0.3.0}/test.sh +0 -0
  44. {orfdata-0.2.0 → orfdata-0.3.0}/tests/data/references/README.md +0 -0
  45. {orfdata-0.2.0 → orfdata-0.3.0}/tests/data/references/canis_lupus_familiaris.json +0 -0
  46. {orfdata-0.2.0 → orfdata-0.3.0}/tests/data/references/homo_sapiens.json +0 -0
  47. {orfdata-0.2.0 → orfdata-0.3.0}/tests/data/references/mus_musculus.json +0 -0
  48. {orfdata-0.2.0 → orfdata-0.3.0}/tests/data/references/saccharomyces_cerevisiae.json +0 -0
  49. {orfdata-0.2.0 → orfdata-0.3.0}/tests/test_initiation_context.py +0 -0
  50. {orfdata-0.2.0 → orfdata-0.3.0}/tests/test_reference_context.py +0 -0
  51. {orfdata-0.2.0 → orfdata-0.3.0}/tests/test_reference_orf.py +0 -0
  52. {orfdata-0.2.0 → orfdata-0.3.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.2.0
3
+ Version: 0.3.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
@@ -11,6 +11,10 @@ Provides-Extra: pyensembl
11
11
  Requires-Dist: pyensembl>=2.27.2; extra == "pyensembl"
12
12
  Provides-Extra: build-data
13
13
  Requires-Dist: openpyxl>=3.1; extra == "build-data"
14
+ Provides-Extra: data
15
+ Requires-Dist: datacache<2,>=1.25.1; extra == "data"
16
+ Provides-Extra: columnar
17
+ Requires-Dist: pyarrow>=14; extra == "columnar"
14
18
  Provides-Extra: dev
15
19
  Requires-Dist: pytest; extra == "dev"
16
20
  Requires-Dist: ruff; extra == "dev"
@@ -18,6 +22,7 @@ Requires-Dist: build; extra == "dev"
18
22
  Requires-Dist: twine; extra == "dev"
19
23
  Requires-Dist: openpyxl>=3.1; extra == "dev"
20
24
  Requires-Dist: pyensembl>=2.27.2; extra == "dev"
25
+ Requires-Dist: datacache<2,>=1.25.1; extra == "dev"
21
26
  Dynamic: license-file
22
27
 
23
28
  # orfdata
@@ -95,5 +100,7 @@ bind real human, mouse, dog and yeast reference slices; those fixtures are not
95
100
  new experimental ORF catalogues. [Species and reference identity](docs/species.md).
96
101
 
97
102
  Install with `pip install orfdata[pyensembl]`. See the
103
+ [measurement API](docs/measurements.md) for native units, explicit samples and
104
+ assays, and DataCache-managed downloads, and the
98
105
  [quantitative, multispecies data roadmap](https://github.com/openvax/orfdata/issues/3)
99
- for planned RNA abundance, ribosome occupancy and protein measurements.
106
+ for upcoming RNA abundance, ribosome occupancy and protein data releases.
@@ -73,5 +73,7 @@ bind real human, mouse, dog and yeast reference slices; those fixtures are not
73
73
  new experimental ORF catalogues. [Species and reference identity](docs/species.md).
74
74
 
75
75
  Install with `pip install orfdata[pyensembl]`. See the
76
+ [measurement API](docs/measurements.md) for native units, explicit samples and
77
+ assays, and DataCache-managed downloads, and the
76
78
  [quantitative, multispecies data roadmap](https://github.com/openvax/orfdata/issues/3)
77
- for planned RNA abundance, ribosome occupancy and protein measurements.
79
+ for upcoming RNA abundance, ribosome occupancy and protein data releases.
@@ -0,0 +1,134 @@
1
+ # RNA, ribosome and protein measurements
2
+
3
+ ORFdata adds evidence to PyEnsembl's reference transcripts and proteins. Its
4
+ quantitative layer stores measurements of genes, transcripts, reference ORFs,
5
+ proteins, protein groups and peptides. A measurement stays at the resolution
6
+ the experiment supports. Parent-gene RNA abundance is useful context for an ORF;
7
+ it is not a separate measurement of each ORF in that gene.
8
+
9
+ Install `orfdata[data]` to manage datasets. The initial 0.3 registry is empty;
10
+ the [data roadmap](https://github.com/openvax/orfdata/issues/3) tracks source
11
+ imports. The bundled human ORF evidence catalogue remains available offline.
12
+ Its pooled PSM counts describe spectra, not sample-level protein abundance.
13
+
14
+ ## Discover, download and query
15
+
16
+ ```python
17
+ from orfdata import DatasetRegistry, ScopedTarget
18
+
19
+ registry = DatasetRegistry() # Metadata only; no download.
20
+ releases = registry.list(species="dog", assay="digest_ms")
21
+ ```
22
+
23
+ For a registered release, use `registry.info(name, version)` for its immutable
24
+ manifest, `registry.download(name, version)` to fetch it, and
25
+ `registry.open(name, version)` to read it offline. `inspect` reports missing,
26
+ available or invalid files without downloading or repairing them.
27
+ `download_metadata` returns the artifact's access terms and file hashes/sizes.
28
+ An invalid cache
29
+ requires an explicit `download(..., force=True)`; old reader paths survive.
30
+ Python download options such as timeout, retries and progress go to DataCache.
31
+
32
+ ```python
33
+ # dataset = registry.open(name, version)
34
+ # transcript is a PyEnsembl Transcript in the dataset's exact reference context.
35
+ target = ScopedTarget.from_pyensembl(
36
+ transcript, kind="transcript", namespace="ensembl:110"
37
+ )
38
+ for measurement in dataset.measurements(target=target):
39
+ print(measurement.value, measurement.unit, measurement.status)
40
+ ```
41
+
42
+ Use `ScopedTarget.from_orf(bound_reference_orf, namespace=source_release)` for a
43
+ validated reference ORF. Namespaces and identifiers must match the source's
44
+ mapping table. The default PyEnsembl target uses its unversioned `id`; request
45
+ `versioned=True` when the source uses versioned identifiers. There is no implicit
46
+ mapping across annotation releases, gene isoforms, ORFs or species. A variant or
47
+ fusion product does not inherit a native reference protein measurement.
48
+
49
+ The CLI uses the same operations:
50
+
51
+ ```sh
52
+ orfdata datasets list --species yeast
53
+ orfdata datasets info DATASET --version RELEASE
54
+ orfdata datasets download DATASET --version RELEASE
55
+ orfdata datasets inspect DATASET --version RELEASE
56
+ orfdata datasets query DATASET --version RELEASE --quantity protein_abundance
57
+ ```
58
+
59
+ Queries stream JSON lines. `--reference ref:SHA256` selects an exact context;
60
+ target queries also require `--target`, `--kind` and `--namespace`. Unqualified
61
+ sample/assay ID queries across multiple references are rejected. Existing
62
+ catalogue commands such as `orfdata --gene TPST1` retain their behavior.
63
+
64
+ ## What each value means
65
+
66
+ `Measurement` retains the dataset/version, reference-scoped target, sample and
67
+ assay IDs, value, native unit, linear/log scale and log base, uncertainty and its
68
+ definition, detection limit, status, origin and provenance. Measured zero,
69
+ below detection, not measured, not mapped and missing are distinct. Censored or
70
+ missing values use `None`; native sentinels can remain in provenance.
71
+
72
+ RNA counts/TPM/FPKM, ribosome occupancy, source-defined translation efficiency,
73
+ LFQ/iBAQ/TMT values, peptide intensity, PSM counts, copies per cell and turnover
74
+ remain distinct. No conversion is automatic. Derived/predicted values name a
75
+ method and retain source/input provenance. An estimate from calibration remains
76
+ an estimate; an intensity/TPM ratio does not acquire molecule-ratio units.
77
+
78
+ `Sample` records biological units, pools and source labels. `Assay` separately
79
+ records technical replicates, runs, fractions, TMT plex/channel/bridge,
80
+ normalization and calibration. A sample's strain/breed belongs in its attributes;
81
+ it need not be the strain used to assemble its reference genome. `TargetMapping`
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).
86
+
87
+ ## Importing a source
88
+
89
+ Define a `DatasetManifest` with exact reference contexts, raw source URLs,
90
+ SHA-256 hashes and byte sizes, citations, file-specific access/redistribution
91
+ terms, searched/measurable coverage, processing software/version/parameters and
92
+ row accounting. Unavailable metadata stay unknown. Article licensing does not
93
+ substitute for the data files' terms.
94
+
95
+ An importer calls `QuantitativeDataset.create(path, manifest, samples=...,
96
+ assays=..., measurements=..., mappings=...)`. These inputs may be generators;
97
+ measurements are written incrementally. Every observation names a source asset
98
+ or earlier input measurements. Derived inputs precede outputs in the same
99
+ reference context. Duplicate IDs, broken foreign keys, wrong dataset releases
100
+ and cross-reference links fail the import. Failed builds do not replace data.
101
+
102
+ Use `build_dataset(manifest, builder, ...)` for a managed local import.
103
+ `builder(source_paths, output_path)` reads the verified inputs and creates that
104
+ SQLite dataset. DataCache publishes it atomically with input/output checksums
105
+ and a processing receipt. `source_paths={asset_id: local_file}` supports manually
106
+ acquired sources. Importing locally does not grant redistribution rights.
107
+
108
+ A `DatasetRegistry(definitions, cache_root=...)` registers releases as:
109
+
110
+ ```python
111
+ definitions = {
112
+ name: {
113
+ "default_version": version,
114
+ "versions": {version: {
115
+ "manifest": manifest.to_dict(),
116
+ "distribution": {"access_terms": terms, "redistributable": False},
117
+ "assets": {"dataset.sqlite": {
118
+ "url": url, "sha256": sha256, "size": size, "decompress": False,
119
+ }},
120
+ }},
121
+ }
122
+ }
123
+ ```
124
+
125
+ CLI users can supply this JSON with `orfdata datasets --registry FILE ...`.
126
+ `ORFDATA_CACHE`, then `OPENVAX_DATA_CACHE`, then the platform's OpenVax cache
127
+ select the root. ORFdata owns its `orfdata/` child. Release cache identities
128
+ include manifest/reference and content hashes. SQLite partitions every table
129
+ by reference key, with composite foreign keys; human, mouse, dog, yeast and
130
+ arbitrary other taxa use the same schema. The core API needs neither pandas nor
131
+ PyArrow. Optional `orfdata[columnar]` enables `dataset.export_parquet(path)`;
132
+ query columns accompany full measurement JSON, preserving exact values and
133
+ provenance even when a floating-point analysis column cannot represent an
134
+ integer exactly.
@@ -12,7 +12,10 @@ from .catalog import (
12
12
  load_catalog,
13
13
  load_uorf_catalog,
14
14
  )
15
+ from .dataset import QuantitativeDataset
16
+ from .datasets import DatasetRegistry, build_dataset
15
17
  from .initiation_context import KOZAK_PREFERENCES, InitiationContext, annotate_initiation_context
18
+ from .measurements import Assay, DatasetManifest, Measurement, Sample, SourceAsset, TargetMapping
16
19
  from .model import ORFModel, ReferenceORF
17
20
  from .reference import ReferenceContext, ScopedTarget
18
21
  from .version import __version__
@@ -28,6 +31,15 @@ __all__ = [
28
31
  "ReferenceORF",
29
32
  "ReferenceContext",
30
33
  "ScopedTarget",
34
+ "QuantitativeDataset",
35
+ "DatasetRegistry",
36
+ "build_dataset",
37
+ "DatasetManifest",
38
+ "SourceAsset",
39
+ "Sample",
40
+ "Assay",
41
+ "Measurement",
42
+ "TargetMapping",
31
43
  "load_catalog",
32
44
  "UORFCatalog",
33
45
  "UORFRecord",
@@ -11,7 +11,14 @@ from .catalog import EVIDENCE_METHODS, UORFCatalog, load_catalog
11
11
 
12
12
  def run(argv=None, prog=None, *, catalog_loader=load_catalog):
13
13
  """Export reference records as JSON, TSV or FASTA."""
14
- parser = argparse.ArgumentParser(description=__doc__, prog=prog)
14
+ argv = list(sys.argv[1:] if argv is None else argv)
15
+ if argv and argv[0] == "datasets":
16
+ from .dataset_cli import run as dataset_run
17
+
18
+ return dataset_run(argv[1:], prog=(prog or "orfdata") + " datasets")
19
+ parser = argparse.ArgumentParser(
20
+ description=__doc__, prog=prog,
21
+ epilog="For quantitative data management, use 'orfdata datasets --help'.")
15
22
  parser.add_argument("--catalog", help="Use a compatible local JSON/JSON.gz catalogue")
16
23
  parser.add_argument("--gene", help="Exact gene symbol or Ensembl gene ID")
17
24
  parser.add_argument("--transcript", help="Exact source transcript ID (unversioned)")
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,281 @@
1
+ """Streaming, reference-partitioned quantitative datasets stored as SQLite."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ from contextlib import closing
8
+ from pathlib import Path
9
+
10
+ from .measurements import (
11
+ MEASUREMENT_STATUSES,
12
+ QUANTITIES,
13
+ Assay,
14
+ DatasetManifest,
15
+ Measurement,
16
+ Sample,
17
+ TargetMapping,
18
+ )
19
+ from .reference import ReferenceContext, ScopedTarget
20
+
21
+ _SCHEMA = """
22
+ CREATE TABLE metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL);
23
+ CREATE TABLE reference (key TEXT PRIMARY KEY, record TEXT NOT NULL);
24
+ CREATE TABLE target (
25
+ reference_key TEXT NOT NULL REFERENCES reference(key),
26
+ key TEXT NOT NULL, kind TEXT NOT NULL, namespace TEXT NOT NULL,
27
+ identifier TEXT NOT NULL, PRIMARY KEY(reference_key, key));
28
+ CREATE TABLE sample (
29
+ reference_key TEXT NOT NULL REFERENCES reference(key),
30
+ id TEXT NOT NULL, record TEXT NOT NULL, PRIMARY KEY(reference_key, id));
31
+ CREATE TABLE assay (
32
+ reference_key TEXT NOT NULL, id TEXT NOT NULL, sample_id TEXT NOT NULL,
33
+ record TEXT NOT NULL, PRIMARY KEY(reference_key, id),
34
+ UNIQUE(reference_key, id, sample_id),
35
+ FOREIGN KEY(reference_key, sample_id) REFERENCES sample(reference_key, id));
36
+ CREATE TABLE mapping (
37
+ reference_key TEXT NOT NULL REFERENCES reference(key),
38
+ id TEXT NOT NULL, record TEXT NOT NULL, PRIMARY KEY(reference_key, id));
39
+ CREATE TABLE measurement (
40
+ reference_key TEXT NOT NULL, id TEXT NOT NULL, target_key TEXT NOT NULL,
41
+ sample_id TEXT NOT NULL, assay_id TEXT NOT NULL, quantity TEXT NOT NULL,
42
+ status TEXT NOT NULL, record TEXT NOT NULL, PRIMARY KEY(reference_key, id),
43
+ FOREIGN KEY(reference_key, target_key) REFERENCES target(reference_key, key),
44
+ FOREIGN KEY(reference_key, assay_id, sample_id)
45
+ REFERENCES assay(reference_key, id, sample_id));
46
+ CREATE TABLE input_measurement (
47
+ reference_key TEXT NOT NULL, output_id TEXT NOT NULL, input_id TEXT NOT NULL,
48
+ PRIMARY KEY(reference_key, output_id, input_id),
49
+ FOREIGN KEY(reference_key, output_id) REFERENCES measurement(reference_key, id),
50
+ FOREIGN KEY(reference_key, input_id) REFERENCES measurement(reference_key, id));
51
+ CREATE INDEX target_lookup ON target(reference_key, namespace, kind, identifier);
52
+ CREATE INDEX observation_lookup ON measurement(reference_key, target_key, sample_id, assay_id);
53
+ CREATE INDEX assay_lookup ON measurement(reference_key, assay_id, quantity, status);
54
+ """
55
+
56
+
57
+ def _json(value):
58
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
59
+
60
+
61
+ def _target_key(target):
62
+ return hashlib.sha256(_json(target.key).encode()).hexdigest()
63
+
64
+
65
+ class QuantitativeDataset:
66
+ """A read-only release. Constructing or querying it never downloads data.
67
+
68
+ Local IDs are unique only within one dataset release AND reference context.
69
+ Iterators preserve that context even when IDs collide between species.
70
+ Registry-managed files are checked against pinned hashes before opening.
71
+ """
72
+
73
+ def __init__(self, path):
74
+ self.path = Path(path).resolve()
75
+ with closing(self._connect()) as connection:
76
+ if connection.execute("PRAGMA user_version").fetchone()[0] != 1:
77
+ raise ValueError("Unsupported quantitative database schema")
78
+ row = connection.execute("SELECT value FROM metadata WHERE name='manifest'").fetchone()
79
+ if row is None:
80
+ raise ValueError("Missing quantitative manifest")
81
+ self.manifest = DatasetManifest.from_dict(json.loads(row[0]))
82
+ self._references = {r.key: r for r in self.manifest.references}
83
+
84
+ def _connect(self):
85
+ # mode=ro prevents SQLite's default creation of an empty missing database.
86
+ connection = sqlite3.connect(self.path.as_uri() + "?mode=ro", uri=True)
87
+ connection.execute("PRAGMA query_only=ON")
88
+ return connection
89
+
90
+ @classmethod
91
+ def create(cls, path, manifest, *, samples, assays, measurements, mappings=()):
92
+ """Write a new dataset from iterators, validating scope and foreign keys.
93
+
94
+ Never overwrite an existing file. An interrupted/invalid build removes
95
+ only the new file it created. For atomic managed publication, call this
96
+ inside DataCache's materialize builder. Derived observations must follow
97
+ their input observations, preventing cyclic or dangling provenance.
98
+ """
99
+ if not isinstance(manifest, DatasetManifest):
100
+ raise TypeError("manifest must be a DatasetManifest")
101
+ path = Path(path)
102
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
103
+ os.close(descriptor)
104
+ connection = None
105
+ try:
106
+ connection = sqlite3.connect(path)
107
+ connection.execute("PRAGMA foreign_keys=ON")
108
+ connection.executescript(_SCHEMA)
109
+ connection.execute("PRAGMA user_version=1")
110
+ connection.execute("INSERT INTO metadata VALUES ('manifest', ?)",
111
+ (_json(manifest.to_dict()),))
112
+ for reference in manifest.references:
113
+ connection.execute("INSERT INTO reference VALUES (?, ?)",
114
+ (reference.key, _json(reference.to_dict())))
115
+ for sample in samples:
116
+ if not isinstance(sample, Sample):
117
+ raise TypeError("samples must contain Samples")
118
+ connection.execute("INSERT INTO sample VALUES (?, ?, ?)",
119
+ (sample.reference.key, sample.sample_id, _json(sample.to_dict())))
120
+ for assay in assays:
121
+ if not isinstance(assay, Assay):
122
+ raise TypeError("assays must contain Assays")
123
+ if assay.kind not in manifest.assay_kinds:
124
+ raise ValueError("Assay kind absent from manifest coverage")
125
+ connection.execute("INSERT INTO assay VALUES (?, ?, ?, ?)",
126
+ (assay.reference.key, assay.assay_id, assay.sample_id,
127
+ _json(assay.to_dict())))
128
+ for mapping in mappings:
129
+ if not isinstance(mapping, TargetMapping):
130
+ raise TypeError("mappings must contain TargetMappings")
131
+ connection.execute("INSERT INTO mapping VALUES (?, ?, ?)",
132
+ (mapping.reference.key, mapping.mapping_id,
133
+ _json(mapping.to_dict())))
134
+ source_ids = {s.asset_id for s in manifest.sources}
135
+ for observation in measurements:
136
+ if not isinstance(observation, Measurement):
137
+ raise TypeError("measurements must contain Measurements")
138
+ if (observation.dataset_id, observation.dataset_version) != (manifest.dataset_id, manifest.version):
139
+ raise ValueError("Measurement belongs to a different dataset release")
140
+ source = observation.provenance.get("source_asset")
141
+ if source is not None and source not in source_ids:
142
+ raise ValueError("Measurement provenance names an unknown source asset")
143
+ if source is None and not observation.input_measurements:
144
+ raise ValueError("Measurement requires a source_asset or input measurements")
145
+ reference_key = observation.target.reference.key
146
+ for identifier in observation.input_measurements:
147
+ if connection.execute(
148
+ "SELECT 1 FROM measurement WHERE reference_key=? AND id=?",
149
+ (reference_key, identifier),
150
+ ).fetchone() is None:
151
+ raise ValueError("Derived inputs must precede outputs in the same reference context")
152
+ target = observation.target
153
+ target_key = _target_key(target)
154
+ connection.execute("INSERT OR IGNORE INTO target VALUES (?, ?, ?, ?, ?)",
155
+ (reference_key, target_key, target.kind,
156
+ target.namespace, target.identifier))
157
+ record = observation.to_dict()
158
+ record.pop("target")
159
+ connection.execute("INSERT INTO measurement VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
160
+ (reference_key, observation.measurement_id, target_key,
161
+ observation.sample_id, observation.assay_id,
162
+ observation.quantity, observation.status, _json(record)))
163
+ connection.executemany("INSERT INTO input_measurement VALUES (?, ?, ?)",
164
+ ((reference_key, observation.measurement_id, identifier)
165
+ for identifier in observation.input_measurements))
166
+ counts = {table: connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
167
+ for table in ("sample", "assay", "mapping", "target", "measurement")}
168
+ connection.execute("INSERT INTO metadata VALUES ('counts', ?)", (_json(counts),))
169
+ connection.commit()
170
+ connection.close()
171
+ connection = None
172
+ return cls(path)
173
+ except BaseException:
174
+ if connection is not None:
175
+ connection.close()
176
+ path.unlink()
177
+ raise
178
+
179
+ def _scope(self, reference):
180
+ if reference is None:
181
+ return None
182
+ key = reference.key if isinstance(reference, ReferenceContext) else reference
183
+ if key not in self._references:
184
+ raise ValueError("Reference context is not present in this dataset")
185
+ return key
186
+
187
+ def _records(self, table, cls, *, reference=None):
188
+ scope = self._scope(reference)
189
+ query = f"SELECT record FROM {table}"
190
+ params = ()
191
+ if scope is not None:
192
+ query += " WHERE reference_key=?"
193
+ params = (scope,)
194
+ query += " ORDER BY reference_key, id"
195
+ with closing(self._connect()) as connection:
196
+ for row in connection.execute(query, params):
197
+ yield cls.from_dict(json.loads(row[0]))
198
+
199
+ def samples(self, *, reference=None):
200
+ return self._records("sample", Sample, reference=reference)
201
+
202
+ def assays(self, *, reference=None):
203
+ return self._records("assay", Assay, reference=reference)
204
+
205
+ def mappings(self, *, reference=None):
206
+ return self._records("mapping", TargetMapping, reference=reference)
207
+
208
+ def measurements(self, *, target=None, reference=None, sample_id=None,
209
+ assay_id=None, quantity=None, status=None):
210
+ """Iterate native observations; a target match is exact, never orthology.
211
+
212
+ Unqualified IDs across multiple references are rejected. To browse all
213
+ species together, omit ID filters; each result still carries its scope.
214
+ """
215
+ if quantity is not None and quantity not in QUANTITIES:
216
+ raise ValueError("Unknown measurement quantity")
217
+ if status is not None and status not in MEASUREMENT_STATUSES:
218
+ raise ValueError("Unknown measurement status")
219
+ scope = self._scope(reference)
220
+ if target is not None:
221
+ if not isinstance(target, ScopedTarget):
222
+ raise TypeError("target must be a ScopedTarget")
223
+ target_scope = self._scope(target.reference)
224
+ if scope is not None and scope != target_scope:
225
+ raise ValueError("Target and requested reference disagree")
226
+ scope = target_scope
227
+ if scope is None and len(self._references) > 1 and (sample_id or assay_id):
228
+ raise ValueError("An ID query across multiple references requires an explicit reference")
229
+ filters = dict(reference_key=scope, target_key=_target_key(target) if target else None,
230
+ sample_id=sample_id, assay_id=assay_id, quantity=quantity, status=status)
231
+ selected = [(key, value) for key, value in filters.items() if value is not None]
232
+ query = ("SELECT m.record, m.reference_key, t.kind, t.namespace, t.identifier "
233
+ "FROM measurement m JOIN target t ON m.reference_key=t.reference_key "
234
+ "AND m.target_key=t.key")
235
+ if selected:
236
+ query += " WHERE " + " AND ".join(f"m.{key}=?" for key, _ in selected)
237
+ query += " ORDER BY m.reference_key, m.id"
238
+ with closing(self._connect()) as connection:
239
+ for row in connection.execute(query, [value for _, value in selected]):
240
+ values = json.loads(row[0])
241
+ values["target"] = ScopedTarget(self._references[row[1]], row[2], row[4], row[3])
242
+ yield Measurement(**values)
243
+
244
+ @property
245
+ def counts(self):
246
+ with closing(self._connect()) as connection:
247
+ result = json.loads(connection.execute(
248
+ "SELECT value FROM metadata WHERE name='counts'").fetchone()[0])
249
+ return result
250
+
251
+ def export_parquet(self, path, **query):
252
+ """Export query results in bounded batches; requires orfdata[columnar]."""
253
+ try:
254
+ import pyarrow as pa
255
+ import pyarrow.parquet as pq
256
+ except ImportError as exc:
257
+ raise ImportError("Install orfdata[columnar] to export Parquet") from exc
258
+ schema = pa.schema([(name, pa.string()) for name in
259
+ ("dataset_id", "version", "reference_key", "measurement_id",
260
+ "sample_id", "assay_id", "target_kind", "target_namespace",
261
+ "target_identifier", "quantity", "unit", "status", "origin",
262
+ "scale", "measurement_json")] + [("value_float64", pa.float64())])
263
+ with pq.ParquetWriter(path, schema) as writer:
264
+ batch = []
265
+ for value in self.measurements(**query):
266
+ batch.append(dict(dataset_id=self.manifest.dataset_id, version=self.manifest.version,
267
+ reference_key=value.target.reference.key,
268
+ measurement_id=value.measurement_id,
269
+ sample_id=value.sample_id, assay_id=value.assay_id,
270
+ target_kind=value.target.kind,
271
+ target_namespace=value.target.namespace,
272
+ target_identifier=value.target.identifier, quantity=value.quantity,
273
+ unit=value.unit, status=value.status, origin=value.origin,
274
+ scale=value.scale,
275
+ value_float64=float(value.value) if value.value is not None else None,
276
+ measurement_json=_json(value.to_dict())))
277
+ if len(batch) == 4096:
278
+ writer.write_table(pa.Table.from_pylist(batch, schema=schema))
279
+ batch = []
280
+ if batch:
281
+ writer.write_table(pa.Table.from_pylist(batch, schema=schema))
@@ -0,0 +1,80 @@
1
+ """Discover, download, inspect and query quantitative datasets."""
2
+
3
+ import argparse
4
+ import json
5
+ import sqlite3
6
+ import sys
7
+
8
+ from .datasets import DatasetRegistry
9
+ from .reference import ScopedTarget
10
+
11
+
12
+ def run(argv=None, prog=None):
13
+ parser = argparse.ArgumentParser(description=__doc__, prog=prog)
14
+ parser.add_argument("--registry", help="Local registry JSON (otherwise use packaged releases)")
15
+ parser.add_argument("--cache-root", help="Override the OpenVax data cache root")
16
+ commands = parser.add_subparsers(dest="command", required=True)
17
+ listing = commands.add_parser("list", help="List published releases and assay coverage")
18
+ listing.add_argument("--species")
19
+ listing.add_argument("--taxon-id", type=int)
20
+ listing.add_argument("--assay")
21
+ for command in ("info", "download", "inspect", "query"):
22
+ subparser = commands.add_parser(command)
23
+ subparser.add_argument("name")
24
+ subparser.add_argument("--version")
25
+ if command == "download":
26
+ subparser.add_argument("--force", action="store_true")
27
+ subparser.add_argument("--timeout", type=float)
28
+ subparser.add_argument("--quiet", action="store_true")
29
+ subparser.add_argument("--source-path", help="Use a local dataset.sqlite with the registered hash")
30
+ if command == "query":
31
+ subparser.add_argument("--reference", help="Exact ref:SHA256 context key")
32
+ subparser.add_argument("--sample")
33
+ subparser.add_argument("--assay-id")
34
+ subparser.add_argument("--quantity")
35
+ subparser.add_argument("--status")
36
+ subparser.add_argument("--target")
37
+ subparser.add_argument("--kind")
38
+ subparser.add_argument("--namespace")
39
+ args = parser.parse_args(argv)
40
+ try:
41
+ definitions = None
42
+ if args.registry:
43
+ with open(args.registry) as handle:
44
+ definitions = json.load(handle)
45
+ registry = DatasetRegistry(definitions, cache_root=args.cache_root)
46
+ if args.command == "list":
47
+ result = [m.to_dict() for m in registry.list(
48
+ species=args.species, taxon_id=args.taxon_id, assay=args.assay)]
49
+ elif args.command == "info":
50
+ result = registry.info(args.name, args.version).to_dict()
51
+ result["download"] = registry.download_metadata(args.name, args.version)
52
+ elif args.command == "inspect":
53
+ inspected = registry.inspect(args.name, args.version)
54
+ result = dict(status=inspected.status, path=str(inspected.path),
55
+ error=str(inspected.error) if inspected.error else None)
56
+ elif args.command == "download":
57
+ options = dict(show_progress=not args.quiet)
58
+ if args.timeout is not None:
59
+ options["timeout"] = args.timeout
60
+ sources = {"dataset.sqlite": args.source_path} if args.source_path else None
61
+ result = registry.download(args.name, args.version, force=args.force,
62
+ source_paths=sources, **options)
63
+ else:
64
+ dataset = registry.open(args.name, args.version)
65
+ target = None
66
+ if any((args.target, args.kind, args.namespace)):
67
+ if not all((args.target, args.kind, args.namespace, args.reference)):
68
+ parser.error("Target queries require --target, --kind, --namespace and --reference")
69
+ references = {r.key: r for r in dataset.manifest.references}
70
+ target = ScopedTarget(references[args.reference], args.kind, args.target, args.namespace)
71
+ for measurement in dataset.measurements(
72
+ target=target, reference=args.reference, sample_id=args.sample,
73
+ assay_id=args.assay_id, quantity=args.quantity, status=args.status,
74
+ ):
75
+ sys.stdout.write(json.dumps(measurement.to_dict()) + "\n")
76
+ return
77
+ json.dump(result, sys.stdout, indent=2)
78
+ sys.stdout.write("\n")
79
+ except (ValueError, TypeError, KeyError, OSError, ImportError, sqlite3.Error) as exc:
80
+ parser.error(str(exc))