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.
Files changed (58) hide show
  1. spatialscape/__init__.py +13 -0
  2. spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
  3. spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
  4. spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
  5. spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
  6. spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
  7. spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
  8. spatialscape/_app/assets/expression-E359vKZW.js +368 -0
  9. spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
  10. spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
  11. spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
  12. spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
  13. spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
  14. spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
  15. spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
  16. spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
  17. spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
  18. spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
  19. spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
  20. spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
  21. spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
  22. spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
  23. spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
  24. spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
  25. spatialscape/_app/favicon.svg +161 -0
  26. spatialscape/_app/index.html +23 -0
  27. spatialscape/build.py +630 -0
  28. spatialscape/cli.py +399 -0
  29. spatialscape/config.py +570 -0
  30. spatialscape/convert.py +321 -0
  31. spatialscape/discover.py +98 -0
  32. spatialscape/expression.py +300 -0
  33. spatialscape/fingerprint.py +152 -0
  34. spatialscape/genes.py +251 -0
  35. spatialscape/geometry.py +132 -0
  36. spatialscape/images.py +240 -0
  37. spatialscape/init.py +578 -0
  38. spatialscape/manifest.py +162 -0
  39. spatialscape/obs.py +48 -0
  40. spatialscape/outlines.py +145 -0
  41. spatialscape/polygons.py +85 -0
  42. spatialscape/rds.py +609 -0
  43. spatialscape/rdsread.py +556 -0
  44. spatialscape/readers.py +195 -0
  45. spatialscape/registry.py +83 -0
  46. spatialscape/serve.py +262 -0
  47. spatialscape/session.py +474 -0
  48. spatialscape/site.py +274 -0
  49. spatialscape/synthetic.py +142 -0
  50. spatialscape/thumbnail.py +95 -0
  51. spatialscape/tracks.py +551 -0
  52. spatialscape/validate.py +193 -0
  53. spatialscape/vocab.py +188 -0
  54. spatialscape-0.3.0.dist-info/METADATA +68 -0
  55. spatialscape-0.3.0.dist-info/RECORD +58 -0
  56. spatialscape-0.3.0.dist-info/WHEEL +4 -0
  57. spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
  58. 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