spatialscape 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spatialscape/__init__.py +13 -0
- spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
- spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
- spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
- spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
- spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
- spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
- spatialscape/_app/assets/expression-E359vKZW.js +368 -0
- spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
- spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
- spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
- spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
- spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
- spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
- spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
- spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
- spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
- spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
- spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
- spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
- spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
- spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
- spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
- spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
- spatialscape/_app/favicon.svg +161 -0
- spatialscape/_app/index.html +23 -0
- spatialscape/build.py +630 -0
- spatialscape/cli.py +399 -0
- spatialscape/config.py +570 -0
- spatialscape/convert.py +321 -0
- spatialscape/discover.py +98 -0
- spatialscape/expression.py +300 -0
- spatialscape/fingerprint.py +152 -0
- spatialscape/genes.py +251 -0
- spatialscape/geometry.py +132 -0
- spatialscape/images.py +240 -0
- spatialscape/init.py +578 -0
- spatialscape/manifest.py +162 -0
- spatialscape/obs.py +48 -0
- spatialscape/outlines.py +145 -0
- spatialscape/polygons.py +85 -0
- spatialscape/rds.py +609 -0
- spatialscape/rdsread.py +556 -0
- spatialscape/readers.py +195 -0
- spatialscape/registry.py +83 -0
- spatialscape/serve.py +262 -0
- spatialscape/session.py +474 -0
- spatialscape/site.py +274 -0
- spatialscape/synthetic.py +142 -0
- spatialscape/thumbnail.py +95 -0
- spatialscape/tracks.py +551 -0
- spatialscape/validate.py +193 -0
- spatialscape/vocab.py +188 -0
- spatialscape-0.3.0.dist-info/METADATA +68 -0
- spatialscape-0.3.0.dist-info/RECORD +58 -0
- spatialscape-0.3.0.dist-info/WHEEL +4 -0
- spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
- spatialscape-0.3.0.dist-info/licenses/LICENSE +21 -0
spatialscape/tracks.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Genome tracks for ATAC samples: pseudobulk accessibility per annotation category.
|
|
2
|
+
|
|
3
|
+
For every field listed in `tracks.by`, one bigWig per category (plus one for all cells); the viewer's genome panel
|
|
4
|
+
stacks them under the current locus, colored from the shared vocabulary. Two sources:
|
|
5
|
+
|
|
6
|
+
- Fragment files (Cell Ranger `fragments.tsv.gz`): Tn5 insertions per million, both ends of every fragment,
|
|
7
|
+
binned. One file, or one per run for an object pooled from several 10x runs, whose barcodes collide: each
|
|
8
|
+
observation is matched to the fragments of its own run (`sample_column`), and a category's track sums its cells
|
|
9
|
+
over every run before the per-million scaling. Each file is streamed once (coordinate-sorted, so one chromosome
|
|
10
|
+
is in memory at a time) and never decompressed to disk.
|
|
11
|
+
- bigWigs exported elsewhere (ArchR `getGroupBW`, Signac `ExportGroupBW`, bamCoverage per group), copied in as
|
|
12
|
+
they are: their bin size is read from the files and the manifest records the units given.
|
|
13
|
+
|
|
14
|
+
Needs pyBigWig (`pip install "spatialscape[tracks]"`).
|
|
15
|
+
|
|
16
|
+
samples/<id>/tracks/
|
|
17
|
+
all.bw every cell (from fragments, or when `bigwigs.all` was given)
|
|
18
|
+
<field>/<category>.bw one per category label (file names sanitized; the manifest maps labels to paths)
|
|
19
|
+
peaks.bed the sample's peak features, when their names are chr:start-end
|
|
20
|
+
genes/ gene models, one JSON per chromosome plus an index of names (see genes.py)
|
|
21
|
+
links.bedpe copied from the yaml when given
|
|
22
|
+
chrom.sizes
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import gzip
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
from .config import TracksSpec, is_remote
|
|
38
|
+
from .genes import GeneModel, gene_models_for, write_gene_models
|
|
39
|
+
from .manifest import TrackSet, TracksInfo
|
|
40
|
+
|
|
41
|
+
Log = Callable[[str], None]
|
|
42
|
+
TRACKS_RECIPE = 4 # bump when write_tracks writes something different for the same inputs and settings
|
|
43
|
+
DEFAULT_BIN = 25 # bp per bin for tracks built from fragments
|
|
44
|
+
UNITS = {"nfrags": "insertions per million", "tss": "per million TSS insertions", "cells": "insertions per cell"}
|
|
45
|
+
PEAK_RE = re.compile(r"^(?:peak: )?(chr[0-9XYM]+|[0-9XYM]+)[:-](\d+)-(\d+)$") # chr1:100-200 (10x) or chr1-100-200 (Signac)
|
|
46
|
+
|
|
47
|
+
# GRCh38 primary chromosomes (the 10x references use these names and lengths)
|
|
48
|
+
GRCH38 = {
|
|
49
|
+
"chr1": 248956422, "chr2": 242193529, "chr3": 198295559, "chr4": 190214555, "chr5": 181538259,
|
|
50
|
+
"chr6": 170805979, "chr7": 159345973, "chr8": 145138636, "chr9": 138394717, "chr10": 133797422,
|
|
51
|
+
"chr11": 135086622, "chr12": 133275309, "chr13": 114364328, "chr14": 107043718, "chr15": 101991189,
|
|
52
|
+
"chr16": 90338345, "chr17": 83257441, "chr18": 80373285, "chr19": 58617616, "chr20": 64444167,
|
|
53
|
+
"chr21": 46709983, "chr22": 50818468, "chrX": 156040895, "chrY": 57227415, "chrM": 16569,
|
|
54
|
+
}
|
|
55
|
+
GENOME_SIZES = {"hg38": GRCH38, "GRCh38": GRCH38}
|
|
56
|
+
|
|
57
|
+
Hits = tuple[np.ndarray, np.ndarray] # (bin indices, counts) of the non-zero bins of one chromosome
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pybigwig():
|
|
61
|
+
try:
|
|
62
|
+
import pyBigWig
|
|
63
|
+
except ImportError as e: # pragma: no cover
|
|
64
|
+
raise ImportError("genome tracks need pyBigWig: pip install 'spatialscape[tracks]'") from e
|
|
65
|
+
return pyBigWig
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def chrom_sizes(spec: TracksSpec, default: dict[str, int] | None = None) -> dict[str, int]:
|
|
69
|
+
"""`chrom_sizes:` if given, else the genome's table, else `default` (the chromosomes of a bigWig being copied)."""
|
|
70
|
+
if spec.chrom_sizes is not None:
|
|
71
|
+
df = pd.read_csv(spec.chrom_sizes, sep="\t", header=None, usecols=[0, 1], names=["chrom", "size"], dtype={"chrom": str})
|
|
72
|
+
return dict(zip(df["chrom"], df["size"].astype(int), strict=False))
|
|
73
|
+
if spec.genome in GENOME_SIZES:
|
|
74
|
+
return dict(GENOME_SIZES[spec.genome])
|
|
75
|
+
if default:
|
|
76
|
+
return {str(c): int(n) for c, n in default.items()}
|
|
77
|
+
raise ValueError(f"tracks: no chromosome sizes known for genome {spec.genome!r}; set `chrom_sizes:`")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _slug(label: str) -> str:
|
|
81
|
+
s = re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("_")
|
|
82
|
+
return s or "category"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _unique_slug(label: str, used: set[str]) -> str:
|
|
86
|
+
slug = _slug(label)
|
|
87
|
+
n = 2
|
|
88
|
+
while slug in used: # "NK/T cells" and "NK T cells" must not share a file
|
|
89
|
+
slug = f"{_slug(label)}_{n}"
|
|
90
|
+
n += 1
|
|
91
|
+
used.add(slug)
|
|
92
|
+
return slug
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_bigwig(pybigwig, path: Path, sizes: dict[str, int], per_chrom: dict[str, Hits], bin_: int, scale: float) -> None:
|
|
96
|
+
"""per_chrom: chrom -> (bin indices, counts) for the non-zero bins. Values are counts * scale. The gaps between
|
|
97
|
+
them are written as explicit zero runs: a bigWig's zoom levels average over covered bases only, so without
|
|
98
|
+
them a lone 25 bp bin would be reported at full height for a whole 250 bp zoom bin once zoomed out."""
|
|
99
|
+
bw = pybigwig.open(str(path), "w")
|
|
100
|
+
bw.addHeader(list(sizes.items()))
|
|
101
|
+
for chrom in sizes:
|
|
102
|
+
hit = per_chrom.get(chrom)
|
|
103
|
+
if hit is None or len(hit[0]) == 0:
|
|
104
|
+
continue
|
|
105
|
+
idx, cnt = hit
|
|
106
|
+
size = sizes[chrom]
|
|
107
|
+
starts = idx.astype(np.int64) * bin_
|
|
108
|
+
ends = np.minimum(starts + bin_, size)
|
|
109
|
+
gap_s = np.concatenate([[0], ends])
|
|
110
|
+
gap_e = np.concatenate([starts, [size]])
|
|
111
|
+
keep = gap_e > gap_s
|
|
112
|
+
all_s = np.concatenate([starts, gap_s[keep]])
|
|
113
|
+
all_e = np.concatenate([ends, gap_e[keep]])
|
|
114
|
+
all_v = np.concatenate([cnt.astype(np.float64) * scale, np.zeros(int(keep.sum()))])
|
|
115
|
+
order = np.argsort(all_s, kind="stable")
|
|
116
|
+
all_s, all_e, all_v = all_s[order], all_e[order], all_v[order]
|
|
117
|
+
# the entries tile the chromosome without gaps: merge neighbours with the same value (runs of 1s, long zero
|
|
118
|
+
# stretches) into one interval each, which is what keeps a dense track's file size in check
|
|
119
|
+
first = np.ones(len(all_s), dtype=bool)
|
|
120
|
+
first[1:] = all_v[1:] != all_v[:-1]
|
|
121
|
+
s_ = all_s[first]
|
|
122
|
+
e_ = np.append(s_[1:], all_e[-1])
|
|
123
|
+
v_ = all_v[first]
|
|
124
|
+
bw.addEntries([chrom] * len(s_), s_.tolist(), ends=e_.tolist(), values=v_.tolist())
|
|
125
|
+
bw.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _bare_barcode(name: str, run: str) -> str:
|
|
129
|
+
"""The barcode inside an observation name that carries its run: `run#bc` (ArchR), `run_bc`, `run-bc`, `bc_run`, `bc-run`, `bc#run`."""
|
|
130
|
+
for sep in ("#", "_", "-"):
|
|
131
|
+
if name.startswith(run + sep):
|
|
132
|
+
return name[len(run) + 1 :]
|
|
133
|
+
if name.endswith(sep + run):
|
|
134
|
+
return name[: -len(run) - 1]
|
|
135
|
+
return name
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def tss_positions(models: dict[str, list[GeneModel]]) -> dict[str, np.ndarray]:
|
|
139
|
+
"""Per chromosome, the sorted TSS positions of the gene models (start on +, last base on -, start when unknown)."""
|
|
140
|
+
out = {}
|
|
141
|
+
for chrom, ms in models.items():
|
|
142
|
+
pos = np.fromiter(((m.end - 1) if m.strand < 0 else m.start for m in ms), dtype=np.int64, count=len(ms))
|
|
143
|
+
out[chrom] = np.unique(pos)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def near_tss(pos: np.ndarray, tss: np.ndarray | None, window: int) -> np.ndarray:
|
|
148
|
+
"""Which insertion positions lie within `window` bp of a TSS (sorted positions), as a boolean mask."""
|
|
149
|
+
if tss is None or len(tss) == 0:
|
|
150
|
+
return np.zeros(len(pos), dtype=bool)
|
|
151
|
+
i = np.searchsorted(tss, pos)
|
|
152
|
+
left = tss[np.clip(i - 1, 0, len(tss) - 1)]
|
|
153
|
+
right = tss[np.clip(i, 0, len(tss) - 1)]
|
|
154
|
+
return (np.abs(pos - left) <= window) | (np.abs(right - pos) <= window)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _merge_hits(store: dict[str, Hits], chrom: str, acc: np.ndarray) -> None:
|
|
158
|
+
"""Keep the non-zero bins of `acc` for `chrom`, added to what an earlier fragment file left for it."""
|
|
159
|
+
nz = np.flatnonzero(acc)
|
|
160
|
+
if not len(nz):
|
|
161
|
+
return
|
|
162
|
+
idx, cnt = nz.astype(np.int32), acc[nz].astype(np.int64)
|
|
163
|
+
prev = store.get(chrom)
|
|
164
|
+
if prev is not None:
|
|
165
|
+
both_idx = np.concatenate([prev[0], idx])
|
|
166
|
+
both_cnt = np.concatenate([prev[1], cnt])
|
|
167
|
+
uniq, inv = np.unique(both_idx, return_inverse=True)
|
|
168
|
+
idx, cnt = uniq.astype(np.int32), np.bincount(inv, weights=both_cnt).astype(np.int64)
|
|
169
|
+
store[chrom] = (idx, cnt)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Insertions:
|
|
173
|
+
"""Insertion counts per bin, per category of each field and for every cell: one chromosome's dense accumulators
|
|
174
|
+
at a time, kept as non-zero bins once the chromosome is done, summed over fragment files. Alongside, what each
|
|
175
|
+
group's track is scaled by: its insertions, its insertions at TSSs, or its number of cells."""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self, sizes: dict[str, int], bin_: int, fields: list[str], ncat: dict[str, int], *, tss: dict[str, np.ndarray] | None = None, window: int = 50
|
|
179
|
+
):
|
|
180
|
+
self.sizes, self.bin, self.fields, self.ncat = sizes, bin_, fields, ncat
|
|
181
|
+
self.tss, self.window = tss, window
|
|
182
|
+
self.by: dict[str, list[dict[str, Hits]]] = {f: [{} for _ in range(ncat[f])] for f in fields}
|
|
183
|
+
self.all: dict[str, Hits] = {}
|
|
184
|
+
self.totals = {f: np.zeros(ncat[f], dtype=np.int64) for f in fields}
|
|
185
|
+
self.tss_totals = {f: np.zeros(ncat[f], dtype=np.int64) for f in fields}
|
|
186
|
+
self.total_all = 0
|
|
187
|
+
self.tss_total_all = 0
|
|
188
|
+
self.cur: str | None = None
|
|
189
|
+
self.cur_tss: np.ndarray | None = None
|
|
190
|
+
self.acc: dict[str, np.ndarray] = {}
|
|
191
|
+
self.acc_all = np.zeros(0, dtype=np.int32)
|
|
192
|
+
|
|
193
|
+
def start(self, chrom: str) -> None:
|
|
194
|
+
self.flush()
|
|
195
|
+
self.cur = chrom
|
|
196
|
+
self.cur_tss = self.tss.get(chrom) if self.tss is not None else None
|
|
197
|
+
nbins = -(-self.sizes[chrom] // self.bin) # the last bin ends at the chromosome end, never past it
|
|
198
|
+
self.acc_all = np.zeros(nbins, dtype=np.int32)
|
|
199
|
+
self.acc = {f: np.zeros((self.ncat[f], nbins), dtype=np.int32) for f in self.fields}
|
|
200
|
+
|
|
201
|
+
def add(self, bp: np.ndarray, rows: np.ndarray, codes: dict[str, np.ndarray]) -> None:
|
|
202
|
+
"""bp: insertion positions; rows: observation per insertion."""
|
|
203
|
+
nbins = len(self.acc_all)
|
|
204
|
+
pos = np.clip(bp // self.bin, 0, nbins - 1)
|
|
205
|
+
self.acc_all += np.bincount(pos, minlength=nbins).astype(np.int32) # bincount: ~20x faster than add.at
|
|
206
|
+
self.total_all += len(pos)
|
|
207
|
+
at_tss = near_tss(bp, self.cur_tss, self.window) if self.tss is not None else None
|
|
208
|
+
if at_tss is not None:
|
|
209
|
+
self.tss_total_all += int(at_tss.sum())
|
|
210
|
+
for f in self.fields:
|
|
211
|
+
ck = codes[f][rows]
|
|
212
|
+
counts = np.bincount(ck, minlength=self.ncat[f])
|
|
213
|
+
self.totals[f] += counts
|
|
214
|
+
if at_tss is not None:
|
|
215
|
+
self.tss_totals[f] += np.bincount(ck[at_tss], minlength=self.ncat[f])
|
|
216
|
+
for k in np.flatnonzero(counts):
|
|
217
|
+
self.acc[f][k] += np.bincount(pos[ck == k], minlength=nbins).astype(np.int32)
|
|
218
|
+
|
|
219
|
+
def flush(self) -> None:
|
|
220
|
+
if self.cur is None:
|
|
221
|
+
return
|
|
222
|
+
_merge_hits(self.all, self.cur, self.acc_all)
|
|
223
|
+
for f in self.fields:
|
|
224
|
+
for k in range(self.ncat[f]):
|
|
225
|
+
_merge_hits(self.by[f][k], self.cur, self.acc[f][k])
|
|
226
|
+
self.cur, self.acc, self.acc_all = None, {}, np.zeros(0, dtype=np.int32)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def split_ssh_url(url: str) -> tuple[str, str]:
|
|
230
|
+
"""`ssh://[user@]host/abs/path` -> (host, /abs/path)."""
|
|
231
|
+
rest = url[len("ssh://") :]
|
|
232
|
+
host, _, path = rest.partition("/")
|
|
233
|
+
if not host or not path:
|
|
234
|
+
raise ValueError(f"tracks: a remote fragment file is ssh://host/absolute/path, got {url!r}")
|
|
235
|
+
return host, "/" + path
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _file_label(path: Path | str) -> str:
|
|
239
|
+
return path.rsplit("/", 1)[-1] if isinstance(path, str) else path.name
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _stream(path: Path | str, ins: _Insertions, bc: pd.Index, rows_of_run: np.ndarray, codes: dict[str, np.ndarray]) -> tuple[int, int]:
|
|
243
|
+
"""Add one fragment file's insertions for the observations in `bc` (rows `rows_of_run`); (fragments used,
|
|
244
|
+
chromosomes). An `ssh://host/path` file is read through `ssh host cat path`: one sequential pass, nothing
|
|
245
|
+
written on the remote side or stored locally."""
|
|
246
|
+
proc = None
|
|
247
|
+
if is_remote(path):
|
|
248
|
+
host, remote = split_ssh_url(str(path))
|
|
249
|
+
proc = subprocess.Popen(["ssh", "-o", "BatchMode=yes", host, f"cat {shlex.quote(remote)}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
250
|
+
source = proc.stdout
|
|
251
|
+
else:
|
|
252
|
+
source = path
|
|
253
|
+
reader = pd.read_csv(
|
|
254
|
+
source, sep="\t", comment="#", header=None, usecols=[0, 1, 2, 3], names=["chrom", "start", "end", "barcode"],
|
|
255
|
+
dtype={"chrom": str, "start": np.int64, "end": np.int64, "barcode": str}, chunksize=4_000_000,
|
|
256
|
+
compression="gzip" if str(path).endswith(".gz") else None,
|
|
257
|
+
)
|
|
258
|
+
seen: list[str] = []
|
|
259
|
+
n_used = 0
|
|
260
|
+
try:
|
|
261
|
+
n_used = _consume(reader, ins, bc, rows_of_run, codes, path, seen)
|
|
262
|
+
finally:
|
|
263
|
+
if proc is not None:
|
|
264
|
+
err = proc.stderr.read().decode(errors="replace").strip() if proc.stderr else ""
|
|
265
|
+
code = proc.wait()
|
|
266
|
+
if code != 0:
|
|
267
|
+
raise ValueError(f"tracks: reading {path} over ssh failed (exit {code}): {err[-500:]}")
|
|
268
|
+
ins.flush()
|
|
269
|
+
return n_used, len(seen)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _consume(reader, ins: _Insertions, bc: pd.Index, rows_of_run: np.ndarray, codes: dict[str, np.ndarray], path: Path | str, seen: list[str]) -> int:
|
|
273
|
+
n_used = 0
|
|
274
|
+
for chunk in reader:
|
|
275
|
+
idx = bc.get_indexer(chunk["barcode"].to_numpy())
|
|
276
|
+
keep = idx >= 0
|
|
277
|
+
if not keep.any():
|
|
278
|
+
continue
|
|
279
|
+
chunk = chunk[keep].reset_index(drop=True)
|
|
280
|
+
rows = rows_of_run[idx[keep]]
|
|
281
|
+
for chrom, grp in chunk.groupby("chrom", sort=False):
|
|
282
|
+
chrom = str(chrom)
|
|
283
|
+
if chrom not in ins.sizes:
|
|
284
|
+
continue
|
|
285
|
+
if chrom != ins.cur:
|
|
286
|
+
if chrom in seen:
|
|
287
|
+
raise ValueError(f"tracks: {path} is not sorted by chromosome ({chrom} appears twice)")
|
|
288
|
+
ins.start(chrom)
|
|
289
|
+
seen.append(chrom)
|
|
290
|
+
r = rows[grp.index.to_numpy()]
|
|
291
|
+
bp = np.concatenate([grp["start"].to_numpy(), grp["end"].to_numpy() - 1]) # both Tn5 insertion sites
|
|
292
|
+
ins.add(bp, np.concatenate([r, r]), codes)
|
|
293
|
+
n_used += len(grp)
|
|
294
|
+
return n_used
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _tracks_from_fragments(
|
|
298
|
+
pybigwig,
|
|
299
|
+
spec: TracksSpec,
|
|
300
|
+
tdir: Path,
|
|
301
|
+
sizes: dict[str, int],
|
|
302
|
+
bin_: int,
|
|
303
|
+
*,
|
|
304
|
+
ids: list[str],
|
|
305
|
+
barcodes: list[str] | None,
|
|
306
|
+
runs: list[str] | None,
|
|
307
|
+
cats: dict[str, np.ndarray],
|
|
308
|
+
categories: dict[str, list[str]],
|
|
309
|
+
fields: list[str],
|
|
310
|
+
tss: dict[str, np.ndarray] | None,
|
|
311
|
+
log: Log,
|
|
312
|
+
) -> tuple[str, list[TrackSet]]:
|
|
313
|
+
codes = {f: np.asarray(cats[f], dtype=np.int64) for f in fields}
|
|
314
|
+
ncat = {f: len(categories[f]) for f in fields}
|
|
315
|
+
ins = _Insertions(sizes, bin_, fields, ncat, tss=tss if spec.normalize == "tss" else None, window=spec.tss_window)
|
|
316
|
+
given = np.asarray([str(b) for b in barcodes], dtype=object) if barcodes is not None else None
|
|
317
|
+
run_of = np.asarray([str(r) for r in runs], dtype=object) if runs is not None else None
|
|
318
|
+
covered = np.zeros(len(ids), dtype=bool) # observations whose run has a fragment file (what `cells` divides by)
|
|
319
|
+
for run, path in spec.fragment_files:
|
|
320
|
+
if run is None:
|
|
321
|
+
rows_of_run = np.arange(len(ids))
|
|
322
|
+
names = given if given is not None else np.asarray([str(i) for i in ids], dtype=object)
|
|
323
|
+
else:
|
|
324
|
+
rows_of_run = np.flatnonzero(run_of == run) if run_of is not None else np.zeros(0, dtype=np.int64)
|
|
325
|
+
if not len(rows_of_run):
|
|
326
|
+
have = sorted(set(run_of.tolist())) if run_of is not None else []
|
|
327
|
+
raise ValueError(f"tracks: no observation has {spec.sample_column} == {run!r} (values: {have[:8]})")
|
|
328
|
+
names = given[rows_of_run] if given is not None else np.asarray([_bare_barcode(str(ids[i]), run) for i in rows_of_run], dtype=object)
|
|
329
|
+
covered[rows_of_run] = True
|
|
330
|
+
bc = pd.Index(names)
|
|
331
|
+
if bc.has_duplicates:
|
|
332
|
+
raise ValueError(f"tracks: fragment barcodes are not unique per observation{f' in run {run}' if run else ''}")
|
|
333
|
+
where = f" over ssh from {split_ssh_url(str(path))[0]}" if is_remote(path) else ""
|
|
334
|
+
log(f" tracks: streaming {_file_label(path)}{f' ({run})' if run else ''}{where} ({', '.join(fields)}; {bin_} bp bins)")
|
|
335
|
+
n_used, n_chroms = _stream(path, ins, bc, rows_of_run, codes)
|
|
336
|
+
log(f" tracks: {n_used:,} fragments from {len(bc):,} barcodes over {n_chroms} chromosomes")
|
|
337
|
+
if n_used == 0:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"tracks: no fragment barcodes matched the observation names{f' of run {run}' if run else ''} ({_file_label(path)}); "
|
|
340
|
+
"set `barcode_column:` to the obs column that holds them"
|
|
341
|
+
)
|
|
342
|
+
def scale(total: int, at_tss: int, n_cells: int) -> float | None:
|
|
343
|
+
"""What divides a group's counts: None when the group has nothing to scale by (no track is written)."""
|
|
344
|
+
if spec.normalize == "tss":
|
|
345
|
+
return 1e6 / at_tss if at_tss > 0 else None
|
|
346
|
+
if spec.normalize == "cells":
|
|
347
|
+
return 1.0 / n_cells if n_cells > 0 else None
|
|
348
|
+
return 1e6 / total if total > 0 else None
|
|
349
|
+
|
|
350
|
+
n_cells = {f: np.bincount(codes[f][covered], minlength=ncat[f]) for f in fields}
|
|
351
|
+
if spec.normalize == "tss":
|
|
352
|
+
log(f" tracks: scaling by insertions within {spec.tss_window} bp of a TSS ({ins.tss_total_all:,} of {ins.total_all:,})")
|
|
353
|
+
s_all = scale(ins.total_all, ins.tss_total_all, int(covered.sum()))
|
|
354
|
+
if s_all is None:
|
|
355
|
+
raise ValueError("tracks: nothing to scale the tracks by (no insertions at TSSs?); check `genes:` or use normalize: nfrags")
|
|
356
|
+
_write_bigwig(pybigwig, tdir / "all.bw", sizes, ins.all, bin_, s_all)
|
|
357
|
+
by: list[TrackSet] = []
|
|
358
|
+
for f in fields:
|
|
359
|
+
fdir = tdir / f
|
|
360
|
+
fdir.mkdir()
|
|
361
|
+
paths: dict[str, str] = {}
|
|
362
|
+
used: set[str] = set()
|
|
363
|
+
skipped: list[str] = []
|
|
364
|
+
for k, label in enumerate(categories[f]):
|
|
365
|
+
if ins.totals[f][k] == 0 or label == "NA": # unlabelled cells are not a group anyone wants a track for
|
|
366
|
+
continue
|
|
367
|
+
sk = scale(int(ins.totals[f][k]), int(ins.tss_totals[f][k]), int(n_cells[f][k]))
|
|
368
|
+
if sk is None:
|
|
369
|
+
skipped.append(label)
|
|
370
|
+
continue
|
|
371
|
+
p = fdir / f"{_unique_slug(label, used)}.bw"
|
|
372
|
+
_write_bigwig(pybigwig, p, sizes, ins.by[f][k], bin_, sk)
|
|
373
|
+
paths[label] = f"tracks/{f}/{p.name}"
|
|
374
|
+
by.append(TrackSet(field=f, categories=paths))
|
|
375
|
+
log(f" tracks: {f}: {len(paths)} categories" + (f" (no TSS insertions for {', '.join(skipped)})" if skipped else ""))
|
|
376
|
+
return "tracks/all.bw", by
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _detect_bin(bw) -> int:
|
|
380
|
+
"""The bin size of an exported bigWig: the greatest common divisor of its interval widths on the first chromosome
|
|
381
|
+
with data (fixed tiles give the tile size; bamCoverage merges equal neighbours, so widths are multiples of it)."""
|
|
382
|
+
for chrom, size in bw.chroms().items():
|
|
383
|
+
iv = bw.intervals(chrom, 0, min(int(size), 5_000_000))
|
|
384
|
+
if iv:
|
|
385
|
+
widths = np.fromiter((e - s for s, e, _ in iv), dtype=np.int64, count=len(iv))
|
|
386
|
+
return max(int(np.gcd.reduce(widths)), 1)
|
|
387
|
+
return DEFAULT_BIN
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _bigwig_for(src: str | dict, label: str, aliases: dict[str, str]) -> Path | None:
|
|
391
|
+
"""The exported file for a category: by its label, else by any raw label the vocabulary maps onto it."""
|
|
392
|
+
names = [label] + [raw for raw, canon in aliases.items() if canon == label and raw != label]
|
|
393
|
+
for name in names:
|
|
394
|
+
p = Path(src[name]) if isinstance(src, dict) and name in src else Path(str(src).replace("{label}", name)) if not isinstance(src, dict) else None
|
|
395
|
+
if p is not None and p.is_file():
|
|
396
|
+
return p
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _copy_bigwigs(
|
|
401
|
+
pybigwig, spec: TracksSpec, tdir: Path, fields: list[str], categories: dict[str, list[str]], aliases: dict[str, dict[str, str]], log: Log
|
|
402
|
+
) -> tuple[dict[str, int], int, str | None, list[TrackSet]]:
|
|
403
|
+
"""Copy exported per-category bigWigs into the sample; (chromosome sizes, bin, all path, track sets)."""
|
|
404
|
+
assert spec.bigwigs is not None
|
|
405
|
+
first: Path | None = None
|
|
406
|
+
|
|
407
|
+
def take(src: Path, dst: Path) -> None:
|
|
408
|
+
nonlocal first
|
|
409
|
+
bw = pybigwig.open(str(src))
|
|
410
|
+
if bw is None or not bw.isBigWig():
|
|
411
|
+
raise ValueError(f"tracks.bigwigs: {src} is not a bigWig")
|
|
412
|
+
bw.close()
|
|
413
|
+
shutil.copy2(src, dst)
|
|
414
|
+
first = first or dst
|
|
415
|
+
|
|
416
|
+
by: list[TrackSet] = []
|
|
417
|
+
for f in fields:
|
|
418
|
+
src = spec.bigwigs.by[f]
|
|
419
|
+
fdir = tdir / f
|
|
420
|
+
fdir.mkdir()
|
|
421
|
+
paths: dict[str, str] = {}
|
|
422
|
+
used: set[str] = set()
|
|
423
|
+
missing: list[str] = []
|
|
424
|
+
for label in categories[f]:
|
|
425
|
+
if label == "NA":
|
|
426
|
+
continue
|
|
427
|
+
p = _bigwig_for(src, label, aliases.get(f, {}))
|
|
428
|
+
if p is None:
|
|
429
|
+
missing.append(label)
|
|
430
|
+
continue
|
|
431
|
+
dst = fdir / f"{_unique_slug(label, used)}.bw"
|
|
432
|
+
take(p, dst)
|
|
433
|
+
paths[label] = f"tracks/{f}/{dst.name}"
|
|
434
|
+
if not paths:
|
|
435
|
+
raise ValueError(f"tracks.bigwigs: no file matched a category of {f!r} ({src}); labels tried: {missing[:6]}")
|
|
436
|
+
by.append(TrackSet(field=f, categories=paths))
|
|
437
|
+
log(f" tracks: {f}: {len(paths)} bigWigs copied" + (f", no file for {', '.join(missing)}" if missing else ""))
|
|
438
|
+
all_path = None
|
|
439
|
+
if spec.bigwigs.all is not None:
|
|
440
|
+
take(Path(spec.bigwigs.all), tdir / "all.bw")
|
|
441
|
+
all_path = "tracks/all.bw"
|
|
442
|
+
assert first is not None
|
|
443
|
+
bw = pybigwig.open(str(first))
|
|
444
|
+
chroms = {str(c): int(n) for c, n in bw.chroms().items()}
|
|
445
|
+
sizes = chrom_sizes(spec, default=chroms)
|
|
446
|
+
common = {c: n for c, n in sizes.items() if c in chroms}
|
|
447
|
+
if not common:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"tracks.bigwigs: chromosome names in {first.name} ({', '.join(list(chroms)[:4])}, ...) do not match "
|
|
450
|
+
f"{spec.genome}'s ({', '.join(list(sizes)[:4])}, ...); set `chrom_sizes:` to theirs"
|
|
451
|
+
)
|
|
452
|
+
if len(common) < len(sizes):
|
|
453
|
+
log(f" tracks: chromosomes without data left out: {', '.join(c for c in sizes if c not in common)}")
|
|
454
|
+
bin_ = spec.bin or _detect_bin(bw)
|
|
455
|
+
bw.close()
|
|
456
|
+
return common, bin_, all_path, by
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _write_annotations(
|
|
460
|
+
spec: TracksSpec, tdir: Path, sizes: dict[str, int], feature_names: list[str], log: Log, gm: tuple[dict[str, list[GeneModel]], str] | None = None
|
|
461
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
462
|
+
"""peaks.bed from the sample's features, gene models (`gm` when already computed), the links file; (peaks, genes, links) paths."""
|
|
463
|
+
peaks_path = None
|
|
464
|
+
peak_rows = []
|
|
465
|
+
for name in feature_names:
|
|
466
|
+
m = PEAK_RE.match(name)
|
|
467
|
+
if m:
|
|
468
|
+
chrom = m.group(1) if m.group(1).startswith("chr") else f"chr{m.group(1)}"
|
|
469
|
+
peak_rows.append((chrom, int(m.group(2)), int(m.group(3)), name))
|
|
470
|
+
if peak_rows:
|
|
471
|
+
order = {c: i for i, c in enumerate(sizes)}
|
|
472
|
+
peak_rows.sort(key=lambda r: (order.get(r[0], 1e9), r[1]))
|
|
473
|
+
with open(tdir / "peaks.bed", "w") as fh:
|
|
474
|
+
fh.writelines(f"{c}\t{s}\t{e}\t{n}\n" for c, s, e, n in peak_rows)
|
|
475
|
+
peaks_path = "tracks/peaks.bed"
|
|
476
|
+
genes_path = links_path = None
|
|
477
|
+
if gm is None:
|
|
478
|
+
gm = gene_models_for(spec.genes, spec.genome, sizes, log=log)
|
|
479
|
+
if gm is not None:
|
|
480
|
+
models, source = gm
|
|
481
|
+
n_genes = write_gene_models(models, tdir / "genes", source=source, order=list(sizes))
|
|
482
|
+
log(f" tracks: gene models: {n_genes:,} genes from {source}")
|
|
483
|
+
genes_path = "tracks/genes"
|
|
484
|
+
if spec.links is not None:
|
|
485
|
+
shutil.copy2(spec.links, tdir / "links.bedpe")
|
|
486
|
+
links_path = "tracks/links.bedpe"
|
|
487
|
+
return peaks_path, genes_path, links_path
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def write_tracks(
|
|
491
|
+
spec: TracksSpec,
|
|
492
|
+
out_dir: Path,
|
|
493
|
+
*,
|
|
494
|
+
ids: list[str],
|
|
495
|
+
barcodes: list[str] | None,
|
|
496
|
+
runs: list[str] | None = None,
|
|
497
|
+
cats: dict[str, np.ndarray],
|
|
498
|
+
categories: dict[str, list[str]],
|
|
499
|
+
aliases: dict[str, dict[str, str]] | None = None,
|
|
500
|
+
feature_names: list[str],
|
|
501
|
+
log: Log = print,
|
|
502
|
+
) -> TracksInfo:
|
|
503
|
+
"""Build the track files for one sample.
|
|
504
|
+
|
|
505
|
+
ids: observation names in bundle order; barcodes: the fragment-file barcode per observation (default: ids, minus
|
|
506
|
+
a run prefix or suffix when there are several runs); runs: the run per observation (`sample_column`);
|
|
507
|
+
cats: field id -> category codes in bundle order; categories: field id -> label per code; aliases: field id ->
|
|
508
|
+
raw label -> label (exported bigWigs may be named by raw labels); feature_names: the sample's features (peaks
|
|
509
|
+
are recognised by name).
|
|
510
|
+
"""
|
|
511
|
+
pybigwig = _pybigwig()
|
|
512
|
+
tdir = Path(out_dir) / "tracks"
|
|
513
|
+
if tdir.exists():
|
|
514
|
+
shutil.rmtree(tdir)
|
|
515
|
+
tdir.mkdir(parents=True)
|
|
516
|
+
fields = [f for f in spec.by if f in cats]
|
|
517
|
+
missing = [f for f in spec.by if f not in cats]
|
|
518
|
+
if missing:
|
|
519
|
+
raise KeyError(f"tracks.by: field(s) {missing} are not categorical fields of this sample")
|
|
520
|
+
gm = None
|
|
521
|
+
if spec.bigwigs is not None:
|
|
522
|
+
sizes, bin_, all_path, by = _copy_bigwigs(pybigwig, spec, tdir, fields, categories, aliases or {}, log)
|
|
523
|
+
units = spec.units or "signal"
|
|
524
|
+
else:
|
|
525
|
+
sizes = chrom_sizes(spec)
|
|
526
|
+
bin_ = spec.bin or DEFAULT_BIN
|
|
527
|
+
tss = None
|
|
528
|
+
if spec.normalize == "tss": # the gene models are needed before the fragments are read
|
|
529
|
+
gm = gene_models_for(spec.genes, spec.genome, sizes, log=log)
|
|
530
|
+
if gm is None:
|
|
531
|
+
raise ValueError("tracks: `normalize: tss` needs gene models; set `genes:` to a GTF, genePred or BED (or use nfrags / cells)")
|
|
532
|
+
tss = tss_positions(gm[0])
|
|
533
|
+
all_path, by = _tracks_from_fragments(
|
|
534
|
+
pybigwig, spec, tdir, sizes, bin_, ids=ids, barcodes=barcodes, runs=runs, cats=cats, categories=categories, fields=fields, tss=tss, log=log
|
|
535
|
+
)
|
|
536
|
+
units = spec.units or UNITS[spec.normalize]
|
|
537
|
+
peaks_path, genes_path, links_path = _write_annotations(spec, tdir, sizes, feature_names, log, gm)
|
|
538
|
+
with open(tdir / "chrom.sizes", "w") as fh:
|
|
539
|
+
fh.writelines(f"{c}\t{n}\n" for c, n in sizes.items())
|
|
540
|
+
return TracksInfo(
|
|
541
|
+
genome=spec.genome, bin=bin_, all=all_path, units=units, by=by, peaks=peaks_path, genes=genes_path, links=links_path, chromSizes="tracks/chrom.sizes"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def write_fragments(path: Path, rows: list[tuple[str, int, int, str]]) -> Path:
|
|
546
|
+
"""A small coordinate-sorted fragments.tsv.gz (tests and the synthetic dataset)."""
|
|
547
|
+
rows = sorted(rows, key=lambda r: (r[0], r[1]))
|
|
548
|
+
with gzip.open(path, "wt") as fh:
|
|
549
|
+
fh.write("# synthetic fragments\n")
|
|
550
|
+
fh.writelines(f"{c}\t{s}\t{e}\t{b}\t1\n" for c, s, e, b in rows)
|
|
551
|
+
return path
|