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/config.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Input schema: the dataset.yaml a contributor writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob as _glob
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
|
13
|
+
|
|
14
|
+
from .manifest import LayoutMode, Platform, SampleKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Model(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MicronsSpec(_Model):
|
|
22
|
+
"""How to convert the coordinate units of `coords` into microns.
|
|
23
|
+
|
|
24
|
+
Exactly one of the explicit options may be set. With none set, `auto` is used:
|
|
25
|
+
xenium/merfish -> already microns; visium -> uns["spot_nn_spacing_level0_px"] or
|
|
26
|
+
uns["spatial"][...]["scalefactors"]; embedding -> rescaled to `extent`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
already_microns: bool = False
|
|
30
|
+
microns_per_unit: float | None = None
|
|
31
|
+
spot_diameter_fullres: float | None = None # Visium: 55 um spot diameter in coord units
|
|
32
|
+
spot_spacing: float | None = None # Visium: 100 um center-to-center in coord units
|
|
33
|
+
scalefactors_json: Path | None = None # SpaceRanger scalefactors_json.json (fullres coords)
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def _one_of(self):
|
|
37
|
+
n = sum(
|
|
38
|
+
[
|
|
39
|
+
self.already_microns,
|
|
40
|
+
self.microns_per_unit is not None,
|
|
41
|
+
self.spot_diameter_fullres is not None,
|
|
42
|
+
self.spot_spacing is not None,
|
|
43
|
+
self.scalefactors_json is not None,
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
if n > 1:
|
|
47
|
+
raise ValueError("microns: set at most one option")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TransformSpec(_Model):
|
|
52
|
+
flip: Literal["none", "x", "y"] = "none"
|
|
53
|
+
rotate: Literal[0, 90, 180, 270] = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExpressionSpec(_Model):
|
|
57
|
+
layer: str = "X"
|
|
58
|
+
normalized: Literal["auto", "lognorm", "counts"] = "auto"
|
|
59
|
+
keep_f16: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ImageChannelSpec(_Model):
|
|
63
|
+
name: str
|
|
64
|
+
color: str = "#ffffff"
|
|
65
|
+
window: tuple[float, float] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ImageSpec(_Model):
|
|
69
|
+
id: str = "image"
|
|
70
|
+
name: str = "Image"
|
|
71
|
+
path: Path
|
|
72
|
+
kind: Literal["rgb", "multichannel"] = "rgb"
|
|
73
|
+
# um per level-0 pixel; "auto" means the image shares the coordinate frame of `coords`
|
|
74
|
+
pixel_size: float | Literal["auto"] = "auto"
|
|
75
|
+
# image pixels per coordinate unit (e.g. tissue_hires_scalef when coords are fullres px)
|
|
76
|
+
pixels_per_unit: float = 1.0
|
|
77
|
+
translate: tuple[float, float] = (0.0, 0.0) # um, applied after everything else
|
|
78
|
+
channels: list[ImageChannelSpec] | None = None
|
|
79
|
+
default_opacity: float = 1.0
|
|
80
|
+
max_size: int | None = None # optionally downsample level 0 to at most this many px per side
|
|
81
|
+
crop: bool = True # keep only the part of the image around the points (3% margin); slide scans reach far past the capture area
|
|
82
|
+
mask: bool = False # blank the image beyond `mask_margin` of the sample's own points: for one slide image that several sections share
|
|
83
|
+
mask_margin: float = 150.0 # um
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PolygonSpec(_Model):
|
|
87
|
+
"""Per-cell boundary polygons, from a long-format parquet file (`path`: one row per vertex) or from an
|
|
88
|
+
`obsm` array of shape (n_obs, n_vertices, 2) inside the object. Vertex coordinates share the frame of
|
|
89
|
+
`coords`; set `affine` when the boundary file is in a different frame (e.g. the object's coordinates were
|
|
90
|
+
transposed after segmentation)."""
|
|
91
|
+
|
|
92
|
+
path: Path | None = None
|
|
93
|
+
obsm: str | None = None
|
|
94
|
+
id_column: str = "cell_id"
|
|
95
|
+
x_column: str = "vertex_x"
|
|
96
|
+
y_column: str = "vertex_y"
|
|
97
|
+
max_vertices: int = 24
|
|
98
|
+
# (a, b, c, d, e, f): x' = a*x + b*y + c ; y' = d*x + e*y + f, applied to the raw vertices first.
|
|
99
|
+
# transpose = [0, 1, 0, 1, 0, 0]; anti-transpose with extents = [0, -1, Ymax, -1, 0, Xmax].
|
|
100
|
+
affine: tuple[float, float, float, float, float, float] | None = None
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="after")
|
|
103
|
+
def _one_source(self):
|
|
104
|
+
if (self.path is None) == (self.obsm is None):
|
|
105
|
+
raise ValueError("polygons: set exactly one of `path` or `obsm`")
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_remote(p: object) -> bool:
|
|
110
|
+
"""`ssh://host/path`: read over ssh at build time, nothing stored locally."""
|
|
111
|
+
return isinstance(p, str) and p.startswith("ssh://")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _file_or_url(v: str | Path) -> Path | str:
|
|
115
|
+
return v if is_remote(v) else Path(v)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BigWigsSpec(_Model):
|
|
119
|
+
"""Tracks another tool exported (ArchR `getGroupBW`, Signac `ExportGroupBW`, deepTools `bamCoverage` per group):
|
|
120
|
+
one bigWig per category of each field, named by a path template with `{label}` or listed label by label."""
|
|
121
|
+
|
|
122
|
+
by: dict[str, str | dict[str, Path]] # field id -> "GroupBigWigs/{label}.bw" or {category label: path}
|
|
123
|
+
all: Path | None = None # a bigWig of every cell, when there is one
|
|
124
|
+
|
|
125
|
+
@model_validator(mode="after")
|
|
126
|
+
def _templates(self):
|
|
127
|
+
for f, v in self.by.items():
|
|
128
|
+
if isinstance(v, str) and "{label}" not in v:
|
|
129
|
+
raise ValueError(f"tracks.bigwigs.by.{f}: a path template needs `{{label}}` (or list the files per category label)")
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TracksSpec(_Model):
|
|
134
|
+
"""Genome tracks for an ATAC sample, shown by the viewer's genome panel: pseudobulk accessibility per category of
|
|
135
|
+
each field in `by`. Built from Cell Ranger style fragment files (insertions per million, binned; one file, or
|
|
136
|
+
one per run for an object pooled from several 10x runs) or copied from bigWigs another tool exported, plus the
|
|
137
|
+
sample's peaks and optional gene models and peak-gene links. Needs `pip install "spatialscape[tracks]"`."""
|
|
138
|
+
|
|
139
|
+
fragments: str | Path | dict[str, str | Path] | None = None # fragments.tsv.gz, or run id -> file for an object pooled from runs; a file may be `ssh://host/path` (streamed, never stored)
|
|
140
|
+
bigwigs: BigWigsSpec | None = None # tracks that already exist, instead of `fragments`
|
|
141
|
+
by: list[str] = Field(default_factory=list) # categorical field ids to aggregate by (with `bigwigs`: its fields)
|
|
142
|
+
genome: str = "hg38" # assembly id (hg38, mm10, ...): names the RefSeq gene models and the default chromosome sizes
|
|
143
|
+
bin: int | None = Field(default=None, ge=1) # bp per bin: 25 for fragments; read from the files for bigwigs
|
|
144
|
+
normalize: Literal["nfrags", "tss", "cells"] = "nfrags" # per group: per million insertions; per million insertions at gene TSSs (robust to background, ArchR's ReadsInTSS); per nucleus
|
|
145
|
+
tss_window: int = Field(default=50, ge=1) # bp either side of a TSS that count as "at the TSS" for `normalize: tss`
|
|
146
|
+
chrom_sizes: Path | None = None # two-column chrom\tsize file; default: the genome's primary chromosomes
|
|
147
|
+
sample_column: str | None = None # obs column naming the run each observation came from (required with several fragment files)
|
|
148
|
+
barcode_column: str | None = None # obs column holding the fragment-file barcode; default: the observation names, minus a run prefix or suffix
|
|
149
|
+
units: str | None = None # what the tracks measure, for the panel's scale: "insertions per million" for fragments, "signal" for bigwigs
|
|
150
|
+
genes: str | None = "refseq" # gene models: a GTF, UCSC genePred table or BED; `refseq` downloads UCSC's table for `genome` once; `none` for no gene track
|
|
151
|
+
links: Path | None = None # BEDPE of peak-gene links, drawn as arcs
|
|
152
|
+
|
|
153
|
+
@model_validator(mode="after")
|
|
154
|
+
def _one_source(self):
|
|
155
|
+
if (self.fragments is None) == (self.bigwigs is None):
|
|
156
|
+
raise ValueError("tracks: give either `fragments` or `bigwigs`")
|
|
157
|
+
if isinstance(self.fragments, dict):
|
|
158
|
+
if not self.fragments:
|
|
159
|
+
raise ValueError("tracks.fragments: no files")
|
|
160
|
+
if not self.sample_column:
|
|
161
|
+
raise ValueError("tracks: several fragment files need `sample_column`, the obs column naming each observation's run")
|
|
162
|
+
if self.bigwigs is not None:
|
|
163
|
+
extra = [f for f in self.by if f not in self.bigwigs.by]
|
|
164
|
+
if extra:
|
|
165
|
+
raise ValueError(f"tracks.by lists {extra} but `bigwigs.by` has no files for them")
|
|
166
|
+
if not self.by:
|
|
167
|
+
self.by = list(self.bigwigs.by)
|
|
168
|
+
elif not self.by:
|
|
169
|
+
raise ValueError("tracks: `by` names the categorical fields to aggregate by")
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def genes_file(self) -> Path | None:
|
|
174
|
+
"""The gene-model file, or None for the `refseq` / `none` keywords."""
|
|
175
|
+
return None if self.genes is None or self.genes in ("refseq", "none") else Path(self.genes)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def fragment_files(self) -> list[tuple[str | None, Path | str]]:
|
|
179
|
+
"""(run id, file) pairs; the run is None for a single file. A remote file stays an `ssh://host/path` string."""
|
|
180
|
+
if isinstance(self.fragments, dict):
|
|
181
|
+
return [(str(k), _file_or_url(v)) for k, v in self.fragments.items()]
|
|
182
|
+
return [(None, _file_or_url(self.fragments))] if self.fragments is not None else []
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def input_files(self) -> list[Path | str]:
|
|
186
|
+
"""Every file the tracks are built from, for the build fingerprint: fragment files (remote ones as their
|
|
187
|
+
URL), bigWigs (a template is globbed), chromosome sizes, gene models and links."""
|
|
188
|
+
files: list[Path | str | None] = [p for _, p in self.fragment_files] + [self.chrom_sizes, self.genes_file, self.links]
|
|
189
|
+
if self.bigwigs is not None:
|
|
190
|
+
files.append(self.bigwigs.all)
|
|
191
|
+
for v in self.bigwigs.by.values():
|
|
192
|
+
if isinstance(v, dict):
|
|
193
|
+
files.extend(v.values())
|
|
194
|
+
else:
|
|
195
|
+
files.extend(sorted(_glob.glob(str(v).replace("{label}", "*"))))
|
|
196
|
+
return [f if is_remote(f) else Path(f) for f in files if f is not None]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class SubsetSpec(_Model):
|
|
200
|
+
"""Keep only the observations whose `column` takes one of `values`, so one object can appear as several
|
|
201
|
+
samples (one panel per broad cell class, one per condition, ...)."""
|
|
202
|
+
|
|
203
|
+
column: str
|
|
204
|
+
values: list[str]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class FieldMap(_Model):
|
|
208
|
+
"""Long form of a field mapping: `{column, scale}`. `scale` multiplies a numeric column (e.g. 100 to turn a
|
|
209
|
+
ratio into a percent) so one field can be shared by samples that store the same quantity in different units."""
|
|
210
|
+
|
|
211
|
+
column: str
|
|
212
|
+
scale: float = 1.0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class SampleSpec(_Model):
|
|
216
|
+
"""One sample. In dataset.yaml an entry may instead carry `glob:` and templated strings
|
|
217
|
+
("{name}", "{stem}", "{dir}", "{i}") that expand into one SampleSpec per match; `platforms.<platform>`
|
|
218
|
+
and `defaults` blocks fill in keys the entry omits."""
|
|
219
|
+
|
|
220
|
+
id: str
|
|
221
|
+
name: str | None = None
|
|
222
|
+
platform: Platform
|
|
223
|
+
kind: SampleKind = "spatial"
|
|
224
|
+
group: str | None = None
|
|
225
|
+
path: Path
|
|
226
|
+
table: str | None = None # SpatialData table key
|
|
227
|
+
coords: str = "obsm/spatial" # "obsm/<key>" or "obs/<xcol>,obs/<ycol>"
|
|
228
|
+
expression: ExpressionSpec = Field(default_factory=ExpressionSpec)
|
|
229
|
+
microns: MicronsSpec = Field(default_factory=MicronsSpec)
|
|
230
|
+
transform: TransformSpec = Field(default_factory=TransformSpec)
|
|
231
|
+
# field id -> obs column (or {column, scale}), or "auto" to expose every categorical (<= 200 levels) and
|
|
232
|
+
# QC-like numeric column. Long-form entries are normalized into `fields` + `field_scales`.
|
|
233
|
+
fields: dict[str, str] | Literal["auto"] = Field(default_factory=dict)
|
|
234
|
+
field_scales: dict[str, float] = Field(default_factory=dict)
|
|
235
|
+
# explicit list, or "auto" to pick up image.ome.zarr / *.ome.zarr / spatial/tissue_hires_image.png next to the data
|
|
236
|
+
images: list[ImageSpec] | Literal["auto"] = "auto"
|
|
237
|
+
polygons: PolygonSpec | None = None
|
|
238
|
+
subset: SubsetSpec | None = None # rows to keep; the default is every observation in the file
|
|
239
|
+
tracks: TracksSpec | None = None # genome tracks from a fragment file (ATAC)
|
|
240
|
+
extent: float = 5000.0 # embedding samples: rescale longest side to this many "um"
|
|
241
|
+
point_radius: float | None = None # um; default depends on platform
|
|
242
|
+
seed: int = 7
|
|
243
|
+
gene_column: str | None = None # var column holding symbols; default var_names
|
|
244
|
+
|
|
245
|
+
@model_validator(mode="before")
|
|
246
|
+
@classmethod
|
|
247
|
+
def _normalize_field_maps(cls, data):
|
|
248
|
+
if not isinstance(data, dict) or not isinstance(data.get("fields"), dict):
|
|
249
|
+
return data
|
|
250
|
+
fields: dict[str, str] = {}
|
|
251
|
+
scales: dict[str, float] = dict(data.get("field_scales") or {})
|
|
252
|
+
for fid, v in data["fields"].items():
|
|
253
|
+
if isinstance(v, dict):
|
|
254
|
+
fm = FieldMap.model_validate(v)
|
|
255
|
+
fields[fid] = fm.column
|
|
256
|
+
if fm.scale != 1.0:
|
|
257
|
+
scales[fid] = fm.scale
|
|
258
|
+
else:
|
|
259
|
+
fields[fid] = v
|
|
260
|
+
return {**data, "fields": fields, "field_scales": scales}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class FieldSpec(_Model):
|
|
264
|
+
id: str
|
|
265
|
+
name: str | None = None
|
|
266
|
+
type: Literal["categorical", "continuous"] = "categorical"
|
|
267
|
+
vocabulary: str | None = None # defaults to id
|
|
268
|
+
categories: list[str] | None = None # explicit order; others appended first-seen
|
|
269
|
+
aliases: dict[str, str] = Field(default_factory=dict)
|
|
270
|
+
palette_key: str | None = None # key inside the palette file; defaults to vocabulary
|
|
271
|
+
colormap: str | None = None
|
|
272
|
+
range: tuple[float, float] | None = None
|
|
273
|
+
description: str | None = None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class FeatureGroupSpec(_Model):
|
|
277
|
+
"""Features whose var names match `pattern` are listed under their own tab instead of the gene list."""
|
|
278
|
+
|
|
279
|
+
id: str
|
|
280
|
+
name: str
|
|
281
|
+
pattern: str # regex searched in var names, e.g. "^RCTD: "
|
|
282
|
+
strip: str | None = None # regex removed from the label; defaults to pattern
|
|
283
|
+
units: str | None = None # e.g. "weight"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class LayoutSpec(_Model):
|
|
287
|
+
mode: LayoutMode = "grid"
|
|
288
|
+
order: list[str] | None = None
|
|
289
|
+
gutter_fraction: float = 0.1
|
|
290
|
+
columns: int | None = Field(default=None, ge=1) # grid: a fixed number of columns (default: fitted to the window)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class DefaultColorSpec(_Model):
|
|
294
|
+
gene: str | None = None
|
|
295
|
+
field: str | None = None
|
|
296
|
+
|
|
297
|
+
@model_validator(mode="after")
|
|
298
|
+
def _one(self):
|
|
299
|
+
if (self.gene is None) == (self.field is None):
|
|
300
|
+
raise ValueError("default_color: set exactly one of gene / field")
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
R_SUFFIXES = (".rds", ".rda", ".rdata")
|
|
305
|
+
R_ENTRY_KEYS = ("sample_col", "assay", "embedding", "object")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def is_r_object(path: Any) -> bool:
|
|
309
|
+
return isinstance(path, (str, Path)) and str(path).lower().endswith(R_SUFFIXES)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class RObjectEntry(_Model):
|
|
313
|
+
"""A `samples:` entry whose `path` is an R object (`.rds`, `.rda`, `.RData`) holding a SpatialExperiment,
|
|
314
|
+
SingleCellExperiment or Seurat object. `build` converts it once into a cache next to the bundle and expands it
|
|
315
|
+
into one sample per `sample_col` value (objects with spatialCoords) or one embedding sample (objects without).
|
|
316
|
+
Every other key of the entry (platform, templated id / name / group, fields, microns, ...) applies to those
|
|
317
|
+
samples. For a Seurat object `assay` names a layer of the gene assay (`data`, `counts`; `logcounts` means
|
|
318
|
+
`data`), and its other assays become prefixed features (`peak: `, `activity: `, `motif: `). `overrides:`
|
|
319
|
+
(`{<sample>: {...}}`, kept in `template`) changes single samples: `{ Br9902_1: { images: [] } }`."""
|
|
320
|
+
|
|
321
|
+
path: Path
|
|
322
|
+
sample_col: str = "sample_id"
|
|
323
|
+
assay: str = "logcounts"
|
|
324
|
+
embedding: str | None = None # reducedDims entry / Seurat reduction for objects without spatialCoords; default: a UMAP
|
|
325
|
+
object: str | None = None # which object to take from an .rda that holds several
|
|
326
|
+
template: dict[str, Any] = Field(default_factory=dict) # the rest of the entry, applied to the expanded samples
|
|
327
|
+
index: int = 0 # position in the yaml's `samples:` list
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class DatasetConfig(_Model):
|
|
331
|
+
"""Top-level dataset.yaml. `samples` are expanded (globs, templates, defaults) by `load_config`."""
|
|
332
|
+
|
|
333
|
+
id: str
|
|
334
|
+
name: str
|
|
335
|
+
description: str | None = None
|
|
336
|
+
default_gene: str | None = None
|
|
337
|
+
default_color: DefaultColorSpec | None = None
|
|
338
|
+
layout: LayoutSpec = Field(default_factory=LayoutSpec)
|
|
339
|
+
palette: Path | None = None
|
|
340
|
+
colormaps: list[str] | None = None
|
|
341
|
+
shard_genes: int = 512
|
|
342
|
+
sharded: bool = True # sharded Zarr (one range request per gene; needs HTTP Range) or one file per gene (GitHub Pages)
|
|
343
|
+
thumbnail_sample: str | None = None # sample shown on the dataset's gallery card (default: first in layout order)
|
|
344
|
+
feature_groups: list[FeatureGroupSpec] = Field(default_factory=list)
|
|
345
|
+
# Optional declarations (display names, explicit category order, aliases, palette keys). Fields that
|
|
346
|
+
# samples reference without a declaration are declared automatically at build time.
|
|
347
|
+
fields: list[FieldSpec] = Field(default_factory=list)
|
|
348
|
+
defaults: dict[str, Any] = Field(default_factory=dict) # merged into every sample entry
|
|
349
|
+
platforms: dict[str, dict[str, Any]] = Field(default_factory=dict) # merged into samples of that platform
|
|
350
|
+
samples: list[SampleSpec]
|
|
351
|
+
|
|
352
|
+
# set by load_config: where the yaml lives, its raw content (for re-expansion) and its R object entries
|
|
353
|
+
_path: Path | None = PrivateAttr(default=None)
|
|
354
|
+
_raw: dict | None = PrivateAttr(default=None)
|
|
355
|
+
_r_objects: list[RObjectEntry] = PrivateAttr(default_factory=list)
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def path(self) -> Path | None:
|
|
359
|
+
return self._path
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def raw(self) -> dict:
|
|
363
|
+
return self._raw or {}
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def r_objects(self) -> list[RObjectEntry]:
|
|
367
|
+
return self._r_objects
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def default_bundle_dir(self) -> Path:
|
|
371
|
+
"""`bundles/<id>` next to the yaml: where `build` writes when `-o` is not given."""
|
|
372
|
+
if self._path is None:
|
|
373
|
+
raise ValueError("this config was not loaded from a file; pass an output directory")
|
|
374
|
+
return self._path.parent / "bundles" / self.id
|
|
375
|
+
|
|
376
|
+
@model_validator(mode="after")
|
|
377
|
+
def _unique_ids(self):
|
|
378
|
+
ids = [s.id for s in self.samples]
|
|
379
|
+
if len(set(ids)) != len(ids):
|
|
380
|
+
raise ValueError(f"sample ids must be unique: {sorted({i for i in ids if ids.count(i) > 1})}")
|
|
381
|
+
fids = [f.id for f in self.fields]
|
|
382
|
+
if len(set(fids)) != len(fids):
|
|
383
|
+
raise ValueError("field ids must be unique")
|
|
384
|
+
return self
|
|
385
|
+
|
|
386
|
+
def field_spec(self, fid: str) -> FieldSpec | None:
|
|
387
|
+
return next((f for f in self.fields if f.id == fid), None)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _resolve_paths(cfg: DatasetConfig, base: Path) -> DatasetConfig:
|
|
391
|
+
"""Paths are relative to the config file; `~` and `${ENV_VAR}` are expanded so configs stay portable."""
|
|
392
|
+
|
|
393
|
+
def res(p: Path | None) -> Path | None:
|
|
394
|
+
if p is None:
|
|
395
|
+
return None
|
|
396
|
+
p = Path(os.path.expandvars(str(p))).expanduser()
|
|
397
|
+
return p if p.is_absolute() else (base / p).resolve()
|
|
398
|
+
|
|
399
|
+
cfg.palette = res(cfg.palette)
|
|
400
|
+
for s in cfg.samples:
|
|
401
|
+
s.path = res(s.path)
|
|
402
|
+
s.microns.scalefactors_json = res(s.microns.scalefactors_json)
|
|
403
|
+
if isinstance(s.images, list):
|
|
404
|
+
for im in s.images:
|
|
405
|
+
im.path = res(im.path)
|
|
406
|
+
if s.polygons and s.polygons.path is not None:
|
|
407
|
+
s.polygons.path = res(s.polygons.path)
|
|
408
|
+
if s.tracks is not None:
|
|
409
|
+
t = s.tracks
|
|
410
|
+
if isinstance(t.fragments, dict):
|
|
411
|
+
t.fragments = {k: os.path.expandvars(v) if is_remote(v) else res(Path(v)) for k, v in t.fragments.items()}
|
|
412
|
+
elif is_remote(t.fragments):
|
|
413
|
+
t.fragments = os.path.expandvars(t.fragments) # ssh://${CLUSTER_USER}@host/... keeps logins out of the yaml
|
|
414
|
+
elif t.fragments is not None:
|
|
415
|
+
t.fragments = res(Path(t.fragments))
|
|
416
|
+
if t.bigwigs is not None:
|
|
417
|
+
t.bigwigs.all = res(t.bigwigs.all)
|
|
418
|
+
t.bigwigs.by = {
|
|
419
|
+
f: str(res(Path(v))) if isinstance(v, str) else {lab: res(pth) for lab, pth in v.items()} for f, v in t.bigwigs.by.items()
|
|
420
|
+
}
|
|
421
|
+
t.chrom_sizes = res(t.chrom_sizes)
|
|
422
|
+
if t.genes_file is not None:
|
|
423
|
+
t.genes = str(res(t.genes_file))
|
|
424
|
+
t.links = res(t.links)
|
|
425
|
+
return cfg
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _deep_merge(base: dict, over: dict) -> dict:
|
|
429
|
+
out = dict(base)
|
|
430
|
+
for k, v in over.items():
|
|
431
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
432
|
+
out[k] = _deep_merge(out[k], v)
|
|
433
|
+
else:
|
|
434
|
+
out[k] = v
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _render(value: Any, vars_: dict[str, str]) -> Any:
|
|
439
|
+
if isinstance(value, str):
|
|
440
|
+
try:
|
|
441
|
+
return value.format(**vars_)
|
|
442
|
+
except (KeyError, IndexError, ValueError):
|
|
443
|
+
return value
|
|
444
|
+
if isinstance(value, list):
|
|
445
|
+
return [_render(v, vars_) for v in value]
|
|
446
|
+
if isinstance(value, dict):
|
|
447
|
+
return {k: _render(v, vars_) for k, v in value.items()}
|
|
448
|
+
return value
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _template_vars(p: Path, i: int) -> dict[str, str]:
|
|
452
|
+
stem = p.name
|
|
453
|
+
for suf in (".h5ad", ".zarr", ".h5"):
|
|
454
|
+
if stem.endswith(suf):
|
|
455
|
+
stem = stem[: -len(suf)]
|
|
456
|
+
return {
|
|
457
|
+
"path": str(p),
|
|
458
|
+
"dir": str(p.parent),
|
|
459
|
+
"name": p.parent.name if p.is_dir() or p.suffix else p.name,
|
|
460
|
+
"stem": stem,
|
|
461
|
+
"i": str(i),
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _merged_entry(entry: dict, raw: dict) -> dict:
|
|
466
|
+
"""`defaults` < `platforms.<platform>` < the entry itself."""
|
|
467
|
+
defaults = raw.get("defaults") or {}
|
|
468
|
+
platforms = raw.get("platforms") or {}
|
|
469
|
+
merged = _deep_merge(defaults, {})
|
|
470
|
+
merged = _deep_merge(merged, platforms.get(entry.get("platform", merged.get("platform", "")), {}))
|
|
471
|
+
return _deep_merge(merged, entry)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def expand_samples(raw: dict, base: Path) -> list[dict]:
|
|
475
|
+
"""Expand glob entries and apply `defaults` / `platforms` blocks. Returns plain dicts for SampleSpec.
|
|
476
|
+
Entries whose `path` is an R object are skipped here; `build` converts and expands them (see convert.py)."""
|
|
477
|
+
out: list[dict] = []
|
|
478
|
+
for entry in raw.get("samples") or []:
|
|
479
|
+
entry = dict(entry)
|
|
480
|
+
pattern = entry.pop("glob", None)
|
|
481
|
+
if pattern is None and is_r_object(entry.get("path")):
|
|
482
|
+
continue
|
|
483
|
+
overrides = _check_overrides(entry.pop("overrides", None))
|
|
484
|
+
if pattern is None and overrides:
|
|
485
|
+
raise ValueError("`overrides` goes with `glob` (or an R object): a single-file entry takes its settings directly")
|
|
486
|
+
if pattern is None:
|
|
487
|
+
matches = [None]
|
|
488
|
+
else:
|
|
489
|
+
pat = str(Path(os.path.expandvars(str(pattern))).expanduser())
|
|
490
|
+
if not Path(pat).is_absolute():
|
|
491
|
+
pat = str(base / pat)
|
|
492
|
+
matches = sorted(_glob.glob(pat, recursive=True))
|
|
493
|
+
if not matches:
|
|
494
|
+
raise ValueError(f"glob matched nothing: {pattern} (paths in dataset.yaml are relative to its folder, {base})")
|
|
495
|
+
names = [_template_vars(Path(m), i)["name"] for i, m in enumerate(matches) if m is not None]
|
|
496
|
+
flat = len(set(names)) < len(names) # samples/A.h5ad, samples/B.h5ad: the folder name is shared, the stem is the sample
|
|
497
|
+
used: set[str] = set()
|
|
498
|
+
for i, m in enumerate(matches):
|
|
499
|
+
e = dict(entry)
|
|
500
|
+
if m is not None:
|
|
501
|
+
e["path"] = m
|
|
502
|
+
vars_ = _template_vars(Path(m), i)
|
|
503
|
+
e = _render(e, vars_)
|
|
504
|
+
e.setdefault("id", re.sub(r"[^A-Za-z0-9_.-]+", "_", vars_["stem" if flat else "name"]))
|
|
505
|
+
# an override is keyed by the sample's `{name}` ({stem} for flat layouts) or by its id
|
|
506
|
+
key = next((k for k in (vars_["stem" if flat else "name"], vars_["name"], vars_["stem"], e["id"]) if k in overrides), None)
|
|
507
|
+
if key is not None:
|
|
508
|
+
used.add(key)
|
|
509
|
+
e = _deep_merge(e, overrides[key])
|
|
510
|
+
merged = _merged_entry(e, raw)
|
|
511
|
+
if m is not None:
|
|
512
|
+
merged = _render(merged, _template_vars(Path(m), i))
|
|
513
|
+
out.append(merged)
|
|
514
|
+
unused = [k for k in overrides if k not in used]
|
|
515
|
+
if unused:
|
|
516
|
+
have = sorted({_template_vars(Path(m), i)["stem" if flat else "name"] for i, m in enumerate(matches) if m is not None})
|
|
517
|
+
raise ValueError(f"overrides name no sample: {', '.join(unused)} (samples of {pattern}: {', '.join(have[:12])}{', ...' if len(have) > 12 else ''})")
|
|
518
|
+
return out
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _check_overrides(value: Any) -> dict[str, dict]:
|
|
522
|
+
"""`overrides: {<sample>: {<sample keys>}}`; the keys are checked when the merged samples are validated."""
|
|
523
|
+
if value is None:
|
|
524
|
+
return {}
|
|
525
|
+
if not isinstance(value, dict) or not all(isinstance(v, dict) for v in value.values()):
|
|
526
|
+
raise ValueError("`overrides` must map sample names to settings, e.g. { Br9902_1: { images: [] } }")
|
|
527
|
+
for k, v in value.items():
|
|
528
|
+
if "glob" in v or "path" in v or "overrides" in v:
|
|
529
|
+
raise ValueError(f"overrides.{k}: `path`, `glob` and `overrides` cannot be overridden")
|
|
530
|
+
return {str(k): v for k, v in value.items()}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def r_object_entries(raw: dict, base: Path) -> list[RObjectEntry]:
|
|
534
|
+
"""The `samples:` entries that point at R objects, with their templates checked against the sample schema."""
|
|
535
|
+
out: list[RObjectEntry] = []
|
|
536
|
+
for i, entry in enumerate(raw.get("samples") or []):
|
|
537
|
+
if not isinstance(entry, dict) or "glob" in entry or not is_r_object(entry.get("path")):
|
|
538
|
+
continue
|
|
539
|
+
entry = dict(entry)
|
|
540
|
+
r_keys = {k: entry.pop(k) for k in R_ENTRY_KEYS if k in entry}
|
|
541
|
+
path = Path(os.path.expandvars(str(entry.pop("path")))).expanduser()
|
|
542
|
+
path = path if path.is_absolute() else (base / path).resolve()
|
|
543
|
+
# validate the template now (platform, fields, microns, ...) so typos surface before a long conversion
|
|
544
|
+
probe = _merged_entry({**entry, "path": "probe/adata.h5ad", "id": "probe"}, raw)
|
|
545
|
+
probe.setdefault("platform", "other")
|
|
546
|
+
_check_overrides(probe.pop("overrides", None))
|
|
547
|
+
SampleSpec.model_validate(_render(probe, _template_vars(Path("probe/adata.h5ad"), 0)))
|
|
548
|
+
out.append(RObjectEntry(path=path, template=entry, index=i, **r_keys))
|
|
549
|
+
return out
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def config_from_raw(raw: dict, path: Path, *, original: dict | None = None) -> DatasetConfig:
|
|
553
|
+
"""Validate an already-parsed yaml document. `path` is the yaml's location (paths resolve against its folder)."""
|
|
554
|
+
base = path.parent
|
|
555
|
+
r_entries = r_object_entries(raw, base)
|
|
556
|
+
cfg = DatasetConfig.model_validate({**raw, "samples": expand_samples(raw, base)})
|
|
557
|
+
cfg = _resolve_paths(cfg, base)
|
|
558
|
+
cfg._path = path
|
|
559
|
+
cfg._raw = original if original is not None else raw
|
|
560
|
+
cfg._r_objects = r_entries
|
|
561
|
+
return cfg
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def load_config(path: str | Path) -> DatasetConfig:
|
|
565
|
+
path = Path(path).expanduser().resolve()
|
|
566
|
+
with open(path) as fh:
|
|
567
|
+
raw = yaml.safe_load(fh)
|
|
568
|
+
if not isinstance(raw, dict):
|
|
569
|
+
raise ValueError(f"{path}: expected a mapping (id, name, samples, ...) at the top level")
|
|
570
|
+
return config_from_raw(dict(raw), path)
|