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.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. 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
+ """