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/init.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""`spatialscape init`: write a dataset.yaml that doubles as a menu of what the data contains.
|
|
2
|
+
|
|
3
|
+
One `samples:` entry per argument (an R object, or a glob of AnnData files). Inside each entry `fields:` maps
|
|
4
|
+
the columns that look like spatial domains, cell types and the usual QC metrics; every other column is listed
|
|
5
|
+
as a comment with its type, so adding one means uncommenting a line. Dataset settings that are optional are
|
|
6
|
+
present as comments too. The file builds as written.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import glob as _glob
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from .config import is_r_object
|
|
23
|
+
from .discover import SKIP_COLUMNS, field_id
|
|
24
|
+
|
|
25
|
+
Log = Callable[[str], None]
|
|
26
|
+
|
|
27
|
+
PLATFORM_LABEL = {"visium": "Visium", "visium_hd": "Visium HD", "xenium": "Xenium", "merfish": "MERFISH", "snrnaseq": "snRNA-seq", "other": "Sample"}
|
|
28
|
+
PLATFORM_PREFIX = {"visium": "vis", "visium_hd": "hd", "xenium": "xen", "merfish": "mer", "snrnaseq": "sn", "other": "smp"}
|
|
29
|
+
GENERIC_STORE_NAMES = {"adata", "data", "sample", "outs", "table", "sdata", "spe", "sce"}
|
|
30
|
+
FIELD_NAMES = {"domain": "Spatial domain", "celltype": "Cell type", "total_counts": "Total counts", "detected_genes": "Detected genes", "mito_percent": "Mito %"}
|
|
31
|
+
MAX_MENU_LEVELS = 60
|
|
32
|
+
DOMAIN_RE = re.compile(r"domain|cluster|bayesspace|precast|banksy|layer|leiden|louvain|^bs_|_k\d+", re.I)
|
|
33
|
+
CELLTYPE_RE = re.compile(r"cell_?type|first_type|annot|label|class|azimuth", re.I)
|
|
34
|
+
QC_RE = {
|
|
35
|
+
"total_counts": re.compile(r"^(sum_umi|total_counts|total_umi|sum|n_counts|ncount_\w+|transcript_counts|nCount_\w+)$", re.I),
|
|
36
|
+
"detected_genes": re.compile(r"^(sum_gene|detected|detected_genes|n_genes|n_genes_by_counts|nfeature_\w+|nFeature_\w+)$", re.I),
|
|
37
|
+
"mito_percent": re.compile(r"^(expr_chrM_ratio|subsets_Mito_percent|pct_counts_mt|percent\.mt|pct_mt|mito_percent|percent_mito|mt_frac|pct_mito|mito_frac)$", re.I),
|
|
38
|
+
}
|
|
39
|
+
EMBEDDING_PREFERENCE = ("UMAP", "umap", "X_umap", "UMAP-HARMONY", "UMAP_HARMONY", "UMAP.HARMONY")
|
|
40
|
+
ARRAY_COLUMNS = {"array_row", "array_col", "in_tissue"} # SpaceRanger's spot / bin grid
|
|
41
|
+
CELL_COLUMNS = {"transcript_counts", "cell_area", "nucleus_area", "nucleus_count", "segmentation_method"} # Xenium's cells.csv
|
|
42
|
+
SAMPLE_COLUMN_RE = re.compile(r"^(sample_id|sample|sample_name|section|section_id|capture_area|slide|library_id|batch)$", re.I)
|
|
43
|
+
VISIUM_MAX_OBS = 20_000 # a Visium capture area holds 4,992 spots (6.5 mm) or 14,336 (11 mm); more is HD bins or cells
|
|
44
|
+
COORDINATE_NOTE = {
|
|
45
|
+
"visium": "full-resolution pixels; microns from the spot spacing (100 um) when `build` runs",
|
|
46
|
+
"visium_hd": "full-resolution pixels; set `microns: { microns_per_unit: <SpaceRanger microns_per_pixel> }`",
|
|
47
|
+
"xenium": "taken as microns",
|
|
48
|
+
"merfish": "taken as microns",
|
|
49
|
+
"snrnaseq": "the 2-D embedding, rescaled to a fixed extent",
|
|
50
|
+
"other": "taken as microns; set `microns:` otherwise",
|
|
51
|
+
}
|
|
52
|
+
GUIDE = "https://mictott.github.io/spatialscape/docs/guide/changes"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- what the columns are
|
|
56
|
+
@dataclass
|
|
57
|
+
class Column:
|
|
58
|
+
name: str
|
|
59
|
+
kind: str # categorical | numeric | boolean | text | empty
|
|
60
|
+
levels: int = 0
|
|
61
|
+
examples: list[str] = field(default_factory=list)
|
|
62
|
+
lo: float | None = None
|
|
63
|
+
hi: float | None = None
|
|
64
|
+
named: bool = True # categorical: labels are words (CeA, L1) rather than bare cluster numbers
|
|
65
|
+
na: int = 0 # missing values (shown as NA in the viewer)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def note(self) -> str:
|
|
69
|
+
base = self._note
|
|
70
|
+
return f"{base}; {self.na:,} NA" if self.na and self.kind in ("categorical", "numeric") else base
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def _note(self) -> str:
|
|
74
|
+
if self.kind == "categorical":
|
|
75
|
+
if self.levels > MAX_MENU_LEVELS:
|
|
76
|
+
return f"{self.levels} levels (too many for a legend)"
|
|
77
|
+
more = ", ..." if self.levels > len(self.examples) else ""
|
|
78
|
+
return f"{self.levels} levels: {', '.join(self.examples)}{more}"
|
|
79
|
+
if self.kind == "numeric":
|
|
80
|
+
return f"numeric {_g(self.lo)}..{_g(self.hi)}"
|
|
81
|
+
if self.kind == "boolean":
|
|
82
|
+
return "true/false"
|
|
83
|
+
if self.kind == "text":
|
|
84
|
+
return "text, unique per cell"
|
|
85
|
+
return "empty"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _g(v: float | None) -> str:
|
|
89
|
+
if v is None or not np.isfinite(v):
|
|
90
|
+
return "?"
|
|
91
|
+
return f"{v:,.0f}" if float(v).is_integer() and abs(v) < 1e12 else f"{v:.3g}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def describe_columns(obs: pd.DataFrame) -> list[Column]:
|
|
95
|
+
out: list[Column] = []
|
|
96
|
+
n = len(obs)
|
|
97
|
+
for col in obs.columns:
|
|
98
|
+
s = obs[col]
|
|
99
|
+
name = str(col)
|
|
100
|
+
na = int(s.isna().sum())
|
|
101
|
+
if str(s.dtype) in ("bool", "boolean"):
|
|
102
|
+
out.append(Column(name, "boolean", na=na))
|
|
103
|
+
elif isinstance(s.dtype, pd.CategoricalDtype) or s.dtype == object or str(s.dtype) == "string":
|
|
104
|
+
vals = s.dropna().astype(str)
|
|
105
|
+
k = int(vals.nunique())
|
|
106
|
+
if k == 0:
|
|
107
|
+
out.append(Column(name, "empty"))
|
|
108
|
+
elif k >= n and n > 10:
|
|
109
|
+
out.append(Column(name, "text"))
|
|
110
|
+
else:
|
|
111
|
+
ex = [v if len(v) <= 18 else v[:17] + "…" for v in vals.unique()[:4]]
|
|
112
|
+
numeric_labels = vals.str.fullmatch(r"-?\d+(\.\d+)?").mean()
|
|
113
|
+
out.append(Column(name, "categorical", levels=k, examples=ex, named=bool(numeric_labels < 0.5), na=na))
|
|
114
|
+
elif pd.api.types.is_numeric_dtype(s):
|
|
115
|
+
v = pd.to_numeric(s, errors="coerce").to_numpy(dtype=float, na_value=np.nan)
|
|
116
|
+
finite = v[np.isfinite(v)]
|
|
117
|
+
na = int(v.size - finite.size)
|
|
118
|
+
if not finite.size:
|
|
119
|
+
out.append(Column(name, "empty"))
|
|
120
|
+
continue
|
|
121
|
+
k = len(np.unique(finite))
|
|
122
|
+
# integer cluster ids (BayesSpace, Leiden from R) are labels, not measurements
|
|
123
|
+
if 2 <= k <= MAX_MENU_LEVELS and np.all(finite == np.rint(finite)) and (DOMAIN_RE.search(name) or CELLTYPE_RE.search(name)):
|
|
124
|
+
ex = [str(int(x)) for x in np.unique(finite)[:4]]
|
|
125
|
+
out.append(Column(name, "categorical", levels=k, examples=ex, named=False, na=na))
|
|
126
|
+
else:
|
|
127
|
+
out.append(Column(name, "numeric", lo=float(finite.min()), hi=float(finite.max()), na=na))
|
|
128
|
+
else:
|
|
129
|
+
out.append(Column(name, "empty"))
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------- which ones to map
|
|
134
|
+
@dataclass
|
|
135
|
+
class Menu:
|
|
136
|
+
mapped: dict[str, str | dict] = field(default_factory=dict) # field id -> column, or {column, scale}
|
|
137
|
+
kinds: dict[str, str] = field(default_factory=dict) # field id -> categorical | continuous
|
|
138
|
+
notes: dict[str, str] = field(default_factory=dict) # field id -> note on the column
|
|
139
|
+
alternatives: list[tuple[str, Column]] = field(default_factory=list) # other candidates for domain / celltype
|
|
140
|
+
unmapped: list[Column] = field(default_factory=list)
|
|
141
|
+
skipped: list[str] = field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def suggest_fields(columns: list[Column], *, skip: set[str] | None = None) -> Menu:
|
|
145
|
+
"""Map one domain-like and one cell-type-like categorical column (2..60 levels), the three usual QC metrics
|
|
146
|
+
by name, and nothing else; everything else is listed as a comment. Among several candidates the last one
|
|
147
|
+
whose labels are names (not bare cluster numbers) wins: later columns are usually the curated, relabelled
|
|
148
|
+
ones. With no domain or cell type at all, every small categorical column is mapped under its own id."""
|
|
149
|
+
skip = set(skip or ())
|
|
150
|
+
menu = Menu()
|
|
151
|
+
used: set[str] = set()
|
|
152
|
+
cols: list[Column] = []
|
|
153
|
+
for c in columns:
|
|
154
|
+
if c.name in skip or SKIP_COLUMNS.match(c.name):
|
|
155
|
+
menu.skipped.append(c.name)
|
|
156
|
+
else:
|
|
157
|
+
cols.append(c)
|
|
158
|
+
cats = [c for c in cols if c.kind == "categorical" and 2 <= c.levels <= MAX_MENU_LEVELS]
|
|
159
|
+
for fid, rx in (("domain", DOMAIN_RE), ("celltype", CELLTYPE_RE)):
|
|
160
|
+
hits = [c for c in cats if rx.search(c.name) and c.name not in used]
|
|
161
|
+
if not hits:
|
|
162
|
+
continue
|
|
163
|
+
named = [c for c in hits if c.named]
|
|
164
|
+
pick = named[-1] if named else hits[0]
|
|
165
|
+
menu.mapped[fid] = pick.name
|
|
166
|
+
menu.kinds[fid] = "categorical"
|
|
167
|
+
menu.notes[fid] = pick.note
|
|
168
|
+
used.add(pick.name)
|
|
169
|
+
for alt in hits:
|
|
170
|
+
if alt is not pick:
|
|
171
|
+
menu.alternatives.append((fid, alt))
|
|
172
|
+
used.add(alt.name)
|
|
173
|
+
if "domain" not in menu.mapped and "celltype" not in menu.mapped:
|
|
174
|
+
for c in cats:
|
|
175
|
+
fid = field_id(c.name)
|
|
176
|
+
if fid in menu.mapped:
|
|
177
|
+
continue
|
|
178
|
+
menu.mapped[fid] = c.name
|
|
179
|
+
menu.kinds[fid] = "categorical"
|
|
180
|
+
menu.notes[fid] = c.note
|
|
181
|
+
used.add(c.name)
|
|
182
|
+
nums = [c for c in cols if c.kind == "numeric"]
|
|
183
|
+
for fid, rx in QC_RE.items():
|
|
184
|
+
hit = next((c for c in nums if rx.match(c.name) and c.name not in used), None)
|
|
185
|
+
if hit is None:
|
|
186
|
+
continue
|
|
187
|
+
says_percent = re.search(r"percent|pct|perc", hit.name, re.I) is not None
|
|
188
|
+
ratio = fid == "mito_percent" and not says_percent and (re.search(r"ratio|frac", hit.name, re.I) is not None or (hit.hi is not None and hit.hi <= 1.0))
|
|
189
|
+
menu.mapped[fid] = {"column": hit.name, "scale": 100} if ratio else hit.name
|
|
190
|
+
menu.kinds[fid] = "continuous"
|
|
191
|
+
menu.notes[fid] = hit.note + (" -> shown as percent" if ratio else "")
|
|
192
|
+
used.add(hit.name)
|
|
193
|
+
menu.unmapped = [c for c in cols if c.name not in used]
|
|
194
|
+
return menu
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------- what the data looks like
|
|
198
|
+
def guess_platform(columns: list[str], n_obs: int, *, spatial: bool) -> tuple[str, str | None]:
|
|
199
|
+
"""The platform a file most likely came from, and the evidence (None when nothing said anything and the
|
|
200
|
+
answer is the default). SpaceRanger's grid columns mean Visium, or Visium HD when there are more
|
|
201
|
+
observations than a capture area has spots; Xenium's cell metrics mean cells; otherwise Visium."""
|
|
202
|
+
if not spatial:
|
|
203
|
+
return "snrnaseq", "no spatial coordinates"
|
|
204
|
+
cols = {str(c).lower() for c in columns}
|
|
205
|
+
grid = sorted(cols & ARRAY_COLUMNS)
|
|
206
|
+
if grid:
|
|
207
|
+
if n_obs > VISIUM_MAX_OBS:
|
|
208
|
+
return "visium_hd", f"{', '.join(grid)} columns and {n_obs:,} observations, more than a Visium capture area holds"
|
|
209
|
+
return "visium", f"{', '.join(grid)} columns"
|
|
210
|
+
cells = sorted(cols & CELL_COLUMNS)
|
|
211
|
+
if len(cells) >= 2:
|
|
212
|
+
return "xenium", f"{', '.join(cells)} columns"
|
|
213
|
+
if n_obs > VISIUM_MAX_OBS:
|
|
214
|
+
return "xenium", f"{n_obs:,} observations and no array_row / array_col: cells, not spots (Visium HD cells: set visium_hd)"
|
|
215
|
+
return "visium", None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sections_in(obs: pd.DataFrame) -> tuple[str, list[str]] | None:
|
|
219
|
+
"""A column that splits one file into several sections (sample_id, capture_area, ...), with its values."""
|
|
220
|
+
for col in obs.columns:
|
|
221
|
+
if SAMPLE_COLUMN_RE.match(str(col)):
|
|
222
|
+
vals = [str(v) for v in pd.unique(obs[col].dropna().astype(str))]
|
|
223
|
+
if 2 <= len(vals) <= MAX_MENU_LEVELS:
|
|
224
|
+
return str(col), vals
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------- one entry per argument
|
|
229
|
+
@dataclass
|
|
230
|
+
class EntryMenu:
|
|
231
|
+
source: str # path or glob, relative to the yaml
|
|
232
|
+
source_note: str
|
|
233
|
+
r_object: bool
|
|
234
|
+
embedding: bool
|
|
235
|
+
platform: str
|
|
236
|
+
id_tpl: str
|
|
237
|
+
name_tpl: str
|
|
238
|
+
group_tpl: str
|
|
239
|
+
menu: Menu
|
|
240
|
+
samples: list[str] = field(default_factory=list)
|
|
241
|
+
assay: str | None = None
|
|
242
|
+
assays: list[str] = field(default_factory=list)
|
|
243
|
+
fragments: dict[str, str] = field(default_factory=dict) # Seurat: run -> fragment file recorded in the object
|
|
244
|
+
sample_col: str | None = None
|
|
245
|
+
embedding_key: str | None = None
|
|
246
|
+
embeddings: list[str] = field(default_factory=list)
|
|
247
|
+
table: str | None = None
|
|
248
|
+
platform_note: str = "" # how the platform was chosen: given, guessed from ..., or the default
|
|
249
|
+
layers: list[str] = field(default_factory=list) # other layers in the file (files only)
|
|
250
|
+
sections: tuple[str, list[str]] | None = None # one file that holds several sections: (column, values)
|
|
251
|
+
n_obs: int | None = None
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def assumed(self) -> list[str]:
|
|
255
|
+
"""What `init` took for granted about this entry, one line each, for the terminal."""
|
|
256
|
+
plat = f"{self.platform} ({self.platform_note})" if self.platform_note else self.platform
|
|
257
|
+
if self.embedding:
|
|
258
|
+
coords = COORDINATE_NOTE["snrnaseq"]
|
|
259
|
+
else:
|
|
260
|
+
coords = COORDINATE_NOTE.get(self.platform, COORDINATE_NOTE["other"])
|
|
261
|
+
if self.r_object:
|
|
262
|
+
expr = f"assay {self.assay}" + (f" (also: {', '.join(a for a in self.assays if a != self.assay)})" if len(self.assays) > 1 else "")
|
|
263
|
+
else:
|
|
264
|
+
expr = "X" + (f" (layers not drawn: {', '.join(self.layers)})" if self.layers else "")
|
|
265
|
+
expr += "; counts are log-normalized by build, anything else is taken as already log-normalized"
|
|
266
|
+
out = [f"platform: {plat}", f"coordinates: {coords}", f"expression: {expr}"]
|
|
267
|
+
m = self.menu
|
|
268
|
+
if m.mapped:
|
|
269
|
+
parts = []
|
|
270
|
+
for fid, v in m.mapped.items():
|
|
271
|
+
col = v if isinstance(v, str) else v["column"]
|
|
272
|
+
note = m.notes.get(fid, "")
|
|
273
|
+
levels = re.match(r"(\d+ levels)", note)
|
|
274
|
+
na = re.search(r"([\d,]+ NA)$", note)
|
|
275
|
+
extra = ", ".join(x for x in ((levels.group(1) if levels else ""), (na.group(1) if na else "")) if x)
|
|
276
|
+
parts.append(f"{fid} <- {col}" + (f" ({extra})" if extra else ""))
|
|
277
|
+
out.append("annotations: " + ", ".join(parts))
|
|
278
|
+
else:
|
|
279
|
+
out.append("annotations: none mapped (every column is listed as a comment)")
|
|
280
|
+
if self.sections:
|
|
281
|
+
col, vals = self.sections
|
|
282
|
+
out.append(f"sections: {len(vals)} in one file ({col}: {_join(vals, 4)}), drawn as one sample; `subset:` splits them")
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _safe(name: str) -> str:
|
|
287
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _rel(path: str | Path, base: Path) -> str:
|
|
291
|
+
return os.path.relpath(os.path.abspath(os.path.expanduser(str(path))), base)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _join(names: list[str], k: int = 6) -> str:
|
|
295
|
+
if len(names) <= k + 1:
|
|
296
|
+
return ", ".join(names)
|
|
297
|
+
return ", ".join(names[:k]) + f", ... ({len(names)} in all)"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _r_entry(path: Path, platform: str | None, base: Path, log: Log) -> EntryMenu:
|
|
301
|
+
from .rds import read_experiment
|
|
302
|
+
|
|
303
|
+
log(f"reading {path} (the whole object is parsed; about as long as loading it in R)")
|
|
304
|
+
x = read_experiment(path)
|
|
305
|
+
cols = describe_columns(x.obs)
|
|
306
|
+
assay = next((k for k in ("logcounts", "data") if k in x.assays), next(iter(x.assays)))
|
|
307
|
+
if x.class_name == "SpatialExperiment" and x.spatial is not None:
|
|
308
|
+
sample_col = "sample_id"
|
|
309
|
+
if sample_col in x.obs.columns:
|
|
310
|
+
samples = [str(v) for v in pd.unique(x.obs[sample_col].astype(str))]
|
|
311
|
+
note = f"{x.class_name}, {len(samples)} sample{'s' if len(samples) != 1 else ''}: {_join(samples)} (one sample per {sample_col})"
|
|
312
|
+
else:
|
|
313
|
+
samples = []
|
|
314
|
+
note = f"{x.class_name}, {x.n_obs:,} cells; no sample_id column: set sample_col below"
|
|
315
|
+
log(f"warning: {path.name} has no `sample_id` column; set `sample_col:` in the yaml to the column that separates samples")
|
|
316
|
+
per_sample = x.n_obs // max(len(samples), 1)
|
|
317
|
+
plat, plat_note = _platform_choice(platform, list(x.obs.columns), per_sample, spatial=True)
|
|
318
|
+
prefix, label = PLATFORM_PREFIX.get(plat, plat[:3]), PLATFORM_LABEL.get(plat, plat)
|
|
319
|
+
return EntryMenu(
|
|
320
|
+
source=_rel(path, base), source_note=note, r_object=True, embedding=False, platform=plat,
|
|
321
|
+
id_tpl=f"{prefix}_{{name}}", name_tpl=f"{{name}} ({label})", group_tpl="{name}",
|
|
322
|
+
menu=suggest_fields(cols, skip={sample_col}), samples=[_safe(s) for s in samples],
|
|
323
|
+
assay=assay, assays=list(x.assays), sample_col=sample_col, platform_note=plat_note, n_obs=x.n_obs,
|
|
324
|
+
)
|
|
325
|
+
from .convert import pick_embedding
|
|
326
|
+
|
|
327
|
+
emb = pick_embedding(x.reduced_dims)
|
|
328
|
+
stem = _safe(path.stem)
|
|
329
|
+
note = f"{x.class_name}, {x.n_obs:,} cells; embedding {emb or 'none found'} -> obsm/X_umap"
|
|
330
|
+
if x.feature_sets:
|
|
331
|
+
note += "; also " + ", ".join(f"{fs.name} as {fs.prefix.strip()} features" for fs in x.feature_sets)
|
|
332
|
+
return EntryMenu(
|
|
333
|
+
source=_rel(path, base), source_note=note, r_object=True, embedding=True, platform="snrnaseq",
|
|
334
|
+
id_tpl=f"sn_{stem}", name_tpl=f"snRNA-seq ({stem})", group_tpl="snRNA-seq",
|
|
335
|
+
menu=suggest_fields(cols), samples=[stem], assay=assay, assays=list(x.assays),
|
|
336
|
+
embedding_key=emb, embeddings=list(x.reduced_dims), fragments={f.run: f.path for f in x.fragments},
|
|
337
|
+
platform_note="no spatialCoords in the object", n_obs=x.n_obs,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _platform_choice(given: str | None, columns: list[str], n_obs: int, *, spatial: bool) -> tuple[str, str]:
|
|
342
|
+
if given:
|
|
343
|
+
return given, "given"
|
|
344
|
+
plat, why = guess_platform(columns, n_obs, spatial=spatial)
|
|
345
|
+
return plat, f"guessed from {why}" if why else "the default; nothing in the columns said otherwise"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _glob_entry(pattern: str, platform: str | None, table: str | None, base: Path, log: Log) -> EntryMenu:
|
|
349
|
+
from .readers import peek
|
|
350
|
+
|
|
351
|
+
matches = sorted(_glob.glob(os.path.expanduser(pattern), recursive=True))
|
|
352
|
+
cols: list[Column] = []
|
|
353
|
+
layers: list[str] = []
|
|
354
|
+
sections = None
|
|
355
|
+
n_obs = None
|
|
356
|
+
if matches:
|
|
357
|
+
pk = peek(Path(matches[0]), table)
|
|
358
|
+
cols = describe_columns(pk.obs)
|
|
359
|
+
layers = list(pk.layers)
|
|
360
|
+
n_obs = len(pk.obs)
|
|
361
|
+
plat, plat_note = _platform_choice(platform, list(pk.obs.columns), n_obs, spatial="spatial" in pk.obsm)
|
|
362
|
+
if plat != "snrnaseq":
|
|
363
|
+
sections = sections_in(pk.obs)
|
|
364
|
+
else:
|
|
365
|
+
log(f"warning: {pattern!r} matches nothing right now; the fields menu is empty and ids come from the files present at build time")
|
|
366
|
+
plat, plat_note = (platform, "given") if platform else ("visium", "the default; no file to look at yet")
|
|
367
|
+
embedding = plat == "snrnaseq"
|
|
368
|
+
if sections:
|
|
369
|
+
col, vals = sections
|
|
370
|
+
log(f"warning: {Path(matches[0]).name} holds {len(vals)} sections ({col}: {_join(vals, 4)}); one entry draws them as one sample. To split them, list the file once per section with `subset: {{ column: {col}, values: [{vals[0]}] }}`")
|
|
371
|
+
# `{name}` is the parent folder (right for `<sample>/adata.h5ad`); `{stem}` is the file or store name
|
|
372
|
+
# (right for `samples/<sample>.h5ad`). Pick whichever carries the sample name.
|
|
373
|
+
stems = {Path(m).name.split(".")[0].lower() for m in matches}
|
|
374
|
+
var = "{name}" if matches and stems <= GENERIC_STORE_NAMES else "{stem}"
|
|
375
|
+
names = [Path(m).parent.name if var == "{name}" else Path(m).name.split(".")[0] for m in matches]
|
|
376
|
+
note = "one sample per match; {name} = parent folder, {stem} = file or store name" + (f"; now {len(matches)}: {_join(names, 4)}" if matches else "; matches nothing yet")
|
|
377
|
+
prefix, label = PLATFORM_PREFIX.get(plat, plat[:3]), PLATFORM_LABEL.get(plat, plat)
|
|
378
|
+
return EntryMenu(
|
|
379
|
+
source=_rel(pattern, base), source_note=note, r_object=False, embedding=embedding, platform=plat,
|
|
380
|
+
id_tpl=f"{prefix}_{var}", name_tpl=f"{var} ({label})", group_tpl="snRNA-seq" if embedding else var,
|
|
381
|
+
menu=suggest_fields(cols), samples=names, table=table, platform_note=plat_note, layers=layers, sections=sections, n_obs=n_obs,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------- rendering
|
|
386
|
+
YAML_PLAIN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_ ./()+%-]*[A-Za-z0-9_.)%]")
|
|
387
|
+
YAML_RESERVED = {"true", "false", "null", "yes", "no", "on", "off", "~"}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _ystr(s: str) -> str:
|
|
391
|
+
"""A YAML scalar: plain when it is unambiguous, double-quoted otherwise (templates, colons, hashes)."""
|
|
392
|
+
plain = (len(s) == 1 and s.isalnum()) or (YAML_PLAIN.fullmatch(s) is not None and ": " not in s and " #" not in s)
|
|
393
|
+
if plain and s.lower() not in YAML_RESERVED:
|
|
394
|
+
try: # numbers, dates, octal: YAML would type them, so they need quotes
|
|
395
|
+
if yaml.safe_load(s) == s:
|
|
396
|
+
return s
|
|
397
|
+
except yaml.YAMLError:
|
|
398
|
+
pass
|
|
399
|
+
return json.dumps(s, ensure_ascii=False)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _pad(text: str, comment: str, width: int = 44) -> str:
|
|
403
|
+
if not comment:
|
|
404
|
+
return text
|
|
405
|
+
return f"{text:<{width}} # {comment}" if len(text) < width else f"{text} # {comment}"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _entry_lines(e: EntryMenu) -> list[str]:
|
|
409
|
+
L: list[str] = []
|
|
410
|
+
L.append(_pad(f" - {'path' if e.r_object else 'glob'}: {_ystr(e.source)}", e.source_note))
|
|
411
|
+
others = ", ".join(p for p in ("visium", "visium_hd", "xenium", "merfish") if p != e.platform)
|
|
412
|
+
L.append(_pad(f" platform: {e.platform}", "" if e.embedding else f"{e.platform_note}; or {others}"))
|
|
413
|
+
if e.embedding:
|
|
414
|
+
L.append(" kind: embedding")
|
|
415
|
+
if not e.r_object:
|
|
416
|
+
L.append(_pad(" coords: obsm/X_umap", "the 2-D embedding to draw"))
|
|
417
|
+
if e.table:
|
|
418
|
+
L.append(f" table: {_ystr(e.table)}")
|
|
419
|
+
if e.r_object:
|
|
420
|
+
others = [a for a in e.assays if a != e.assay]
|
|
421
|
+
L.append(_pad(f" assay: {_ystr(e.assay or 'logcounts')}", f"drawn as expression; also in the object: {', '.join(others)}" if others else "drawn as expression"))
|
|
422
|
+
if e.embedding:
|
|
423
|
+
others = [k for k in e.embeddings if k != e.embedding_key]
|
|
424
|
+
L.append(_pad(f" # embedding: {e.embedding_key or 'UMAP'}", "reducedDims entry to draw" + (f"; also: {', '.join(others)}" if others else "")))
|
|
425
|
+
else:
|
|
426
|
+
L.append(_pad(f" # sample_col: {e.sample_col or 'sample_id'}", "colData column that splits the object into samples"))
|
|
427
|
+
L.append(_pad(" # microns: { microns_per_unit: 0.2525 }", "Visium HD: SpaceRanger's microns_per_pixel. Visium is inferred from the spot spacing"))
|
|
428
|
+
id_note = "" if e.embedding else ("{name} = the sample_id value" if e.r_object else "")
|
|
429
|
+
L.append(_pad(f" id: {_ystr(e.id_tpl)}", id_note))
|
|
430
|
+
L.append(f" name: {_ystr(e.name_tpl)}")
|
|
431
|
+
L.append(f" group: {_ystr(e.group_tpl)}")
|
|
432
|
+
m = e.menu
|
|
433
|
+
if m.mapped:
|
|
434
|
+
L.append(_pad(" fields:", "field id: column. Delete a line to hide an annotation, uncomment one to add it"))
|
|
435
|
+
for fid, v in m.mapped.items():
|
|
436
|
+
val = _ystr(v) if isinstance(v, str) else f"{{ column: {_ystr(v['column'])}, scale: {v['scale']:g} }}"
|
|
437
|
+
L.append(_pad(f" {fid}: {val}", m.notes.get(fid, "")))
|
|
438
|
+
for fid, c in m.alternatives:
|
|
439
|
+
L.append(_pad(f" # {fid}: {_ystr(c.name)}", f"alternative; {c.note}"))
|
|
440
|
+
for c in m.unmapped:
|
|
441
|
+
L.append(_pad(f" # {field_id(c.name)}: {_ystr(c.name)}", c.note))
|
|
442
|
+
else:
|
|
443
|
+
L.append(_pad(" # fields:", "uncomment this line and the columns to show as annotations"))
|
|
444
|
+
for c in m.unmapped:
|
|
445
|
+
L.append(_pad(f" # {field_id(c.name)}: {_ystr(c.name)}", c.note))
|
|
446
|
+
if m.skipped:
|
|
447
|
+
L.append(f" # not listed: {', '.join(m.skipped)} (ids and layout columns)")
|
|
448
|
+
if e.sections:
|
|
449
|
+
col, vals = e.sections
|
|
450
|
+
L.append(_pad(f" # subset: {{ column: {_ystr(col)}, values: [{_ystr(vals[0])}] }}", f"this file holds {len(vals)} sections ({_join(vals, 4)}); one entry per section splits them"))
|
|
451
|
+
if e.fragments:
|
|
452
|
+
L.append(_pad(" # tracks:", "genome tracks from the fragment files the object was built from (paths as recorded in it)"))
|
|
453
|
+
L.append(" # fragments:")
|
|
454
|
+
for run, path in e.fragments.items():
|
|
455
|
+
L.append(f" # {_ystr(run)}: {_ystr(path)}")
|
|
456
|
+
L.append(_pad(" # sample_column: fragment_run", "written by the converter from the object's Fragment records"))
|
|
457
|
+
L.append(" # barcode_column: fragment_barcode")
|
|
458
|
+
first_cat = next((fid for fid, kind in e.menu.kinds.items() if kind == "categorical"), "<a categorical field>")
|
|
459
|
+
L.append(_pad(f" # by: [{first_cat}]", "one track per category"))
|
|
460
|
+
L.append(" # genome: hg38")
|
|
461
|
+
return L
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _pretty(fid: str) -> str:
|
|
465
|
+
if fid in FIELD_NAMES:
|
|
466
|
+
return FIELD_NAMES[fid]
|
|
467
|
+
s = re.sub(r"[_\.]+", " ", fid).strip()
|
|
468
|
+
return s[:1].upper() + s[1:]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def render_dataset_yaml(*, dataset_id: str, name: str, entries: list[EntryMenu]) -> str:
|
|
472
|
+
fields: dict[str, str] = {}
|
|
473
|
+
for e in entries:
|
|
474
|
+
for fid, kind in e.menu.kinds.items():
|
|
475
|
+
fields.setdefault(fid, kind)
|
|
476
|
+
color_field = next((f for f in ("domain", "celltype") if f in fields), next((f for f, k in fields.items() if k == "categorical"), None))
|
|
477
|
+
spatial = [e for e in entries if not e.embedding]
|
|
478
|
+
thumb = f"{spatial[0].id_tpl.replace('{name}', spatial[0].samples[0]).replace('{stem}', spatial[0].samples[0])}" if spatial and spatial[0].samples else "<sample id>"
|
|
479
|
+
L = [
|
|
480
|
+
"# dataset.yaml for spatialscape. Lines starting with # are optional settings; uncomment them to use them.",
|
|
481
|
+
f"# What to edit for which change: {GUIDE}",
|
|
482
|
+
f"id: {_ystr(dataset_id)}",
|
|
483
|
+
f"name: {_ystr(name)}",
|
|
484
|
+
"# description: One sentence for the gallery card.",
|
|
485
|
+
_pad("# default_gene: SNAP25", "gene shown on open (default: the most variable gene)"),
|
|
486
|
+
_pad(f"default_color: {{ field: {color_field} }}" if color_field else "# default_color: { field: domain }", "annotation shown on open; or { gene: SNAP25 }"),
|
|
487
|
+
_pad("# palette: colors.json", '{ "domain": { "L1": "#F0027F", ... } }; a field uses it through palette_key'),
|
|
488
|
+
_pad(f"# thumbnail_sample: {thumb}", "section drawn on the gallery card (default: the first)"),
|
|
489
|
+
_pad("layout: { mode: grid }", "or strip; gutter_fraction: 0.1 spaces sections out"),
|
|
490
|
+
"",
|
|
491
|
+
]
|
|
492
|
+
if fields:
|
|
493
|
+
L += [
|
|
494
|
+
"# Annotation names. Each id is mapped to a column under samples below. Add palette_key (block in the palette",
|
|
495
|
+
"# file), aliases (merge spellings: { AI: IA }) or categories (a fixed order) to an entry when you need them.",
|
|
496
|
+
"fields:",
|
|
497
|
+
]
|
|
498
|
+
L += [f" - {{ id: {fid}, name: {_ystr(_pretty(fid))}, type: {kind} }}" for fid, kind in fields.items()]
|
|
499
|
+
else:
|
|
500
|
+
L += [
|
|
501
|
+
"# Annotation names, once some columns are mapped under samples below:",
|
|
502
|
+
"# fields:",
|
|
503
|
+
"# - { id: domain, name: Spatial domain, type: categorical, palette_key: domain }",
|
|
504
|
+
]
|
|
505
|
+
L += ["", "samples:"]
|
|
506
|
+
for e in entries:
|
|
507
|
+
L += _entry_lines(e)
|
|
508
|
+
return "\n".join(L) + "\n"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ---------------------------------------------------------------- the command
|
|
512
|
+
@dataclass
|
|
513
|
+
class MenuResult:
|
|
514
|
+
out: Path
|
|
515
|
+
dataset_id: str
|
|
516
|
+
entries: list[EntryMenu]
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def samples(self) -> list[str]:
|
|
520
|
+
return [s for e in self.entries for s in e.samples]
|
|
521
|
+
|
|
522
|
+
@property
|
|
523
|
+
def mapped(self) -> list[str]:
|
|
524
|
+
seen: dict[str, None] = {}
|
|
525
|
+
for e in self.entries:
|
|
526
|
+
for fid in e.menu.mapped:
|
|
527
|
+
seen.setdefault(fid, None)
|
|
528
|
+
return list(seen)
|
|
529
|
+
|
|
530
|
+
@property
|
|
531
|
+
def commented(self) -> int:
|
|
532
|
+
return sum(len(e.menu.unmapped) + len(e.menu.alternatives) for e in self.entries)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def default_id(first: str) -> str:
|
|
536
|
+
"""A dataset id from the first argument: the R object's stem, or the folder a glob starts in."""
|
|
537
|
+
if is_r_object(first):
|
|
538
|
+
base = Path(first).stem
|
|
539
|
+
else:
|
|
540
|
+
parts = Path(os.path.expanduser(first)).parts
|
|
541
|
+
wild = next((i for i, x in enumerate(parts) if any(ch in x for ch in "*?[")), None)
|
|
542
|
+
if wild is None:
|
|
543
|
+
base = Path(first).name.split(".")[0]
|
|
544
|
+
elif wild == 0:
|
|
545
|
+
base = Path.cwd().name
|
|
546
|
+
else:
|
|
547
|
+
base = parts[wild - 1]
|
|
548
|
+
return re.sub(r"[^A-Za-z0-9_]+", "_", base).strip("_").lower() or "my_dataset"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def write_menu(
|
|
552
|
+
paths: list[str],
|
|
553
|
+
*,
|
|
554
|
+
out: Path | None = None,
|
|
555
|
+
dataset_id: str | None = None,
|
|
556
|
+
name: str | None = None,
|
|
557
|
+
platform: str | None = None,
|
|
558
|
+
table: str | None = None,
|
|
559
|
+
log: Log = print,
|
|
560
|
+
) -> MenuResult:
|
|
561
|
+
if platform is not None and platform not in PLATFORM_LABEL:
|
|
562
|
+
raise ValueError(f"unknown platform {platform!r}; one of {', '.join(PLATFORM_LABEL)}")
|
|
563
|
+
dataset_id = dataset_id or default_id(paths[0])
|
|
564
|
+
name = name or (dataset_id.replace("_", " ")[:1].upper() + dataset_id.replace("_", " ")[1:])
|
|
565
|
+
out = (Path(out) if out is not None else Path(f"{dataset_id}.yaml")).expanduser()
|
|
566
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
567
|
+
base = out.parent.resolve()
|
|
568
|
+
entries: list[EntryMenu] = []
|
|
569
|
+
for p in paths:
|
|
570
|
+
if is_r_object(p):
|
|
571
|
+
path = Path(p).expanduser()
|
|
572
|
+
if not path.exists():
|
|
573
|
+
raise FileNotFoundError(str(path))
|
|
574
|
+
entries.append(_r_entry(path, platform, base, log))
|
|
575
|
+
else:
|
|
576
|
+
entries.append(_glob_entry(p, platform, table, base, log))
|
|
577
|
+
out.write_text(render_dataset_yaml(dataset_id=dataset_id, name=name, entries=entries))
|
|
578
|
+
return MenuResult(out=out, dataset_id=dataset_id, entries=entries)
|