seqforge 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Package STARsolo's count matrices as ``.h5ad`` — the pilot's deliverable format.
|
|
2
|
+
|
|
3
|
+
This is the last step of ``map/starsolo``, and it lives here rather than in a module of its own on
|
|
4
|
+
purpose. Its input contract **is** STARsolo's ``Solo.out/<Feature>/raw/`` directory layout: a
|
|
5
|
+
separate "h5ad module" would have a config contract of *"wherever starsolo happened to put things"*,
|
|
6
|
+
which is coupling with no interface — the same generalisation-from-a-sample-size-of-one this repo
|
|
7
|
+
declined for the aligner plugin API. A second aligner packages its own output; that is what a module
|
|
8
|
+
is for.
|
|
9
|
+
|
|
10
|
+
Writing an ``.h5ad`` is not an aligner, so the aligner-environment rule has no opinion on it and it needs no liulab-runtime
|
|
11
|
+
environment: ``anndata`` is a plain dependency, like ``pypdf``. Only the STAR step needs a container.
|
|
12
|
+
|
|
13
|
+
**Why the rule shells out to a CLI verb instead of using a Snakemake ``run:`` block.** ``snakemake -n
|
|
14
|
+
-p`` renders every ``shell:`` block while planning and *cannot see inside* a ``run:`` block — so a
|
|
15
|
+
``shell:`` is visible to compose's wiring gate and a ``run:`` is not. Since the gate exists precisely
|
|
16
|
+
to catch a param that does not survive compose, the packaging step has to be reachable by it.
|
|
17
|
+
|
|
18
|
+
Two files come out of a default run, and the split is the user's call (2026-07-15):
|
|
19
|
+
|
|
20
|
+
- ``<sample>.h5ad`` — ``X`` is the primary feature, one ``layer`` per other gene-axis feature. Four
|
|
21
|
+
counts of the same genes in the same cells belong in one object; they differ only in the counting
|
|
22
|
+
rule, which is exactly what a layer is.
|
|
23
|
+
- ``<sample>.velocyto.h5ad`` — spliced / unspliced / ambiguous. Separate because Velocyto is not a
|
|
24
|
+
fourth way to count the same thing: it is three matrices that only mean anything together, and
|
|
25
|
+
scVelo wants its own object.
|
|
26
|
+
|
|
27
|
+
Everything is read from ``raw/``, never ``filtered/``. Cell calling is a downstream decision made
|
|
28
|
+
once, on evidence we do not have at compile time, and ``raw`` is the only form that keeps it
|
|
29
|
+
available. (``filtered/`` does exist for every feature including Velocyto — checked on real output,
|
|
30
|
+
because I had assumed otherwise.)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import TYPE_CHECKING, Literal
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
from scipy.sparse import coo_matrix, csr_matrix
|
|
41
|
+
|
|
42
|
+
from ..models.processing import SoloFeature
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING: # pragma: no cover — anndata is a runtime dep; this keeps import cost off compose
|
|
45
|
+
import anndata
|
|
46
|
+
|
|
47
|
+
#: What the rows of a feature's ``features.tsv`` are. Two features with the same axis are stackable
|
|
48
|
+
#: (same ``var``); two with different axes are not, at any price.
|
|
49
|
+
SoloAxis = Literal["gene", "junction"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class H5adError(RuntimeError):
|
|
53
|
+
"""The Solo.out on disk cannot be packaged as written (mismatched axes, missing matrix)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class SoloFeatureOutput:
|
|
58
|
+
"""What STAR writes under ``Solo.out/<Feature>/raw/`` for one ``--soloFeatures`` value."""
|
|
59
|
+
|
|
60
|
+
axis: SoloAxis
|
|
61
|
+
#: The Matrix Market file(s). One for a plain count; three for Velocyto, which has no
|
|
62
|
+
#: ``matrix.mtx`` at all (verified on real output — do not add one back).
|
|
63
|
+
matrices: tuple[str, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#: STARsolo's output contract, per feature.
|
|
67
|
+
#:
|
|
68
|
+
#: **This is a hand-written table, and it is the one shape this repo distrusts** — so read how it is
|
|
69
|
+
#: kept honest before adding a row. It is not derivable: it states what somebody else's binary
|
|
70
|
+
#: writes, and we have no source for that but STAR itself. Two mechanisms stand behind it:
|
|
71
|
+
#:
|
|
72
|
+
#: 1. ``test_every_solo_feature_is_classified`` collects from ``SoloFeature`` itself, so a new
|
|
73
|
+
#: feature is covered *because it exists*, not because someone remembered. Silently packaging a
|
|
74
|
+
#: new feature into nothing is the failure this prevents.
|
|
75
|
+
#: 2. The axis claim is checked **from bytes at run time**: :func:`_stack` compares the actual
|
|
76
|
+
#: ``features.tsv``/``barcodes.tsv`` of every feature it is about to stack and refuses on
|
|
77
|
+
#: disagreement. So a wrong ``axis`` here fails loudly instead of mislabelling a matrix — which is
|
|
78
|
+
#: the failure class (silent, plausible, wrong) that everything else in this repo is built against.
|
|
79
|
+
#:
|
|
80
|
+
#: The ``matrices`` claim is checked by Snakemake: they are declared outputs of ``starsolo_count``,
|
|
81
|
+
#: so a missing one is a hard failure of the rule that was supposed to write it. That matters more
|
|
82
|
+
#: than it sounds — the rule used to declare ``directory(Solo.out)``, under which STAR writing three
|
|
83
|
+
#: of five features and exiting 0 was indistinguishable from success.
|
|
84
|
+
SOLO_FEATURE_OUTPUT: dict[SoloFeature, SoloFeatureOutput] = {
|
|
85
|
+
"Gene": SoloFeatureOutput(axis="gene", matrices=("matrix.mtx",)),
|
|
86
|
+
"GeneFull": SoloFeatureOutput(axis="gene", matrices=("matrix.mtx",)),
|
|
87
|
+
"GeneFull_ExonOverIntron": SoloFeatureOutput(axis="gene", matrices=("matrix.mtx",)),
|
|
88
|
+
"GeneFull_Ex50pAS": SoloFeatureOutput(axis="gene", matrices=("matrix.mtx",)),
|
|
89
|
+
"Velocyto": SoloFeatureOutput(
|
|
90
|
+
axis="gene", matrices=("spliced.mtx", "unspliced.mtx", "ambiguous.mtx")
|
|
91
|
+
),
|
|
92
|
+
"SJ": SoloFeatureOutput(axis="junction", matrices=("matrix.mtx",)),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#: The feature whose matrices are three-in-one rather than one. Named structurally (by what STAR
|
|
96
|
+
#: writes) rather than by string compare wherever possible; this constant is the one place the name
|
|
97
|
+
#: appears, because the *file naming* of the second h5ad has to come from somewhere.
|
|
98
|
+
_VELOCYTO: SoloFeature = "Velocyto"
|
|
99
|
+
|
|
100
|
+
#: Names for ``features.tsv``'s columns after the first. STARsolo writes CellRanger's three-column
|
|
101
|
+
#: form (id, name, type) for every gene-axis feature. Anything else falls through to ``col<N>``,
|
|
102
|
+
#: which is honest rather than a guess dressed as a schema.
|
|
103
|
+
_VAR_COLUMNS: dict[int, str] = {1: "gene_name", 2: "feature_type"}
|
|
104
|
+
|
|
105
|
+
_FEATURES_TSV = "features.tsv"
|
|
106
|
+
_BARCODES_TSV = "barcodes.tsv"
|
|
107
|
+
|
|
108
|
+
_MAIN_SUFFIX = ".h5ad"
|
|
109
|
+
_VELOCYTO_SUFFIX = ".velocyto.h5ad"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def raw_files(feature: SoloFeature) -> tuple[str, ...]:
|
|
113
|
+
"""Every file ``Solo.out/<feature>/raw/`` must contain, relative to that directory."""
|
|
114
|
+
return (*SOLO_FEATURE_OUTPUT[feature].matrices, _FEATURES_TSV, _BARCODES_TSV)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def solo_raw_files(features: list[SoloFeature]) -> list[str]:
|
|
118
|
+
"""Every raw file a run of ``--soloFeatures <features>`` must produce, relative to ``Solo.out``.
|
|
119
|
+
|
|
120
|
+
These become explicit outputs of ``starsolo_count``, which is what makes "STAR exited 0 having
|
|
121
|
+
written only some of them" a rule failure rather than a thinner matrix nobody notices.
|
|
122
|
+
"""
|
|
123
|
+
return [f"{feat}/raw/{name}" for feat in features for name in raw_files(feat)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _stackable(features: list[SoloFeature]) -> list[SoloFeature]:
|
|
127
|
+
"""The gene-axis, one-matrix features — the ones that go in ``<sample>.h5ad`` together."""
|
|
128
|
+
return [
|
|
129
|
+
f
|
|
130
|
+
for f in features
|
|
131
|
+
if SOLO_FEATURE_OUTPUT[f].axis == "gene" and len(SOLO_FEATURE_OUTPUT[f].matrices) == 1
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _gene_axis(features: list[SoloFeature]) -> list[SoloFeature]:
|
|
136
|
+
"""The gene-axis features — every one that gets a ``filtered/`` cell-called copy on disk.
|
|
137
|
+
|
|
138
|
+
That is ``Gene``/``GeneFull*`` **and** ``Velocyto`` (all ``axis == "gene"``); the junction-axis
|
|
139
|
+
``SJ`` is excluded because we have not confirmed its ``filtered/`` layout on real output, and a
|
|
140
|
+
declared output that STAR did not write is a hard rule failure. Under-declaring only leaves a
|
|
141
|
+
file uncleaned; over-declaring breaks the run — so this stays to what the tree in hand shows.
|
|
142
|
+
"""
|
|
143
|
+
return [f for f in features if SOLO_FEATURE_OUTPUT[f].axis == "gene"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: STAR's per-run log/table files, written beside ``Solo.out`` at ``{OUTDIR}/{sample}/`` (not per
|
|
147
|
+
#: feature). ``Aligned.out.bam`` is deliberately **not** here: it is the CRAM rule's input, declared
|
|
148
|
+
#: on its own, while these four are the stats bundle's. All are written by every ``alignReads`` run.
|
|
149
|
+
STAR_LOG_FILES: tuple[str, ...] = ("Log.final.out", "Log.out", "Log.progress.out", "SJ.out.tab")
|
|
150
|
+
|
|
151
|
+
#: The unsorted alignment STAR writes under ``{OUTDIR}/{sample}/``. Its own constant because exactly
|
|
152
|
+
#: one rule (``solo_to_cram``) consumes it, and the file naming has to come from somewhere.
|
|
153
|
+
STAR_BAM = "Aligned.out.bam"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def solo_stats_files(features: list[SoloFeature]) -> list[str]:
|
|
157
|
+
"""Every small STAR stats file a ``--soloFeatures`` run writes, relative to ``Solo.out``.
|
|
158
|
+
|
|
159
|
+
Declared as ``temp()`` outputs of ``starsolo_count`` so the ``qc_bundle`` rule consumes them into
|
|
160
|
+
``<sample>.qc.json.gz`` and Snakemake then deletes them — no manual ``rm``. Three shapes:
|
|
161
|
+
|
|
162
|
+
- ``Barcodes.stats`` — once, at the top level (barcode-demux QC, not per feature).
|
|
163
|
+
- ``Summary.csv`` / ``Features.stats`` — one per feature, for every feature.
|
|
164
|
+
- ``UMIperCellSorted.txt`` — the knee-plot vector, written **only** for the cell-filtered gene
|
|
165
|
+
features (the ``_stackable`` set). STAR writes none for ``Velocyto`` or ``SJ``, so declaring it
|
|
166
|
+
there would fail the rule. This is the one per-feature distinction, confirmed against real
|
|
167
|
+
output (Gene/GeneFull* have it; Velocyto does not).
|
|
168
|
+
"""
|
|
169
|
+
out = ["Barcodes.stats"]
|
|
170
|
+
for feat in features:
|
|
171
|
+
out += [f"{feat}/Summary.csv", f"{feat}/Features.stats"]
|
|
172
|
+
out += [f"{feat}/UMIperCellSorted.txt" for feat in _stackable(features)]
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def solo_filtered_files(features: list[SoloFeature]) -> list[str]:
|
|
177
|
+
"""Every ``filtered/`` file a ``--soloFeatures`` run writes, relative to ``Solo.out``.
|
|
178
|
+
|
|
179
|
+
STAR's default cell filter (``--soloCellFilter`` default ``CellRanger2.2 3000 0.99 10``) writes a
|
|
180
|
+
``filtered/`` copy of each gene-axis feature — same matrices + axis files as ``raw/``. Nothing
|
|
181
|
+
downstream reads it (the h5ad is built from ``raw/``), so it is declared ``temp()`` and the
|
|
182
|
+
``qc_bundle`` rule lists it as input purely to (a) record ``filtered/barcodes.tsv`` — what STAR
|
|
183
|
+
*called* — as provenance and (b) trigger its deletion.
|
|
184
|
+
"""
|
|
185
|
+
return [f"{feat}/filtered/{name}" for feat in _gene_axis(features) for name in raw_files(feat)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def h5ad_suffixes(features: list[SoloFeature]) -> list[str]:
|
|
189
|
+
"""The ``.h5ad`` files a run of ``--soloFeatures <features>`` yields, as filename suffixes.
|
|
190
|
+
|
|
191
|
+
Called from ``starsolo.smk`` at parse time to declare the rule's outputs **and** by
|
|
192
|
+
:func:`write_h5ad` to decide what to write, so the two cannot disagree. One function, two callers
|
|
193
|
+
— the alternative (compose emits the list, the verb writes what it likes) is two sources of truth
|
|
194
|
+
for one fact, which is the bug this repo keeps finding.
|
|
195
|
+
|
|
196
|
+
A junction-axis feature (``SJ``) yields nothing here, and that is deliberate rather than
|
|
197
|
+
forgotten: its matrix has a different ``var`` axis, nothing downstream consumes it, and STAR has
|
|
198
|
+
already written it to ``Solo.out/SJ/raw/`` for anyone who wants it. It is out of the default
|
|
199
|
+
feature set for the same reason. Give it its own object when something needs one.
|
|
200
|
+
"""
|
|
201
|
+
out: list[str] = []
|
|
202
|
+
if _stackable(features):
|
|
203
|
+
out.append(_MAIN_SUFFIX)
|
|
204
|
+
if _VELOCYTO in features:
|
|
205
|
+
out.append(_VELOCYTO_SUFFIX)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_features(path: Path) -> tuple[list[str], dict[str, list[str]]]:
|
|
210
|
+
rows = [ln.split("\t") for ln in path.read_text().splitlines() if ln]
|
|
211
|
+
if not rows:
|
|
212
|
+
raise H5adError(f"{path} is empty; STARsolo wrote no features")
|
|
213
|
+
names = [r[0] for r in rows]
|
|
214
|
+
width = max(len(r) for r in rows)
|
|
215
|
+
extra = {
|
|
216
|
+
_VAR_COLUMNS.get(i, f"col{i}"): [r[i] if i < len(r) else "" for r in rows]
|
|
217
|
+
for i in range(1, width)
|
|
218
|
+
}
|
|
219
|
+
return names, extra
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _axis_key(raw: Path) -> bytes:
|
|
223
|
+
"""The identity of a feature's axes, as bytes. Compared, never interpreted."""
|
|
224
|
+
return (raw / _FEATURES_TSV).read_bytes() + b"\0" + (raw / _BARCODES_TSV).read_bytes()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _read_matrix(path: Path, shape: tuple[int, int]) -> csr_matrix:
|
|
228
|
+
"""One Matrix Market file -> a **cells x genes** sparse matrix.
|
|
229
|
+
|
|
230
|
+
**Transposed on the way in, by construction.** STARsolo writes genes x barcodes; AnnData is
|
|
231
|
+
observations x variables, and the observations are cells. Getting this backwards yields an object
|
|
232
|
+
that loads cleanly, plots cleanly, and is wrong in the one way nobody checks — and on a square
|
|
233
|
+
matrix it would not even be a shape error. So the swap happens where you can see it: STAR's
|
|
234
|
+
column index becomes the row, its row index becomes the column, and ``shape`` is asserted against
|
|
235
|
+
the axes we actually read rather than taken from the file's own header.
|
|
236
|
+
|
|
237
|
+
Parsed here rather than via ``scipy.io.mmread`` because that function's return type is mid-change
|
|
238
|
+
(sparse matrix -> sparse array) and it warns about it; pinning our matrix orientation to somebody
|
|
239
|
+
else's deprecation cycle is a poor trade for twelve lines. ``int32`` because these are UMI counts
|
|
240
|
+
per gene per cell.
|
|
241
|
+
"""
|
|
242
|
+
if not path.exists():
|
|
243
|
+
raise H5adError(f"{path} is missing; the STAR run that should have written it did not")
|
|
244
|
+
rows: list[int] = []
|
|
245
|
+
cols: list[int] = []
|
|
246
|
+
data: list[int] = []
|
|
247
|
+
header_seen = False
|
|
248
|
+
with path.open() as fh:
|
|
249
|
+
for line in fh:
|
|
250
|
+
if line.startswith("%") or not line.strip():
|
|
251
|
+
continue
|
|
252
|
+
if not header_seen: # the dims line; we trust the axis files, not this
|
|
253
|
+
header_seen = True
|
|
254
|
+
continue
|
|
255
|
+
gene, barcode, value = line.split()
|
|
256
|
+
rows.append(int(barcode) - 1) # 1-based -> 0-based, and STAR's COLUMN is our ROW
|
|
257
|
+
cols.append(int(gene) - 1)
|
|
258
|
+
data.append(int(value))
|
|
259
|
+
mat = coo_matrix(
|
|
260
|
+
(
|
|
261
|
+
np.array(data, dtype=np.int32),
|
|
262
|
+
(np.array(rows, dtype=np.int64), np.array(cols, dtype=np.int64)),
|
|
263
|
+
),
|
|
264
|
+
shape=shape,
|
|
265
|
+
)
|
|
266
|
+
return mat.tocsr()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _stack(solo_dir: Path, features: list[SoloFeature], primary: SoloFeature) -> anndata.AnnData:
|
|
270
|
+
"""The gene-axis features as one object: ``X`` = primary, one layer per other feature."""
|
|
271
|
+
import anndata as ad
|
|
272
|
+
|
|
273
|
+
raws = {f: solo_dir / f / "raw" for f in features}
|
|
274
|
+
# THE assertion. Four features are stacked into one `var`/`obs`, so if their axes differ, every
|
|
275
|
+
# layer but the first is silently misaligned -- each count landing on the wrong gene, in an
|
|
276
|
+
# object that opens fine and plots fine. Bytes decide; nothing here interprets a gene id.
|
|
277
|
+
keys = {f: _axis_key(raw) for f, raw in raws.items()}
|
|
278
|
+
odd = sorted(f for f, k in keys.items() if k != keys[primary])
|
|
279
|
+
if odd:
|
|
280
|
+
raise H5adError(
|
|
281
|
+
f"features {odd} do not share {primary}'s barcodes.tsv/features.tsv, so their counts "
|
|
282
|
+
f"cannot be layers of one object. This should be impossible for a single STAR run — "
|
|
283
|
+
f"check that Solo.out was not assembled from more than one."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
names, extra = _read_features(raws[primary] / _FEATURES_TSV)
|
|
287
|
+
barcodes = (raws[primary] / _BARCODES_TSV).read_text().split()
|
|
288
|
+
shape = (len(barcodes), len(names))
|
|
289
|
+
# Every matrix is read before the object is built, so a missing one raises BEFORE anything is
|
|
290
|
+
# written: a refusal must not leave a half-written deliverable on disk for someone to find.
|
|
291
|
+
mats = {f: _read_matrix(raws[f] / "matrix.mtx", shape) for f in features}
|
|
292
|
+
|
|
293
|
+
adata = ad.AnnData(X=mats[primary])
|
|
294
|
+
adata.obs_names = barcodes
|
|
295
|
+
adata.var_names = names
|
|
296
|
+
for col, values in extra.items():
|
|
297
|
+
adata.var[col] = values
|
|
298
|
+
for feat, mat in mats.items():
|
|
299
|
+
if feat != primary:
|
|
300
|
+
adata.layers[feat] = mat
|
|
301
|
+
adata.uns["primary_feature"] = primary
|
|
302
|
+
adata.uns["soloFeatures"] = list(features)
|
|
303
|
+
return adata
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _velocyto(solo_dir: Path) -> anndata.AnnData:
|
|
307
|
+
"""Velocyto's three matrices as one object.
|
|
308
|
+
|
|
309
|
+
``X`` duplicates ``layers["spliced"]`` on purpose: scVelo reads ``layers["spliced"]`` and
|
|
310
|
+
``layers["unspliced"]``, while most everything else reads ``X``. The cost is one sparse matrix.
|
|
311
|
+
"""
|
|
312
|
+
import anndata as ad
|
|
313
|
+
|
|
314
|
+
raw = solo_dir / _VELOCYTO / "raw"
|
|
315
|
+
names, extra = _read_features(raw / _FEATURES_TSV)
|
|
316
|
+
barcodes = (raw / _BARCODES_TSV).read_text().split()
|
|
317
|
+
shape = (len(barcodes), len(names))
|
|
318
|
+
layers = {
|
|
319
|
+
m.removesuffix(".mtx"): _read_matrix(raw / m, shape)
|
|
320
|
+
for m in SOLO_FEATURE_OUTPUT[_VELOCYTO].matrices
|
|
321
|
+
}
|
|
322
|
+
adata = ad.AnnData(X=layers["spliced"])
|
|
323
|
+
adata.obs_names = barcodes
|
|
324
|
+
adata.var_names = names
|
|
325
|
+
for col, values in extra.items():
|
|
326
|
+
adata.var[col] = values
|
|
327
|
+
for name, mat in layers.items():
|
|
328
|
+
adata.layers[name] = mat
|
|
329
|
+
adata.uns["primary_feature"] = "spliced"
|
|
330
|
+
return adata
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def write_h5ad(
|
|
334
|
+
solo_dir: Path, features: list[SoloFeature], primary: SoloFeature, out_prefix: Path
|
|
335
|
+
) -> list[Path]:
|
|
336
|
+
"""``Solo.out`` -> the ``.h5ad`` files :func:`h5ad_suffixes` promised, in that order."""
|
|
337
|
+
stackable = _stackable(features)
|
|
338
|
+
written: list[Path] = []
|
|
339
|
+
for suffix in h5ad_suffixes(features):
|
|
340
|
+
out = Path(f"{out_prefix}{suffix}")
|
|
341
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
if suffix == _VELOCYTO_SUFFIX:
|
|
343
|
+
adata = _velocyto(solo_dir)
|
|
344
|
+
else:
|
|
345
|
+
# `primary` names which matrix is X. It is `soloFeatures[0]` and may be a feature that
|
|
346
|
+
# is not stackable, in which case the first one that is takes its place -- a stated
|
|
347
|
+
# fallback beats an IndexError three hours into a run.
|
|
348
|
+
head = primary if primary in stackable else stackable[0]
|
|
349
|
+
adata = _stack(solo_dir, stackable, head)
|
|
350
|
+
adata.write_h5ad(out)
|
|
351
|
+
written.append(out)
|
|
352
|
+
return written
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
__all__ = [
|
|
356
|
+
"H5adError",
|
|
357
|
+
"SoloAxis",
|
|
358
|
+
"SoloFeatureOutput",
|
|
359
|
+
"SOLO_FEATURE_OUTPUT",
|
|
360
|
+
"STAR_BAM",
|
|
361
|
+
"STAR_LOG_FILES",
|
|
362
|
+
"h5ad_suffixes",
|
|
363
|
+
"raw_files",
|
|
364
|
+
"solo_filtered_files",
|
|
365
|
+
"solo_raw_files",
|
|
366
|
+
"solo_stats_files",
|
|
367
|
+
"write_h5ad",
|
|
368
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# workflows/map/star.smk -- HAND-WRITTEN, VERSIONED, CI-TESTED. NEVER machine-generated.
|
|
2
|
+
#
|
|
3
|
+
# Plain STAR mapping for bulk paired-end RNA-seq (no cell barcode / UMI demultiplex). Selected by the
|
|
4
|
+
# composer's module id `map/star`; gene counts come from STAR's own `--quantMode GeneCounts`. The
|
|
5
|
+
# genome index resolves at RUN TIME from a `liulab-genome` assembly id.
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_units(path):
|
|
11
|
+
with open(path, newline="") as fh:
|
|
12
|
+
return list(csv.DictReader(fh, delimiter="\t"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UNITS = _load_units(config["units_tsv"])
|
|
16
|
+
SAMPLES = sorted({u["sample_id"] for u in UNITS})
|
|
17
|
+
OUTDIR = config["outdir"]
|
|
18
|
+
ASSEMBLY = config["genome"]["assembly"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fastqs(sample, role):
|
|
22
|
+
# Ordered by the units.tsv `run` column so a pooled sample's two mates pair correctly (STAR
|
|
23
|
+
# desyncs otherwise). `run` is seqforge's own run grouping -- no filename parsing here.
|
|
24
|
+
us = [u for u in UNITS if u["sample_id"] == sample and u["read_id"] == role]
|
|
25
|
+
return [u["path"] for u in sorted(us, key=lambda u: (u["run"], u["path"]))]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def readfilesin(sample, *roles):
|
|
29
|
+
"""Render STAR ``--readFilesIn`` for one sample: each role (a mate) is its FASTQs **comma-joined**,
|
|
30
|
+
and the mates are space-separated -- ``mate1_run1,mate1_run2 mate2_run1,mate2_run2``.
|
|
31
|
+
|
|
32
|
+
A sample pooled across N sequencing runs passes every run's file for a mate as one comma-list, in
|
|
33
|
+
matching run order for every mate (``fastqs`` preserves units.tsv order). This is STAR's own
|
|
34
|
+
multi-file syntax; joining with spaces instead makes STAR read the extra files as extra mates and
|
|
35
|
+
crash. A single-run sample renders one file per mate, so this generalises to any run count."""
|
|
36
|
+
return " ".join(",".join(fastqs(sample, role)) for role in roles)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
rule all:
|
|
40
|
+
input:
|
|
41
|
+
expand(f"{OUTDIR}/{{sample}}/ReadsPerGene.out.tab", sample=SAMPLES),
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
rule genome_index:
|
|
45
|
+
"""Resolve the STAR index via liulab-genome at run time (never a path in the manifest).
|
|
46
|
+
|
|
47
|
+
This rule only **looks up** the index; it never builds one. `get_star_index` returns the genomeDir
|
|
48
|
+
liulab-genome already built for this assembly + annotation, and **raises if none exists** -- the
|
|
49
|
+
index is liulab-genome's artifact, built ahead of the run by its own machinery, in its own
|
|
50
|
+
environment. A machine with no prebuilt index fails loudly here ("build it first"), which is the
|
|
51
|
+
failure mode we want: the pipeline consumes the index, it does not decide when or how it is built.
|
|
52
|
+
|
|
53
|
+
Because nothing is invoked here -- no STAR, no `genomeGenerate` -- this rule needs no tool on PATH
|
|
54
|
+
and no `container:`. (A `container:` would be moot anyway: snakemake wraps a container around a
|
|
55
|
+
`shell:` command in `shell.py`, but a `run:` block executes Python in the snakemake process and
|
|
56
|
+
never passes through that wrap; snakemake's own linter excludes `is_run` rules from "missing
|
|
57
|
+
software definition".) The container on the alignment rule pins the aligner that does the work.
|
|
58
|
+
"""
|
|
59
|
+
output:
|
|
60
|
+
directory(f"{OUTDIR}/index/{ASSEMBLY}"),
|
|
61
|
+
params:
|
|
62
|
+
assembly=ASSEMBLY,
|
|
63
|
+
annotation=config["genome"]["annotation"],
|
|
64
|
+
run:
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
|
|
67
|
+
from genome import Genome
|
|
68
|
+
|
|
69
|
+
index = Genome(params.assembly).get_star_index(gtf=params.annotation)
|
|
70
|
+
out = Path(output[0])
|
|
71
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
out.symlink_to(index)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
rule star_count:
|
|
76
|
+
"""Map one bulk sample's two mates to per-gene counts (STAR GeneCounts)."""
|
|
77
|
+
input:
|
|
78
|
+
mate1=lambda wc: fastqs(wc.sample, config["read_files_in"]["mate1"]),
|
|
79
|
+
mate2=lambda wc: fastqs(wc.sample, config["read_files_in"]["mate2"]),
|
|
80
|
+
index=rules.genome_index.output,
|
|
81
|
+
output:
|
|
82
|
+
f"{OUTDIR}/{{sample}}/ReadsPerGene.out.tab",
|
|
83
|
+
# liulab-runtime's `align-rna`, resolved by compose. See starsolo.smk's note: consuming their
|
|
84
|
+
# artifact, not defining an env, and honoured only under `--software-deployment-method`.
|
|
85
|
+
container: config["container"]
|
|
86
|
+
threads: config["threads"]
|
|
87
|
+
params:
|
|
88
|
+
bulk=config["bulk"],
|
|
89
|
+
prefix=lambda wc: f"{OUTDIR}/{wc.sample}/",
|
|
90
|
+
# each mate is its runs comma-joined, so a sample pooled across runs maps in one pass.
|
|
91
|
+
reads=lambda wc: readfilesin(
|
|
92
|
+
wc.sample, config["read_files_in"]["mate1"], config["read_files_in"]["mate2"]
|
|
93
|
+
),
|
|
94
|
+
shell:
|
|
95
|
+
r"""
|
|
96
|
+
STAR --runMode alignReads --genomeDir {input.index} --runThreadN {threads} \
|
|
97
|
+
--readFilesIn {params.reads} --readFilesCommand zcat \
|
|
98
|
+
--quantMode {params.bulk[quantMode]} \
|
|
99
|
+
--outFileNamePrefix {params.prefix} \
|
|
100
|
+
--outSAMtype BAM SortedByCoordinate
|
|
101
|
+
"""
|