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/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)