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/rds.py ADDED
@@ -0,0 +1,609 @@
1
+ """Read SpatialExperiment / SingleCellExperiment / Seurat objects straight from .rds / .rda files, without R.
2
+
3
+ Parses the serialized objects with the reader in rdsread.py into plain Python structures, then picks out what the
4
+ viewer needs: assays (sparse or dense), colData, rowData, spatialCoords, reducedDims and imgData. For a Seurat
5
+ object: the layers of one assay, `meta.data`, the ident, the reductions, the other assays as prefixed feature
6
+ sets (ATAC peaks, gene activity, motif scores) and the fragment files a ChromatinAssay points at.
7
+ HDF5-backed assays (DelayedArray, BPCells) are not supported; realize them in R first.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ import scipy.sparse as sp
19
+
20
+ R_NA_INT = -2147483648
21
+
22
+
23
+ @dataclass
24
+ class RImage:
25
+ sample_id: str
26
+ image_id: str
27
+ scale_factor: float
28
+ rgb: np.ndarray | None # (h, w, 3) uint8 for loaded images
29
+ path: str | None = None # for stored / remote images
30
+
31
+
32
+ @dataclass
33
+ class RFeatureSet:
34
+ """Another assay of a Seurat object with its own features (ATAC peaks, gene activity, motif scores): drawn in
35
+ the viewer as features named `<prefix><feature>` next to the genes."""
36
+
37
+ name: str
38
+ prefix: str # "peak: ", "activity: ", "motif: ", or "<assay>: "
39
+ var: pd.DataFrame
40
+ assays: dict[str, Any] # layer -> features x cells
41
+
42
+
43
+ @dataclass
44
+ class RFragments:
45
+ """A fragment file a Signac ChromatinAssay was built from (the path as it was on the machine that built it)."""
46
+
47
+ path: str
48
+ cells: dict[str, str] | None # cell name in the object -> barcode in the file; None when they are the same
49
+
50
+ @property
51
+ def run(self) -> str:
52
+ """A short id for the run: the Cell Ranger sample folder (`<run>/outs/atac_fragments.tsv.gz`), else the file stem."""
53
+ parts = self.path.replace("\\", "/").rstrip("/").split("/")
54
+ if len(parts) >= 3 and parts[-2] == "outs":
55
+ return parts[-3]
56
+ name = parts[-1]
57
+ for suffix in (".tsv.gz", ".tsv", ".bed.gz", ".bed", ".gz"):
58
+ if name.endswith(suffix):
59
+ return name[: -len(suffix)]
60
+ return name
61
+
62
+
63
+ def fragment_columns(cells: list[str], fragments: list[RFragments]) -> tuple[pd.Series, pd.Series] | None:
64
+ """Per cell, the run and the barcode its fragments carry, from the Fragment records (None when no record maps
65
+ cells): the columns `tracks:` needs as `sample_column` and `barcode_column` for an object pooled from runs."""
66
+ if not any(f.cells for f in fragments):
67
+ return None
68
+ run = pd.Series([None] * len(cells), index=pd.Index(cells), dtype=object)
69
+ bc = pd.Series([None] * len(cells), index=pd.Index(cells), dtype=object)
70
+ for f in fragments:
71
+ if not f.cells:
72
+ continue
73
+ hit = run.index.intersection(list(f.cells))
74
+ run[hit] = f.run
75
+ bc[hit] = [f.cells[c] for c in hit]
76
+ return pd.Series(pd.Categorical(run.to_numpy()), index=run.index), bc
77
+
78
+
79
+ @dataclass
80
+ class RExperiment:
81
+ class_name: str
82
+ assays: dict[str, Any] # name -> scipy.sparse (genes x cells, CSC) or ndarray; for Seurat the layers of one assay
83
+ obs: pd.DataFrame
84
+ var: pd.DataFrame
85
+ spatial: np.ndarray | None # (n_cells, 2)
86
+ reduced_dims: dict[str, np.ndarray] = field(default_factory=dict)
87
+ images: list[RImage] = field(default_factory=list)
88
+ feature_sets: list[RFeatureSet] = field(default_factory=list) # Seurat: the other assays
89
+ fragments: list[RFragments] = field(default_factory=list) # Seurat: fragment files of a ChromatinAssay
90
+ assay_name: str | None = None # Seurat: the assay whose layers `assays` holds
91
+ notes: list[str] = field(default_factory=list) # what was left out, for the build log
92
+
93
+ @property
94
+ def n_obs(self) -> int:
95
+ return len(self.obs)
96
+
97
+ @property
98
+ def n_vars(self) -> int:
99
+ return len(self.var)
100
+
101
+
102
+ # ---------------------------------------------------------------- helpers over the parsed structure
103
+ def _attrs(o: Any) -> dict:
104
+ return o.get("attributes") or {} if isinstance(o, dict) else {}
105
+
106
+
107
+ def _cls(o: Any) -> str | None:
108
+ return o.get("class_name") if isinstance(o, dict) else None
109
+
110
+
111
+ def _is_null(o: Any) -> bool:
112
+ return o is None or (isinstance(o, dict) and o.get("type") == "null")
113
+
114
+
115
+ def _strings(o: Any) -> list[str | None]:
116
+ if _is_null(o):
117
+ return []
118
+ d = o.get("data", [])
119
+ return [None if s is None else str(s) for s in d] # NA_character_ arrives as None; "NA" can be a real label
120
+
121
+
122
+ def _named_list(o: Any) -> dict[str, Any]:
123
+ """An R list/vector with a `names` attribute -> dict."""
124
+ if _is_null(o):
125
+ return {}
126
+ names = _strings(_attrs(o).get("names"))
127
+ data = o.get("data", [])
128
+ return {str(n): v for n, v in zip(names, data, strict=False) if n is not None}
129
+
130
+
131
+ def _dframe(o: Any) -> tuple[list[str] | None, dict[str, Any]]:
132
+ """S4 DFrame -> (rownames, columns)."""
133
+ a = _attrs(o)
134
+ rn = _strings(a.get("rownames")) if not _is_null(a.get("rownames")) else None
135
+ return rn, _named_list(a.get("listData"))
136
+
137
+
138
+ def _vector_to_series(o: Any, n: int) -> pd.Series | None:
139
+ """One colData column -> pandas Series (None for nested / unsupported columns)."""
140
+ if _is_null(o):
141
+ return None
142
+ t = o.get("type")
143
+ a = _attrs(o)
144
+ cls = _strings(a.get("class")) if "class" in a else []
145
+ data = o.get("data")
146
+ if t == "integer" and "levels" in a: # factor; NA codes arrive as NaN (or R's NA_integer_)
147
+ raw = np.asarray(data, dtype=np.float64)
148
+ na = np.isnan(raw) | (raw == R_NA_INT)
149
+ codes = np.where(na, 0, raw).astype(np.int64) - 1
150
+ levels = _strings(a["levels"])
151
+ return pd.Series(pd.Categorical.from_codes(codes, categories=[str(x) for x in levels]))
152
+ if t in ("integer", "double", "boolean"):
153
+ arr = np.asarray(data)
154
+ if arr.ndim != 1 or len(arr) != n:
155
+ return None
156
+ if t == "integer":
157
+ na = arr == R_NA_INT
158
+ if na.any(): # nullable integers: cluster ids stay integers instead of turning into 1.0, 2.0
159
+ return pd.Series(pd.arrays.IntegerArray(np.where(na, 0, arr).astype(np.int64), na))
160
+ return pd.Series(arr.astype(np.int64))
161
+ if t == "boolean":
162
+ if arr.dtype.kind == "f": # NA logicals arrive as NaN
163
+ return pd.Series(pd.array(np.where(np.isnan(arr), None, arr == 1), dtype="boolean"))
164
+ if arr.dtype == object: # or as None among bools
165
+ return pd.Series(pd.array([None if v is None else bool(v) for v in arr], dtype="boolean"))
166
+ return pd.Series(pd.array(arr.astype(bool), dtype="boolean"))
167
+ return pd.Series(arr)
168
+ if t == "string":
169
+ vals = _strings(o)
170
+ if len(vals) != n:
171
+ return None
172
+ return pd.Series(pd.Categorical(vals))
173
+ if "Rle" in (cls or []) or _cls(o) == "Rle":
174
+ return None
175
+ return None
176
+
177
+
178
+ def _matrix(o: Any) -> Any:
179
+ """dgCMatrix / dgRMatrix / dense numeric matrix -> scipy CSC (or ndarray). Raises for delayed arrays."""
180
+ cls = _cls(o)
181
+ a = _attrs(o)
182
+ if cls in ("dgCMatrix", "dgRMatrix", "lgCMatrix"):
183
+ i = np.asarray(a["i"]["data"], dtype=np.int32) if cls != "dgRMatrix" else np.asarray(a["j"]["data"], dtype=np.int32)
184
+ p = np.asarray(a["p"]["data"], dtype=np.int64)
185
+ x = np.asarray(a["x"]["data"], dtype=np.float32)
186
+ dim = [int(v) for v in np.asarray(a["Dim"]["data"])]
187
+ if cls == "dgRMatrix":
188
+ return sp.csr_matrix((x, i, p), shape=(dim[0], dim[1])).tocsc()
189
+ return sp.csc_matrix((x, i, p), shape=(dim[0], dim[1]))
190
+ if cls and ("Delayed" in cls or "HDF5" in cls):
191
+ raise ValueError(f"assay is a {cls}; realize it in R first (e.g. assay(x, name) <- as(assay(x, name), 'dgCMatrix'))")
192
+ if o.get("type") in ("integer", "double") and "dim" in a:
193
+ dim = [int(v) for v in np.asarray(a["dim"]["data"])]
194
+ return np.asarray(o["data"], dtype=np.float32).reshape(dim, order="F")
195
+ raise ValueError(f"unsupported assay class {cls or o.get('type')}")
196
+
197
+
198
+ def _dimnames(o: Any) -> tuple[list[str] | None, list[str] | None]:
199
+ a = _attrs(o)
200
+ dn = a.get("Dimnames") or a.get("dimnames")
201
+ if _is_null(dn):
202
+ return None, None
203
+ parts = dn.get("data", [])
204
+ out: list[list[str] | None] = []
205
+ for p in parts[:2]:
206
+ out.append(None if _is_null(p) else [str(s) for s in _strings(p)])
207
+ while len(out) < 2:
208
+ out.append(None)
209
+ return out[0], out[1]
210
+
211
+
212
+ def _hex_to_rgb(colors, h: int, w: int) -> np.ndarray:
213
+ """An R `raster` (dim = rows x columns of "#RRGGBB" strings, a numpy `U` array or a list) as an (h, w, 3) uint8
214
+ array, decoded without touching the strings one by one. Unlike every other R matrix, a raster is stored row by
215
+ row (`as.raster` transposes on the way in), so the flat vector is C order."""
216
+ arr = np.asarray(colors if not isinstance(colors, list) else [c if c else "#000000" for c in colors], dtype="U9")
217
+ n = len(arr)
218
+ codes = arr.view(np.uint32).reshape(n, 9) # UTF-32 code points, 0-padded
219
+ hexpos = np.where(codes[:, :1] == ord("#"), 1, 0) # "#RRGGBB" or "RRGGBB"
220
+ idx = hexpos + np.arange(6)[None, :]
221
+ digits = np.take_along_axis(codes, idx, axis=1).astype(np.int64)
222
+ val = digits - 48 # '0'..'9'
223
+ val = np.where(digits >= 97, digits - 87, val) # 'a'..'f'
224
+ val = np.where((digits >= 65) & (digits < 97), digits - 55, val) # 'A'..'F'
225
+ val = np.clip(val, 0, 15)
226
+ rgb = (val[:, 0::2] * 16 + val[:, 1::2]).astype(np.uint8) # (n, 3)
227
+ short = (codes[:, 6] == 0) & (hexpos[:, 0] == 0) # fewer than 6 hex digits: black, as before
228
+ rgb[short] = 0
229
+ return rgb.reshape(h, w, 3)
230
+
231
+
232
+ def _images(int_metadata: dict[str, Any]) -> list[RImage]:
233
+ imgdata = int_metadata.get("imgData")
234
+ if _is_null(imgdata):
235
+ return []
236
+ _, cols = _dframe(imgdata)
237
+ if not cols:
238
+ return []
239
+ sample_ids = _strings(cols.get("sample_id"))
240
+ image_ids = _strings(cols.get("image_id"))
241
+ scales = list(np.asarray(cols["scaleFactor"]["data"], dtype=float)) if "scaleFactor" in cols else [1.0] * len(sample_ids)
242
+ data_col = cols.get("data")
243
+ entries = []
244
+ items = _attrs(data_col).get("listData", {}).get("data", []) if _cls(data_col) == "SimpleList" else data_col.get("data", []) if isinstance(data_col, dict) else []
245
+ for k, (sid, iid) in enumerate(zip(sample_ids, image_ids, strict=False)):
246
+ img = items[k] if k < len(items) else None
247
+ rgb = None
248
+ path = None
249
+ cls = _cls(img)
250
+ a = _attrs(img)
251
+ if cls == "LoadedSpatialImage":
252
+ raster = a.get("image")
253
+ dim = [int(v) for v in np.asarray(_attrs(raster)["dim"]["data"])] if "dim" in _attrs(raster) else None
254
+ if dim:
255
+ rgb = _hex_to_rgb(raster.get("data", []), dim[0], dim[1])
256
+ elif cls == "StoredSpatialImage":
257
+ path = (_strings(a.get("path")) or [None])[0]
258
+ elif cls == "RemoteSpatialImage":
259
+ path = (_strings(a.get("url")) or [None])[0]
260
+ entries.append(RImage(sample_id=str(sid), image_id=str(iid), scale_factor=float(scales[k]), rgb=rgb, path=path))
261
+ return entries
262
+
263
+
264
+ # ---------------------------------------------------------------- parsing (our own reader, see rdsread.py)
265
+ # Slots nobody here needs and that are large in Seurat objects: walked past without being built.
266
+ SKIP_SLOTS = frozenset({"scale.data", "graphs", "neighbors", "commands", "tools", "feature.loadings", "feature.loadings.projected", "jackstraw", "misc"})
267
+
268
+
269
+ def _parse(path: Path, object_name: str | None):
270
+ """Parse an .rds (one object) or .rda / .RData (named objects -> dict) file."""
271
+ from .rdsread import read_rda, read_rds
272
+
273
+ if path.suffix.lower() in (".rda", ".rdata"):
274
+ objs = read_rda(path, skip=SKIP_SLOTS, only=object_name)
275
+ if object_name is not None and object_name not in objs:
276
+ raise KeyError(f"{path}: no object named {object_name!r}; found {list(read_rda(path, skip=SKIP_SLOTS))}")
277
+ return objs
278
+ return read_rds(path, skip=SKIP_SLOTS)
279
+
280
+
281
+ # ---------------------------------------------------------------- Seurat objects
282
+
283
+ PEAK_NAME = re.compile(r"^(chr)?[0-9A-Za-z_.]+[:-]\d+-\d+$") # chr1-100-200 (Signac) or chr1:100-200
284
+
285
+
286
+ def _r_dataframe(o: Any) -> tuple[list[str] | None, dict[str, Any]]:
287
+ """S3 data.frame (Seurat meta.data, meta.features) -> (rownames, columns). Compact row names c(NA, -n) mean 1..n."""
288
+ if _is_null(o):
289
+ return None, {}
290
+ a = _attrs(o)
291
+ rn = a.get("row.names")
292
+ rownames = None
293
+ if not _is_null(rn):
294
+ if rn.get("type") == "string":
295
+ rownames = [str(x) for x in _strings(rn)]
296
+ else:
297
+ vals = np.asarray(rn.get("data", []), dtype=np.float64)
298
+ if len(vals) == 2 and (np.isnan(vals[0]) or vals[0] == R_NA_INT):
299
+ rownames = [str(i) for i in range(1, int(abs(vals[1])) + 1)]
300
+ else:
301
+ rownames = [str(int(v)) for v in vals]
302
+ return rownames, _named_list(o)
303
+
304
+
305
+ def _dense(o: Any) -> np.ndarray | None:
306
+ """A numeric R matrix (double/integer with `dim`) as a float64 array, or None for anything else / empty."""
307
+ if _is_null(o) or o.get("type") not in ("double", "integer") or "dim" not in _attrs(o):
308
+ return None
309
+ dim = [int(v) for v in np.asarray(_attrs(o)["dim"]["data"])]
310
+ if not all(dim):
311
+ return None
312
+ return np.asarray(o["data"], dtype=np.float64).reshape(dim, order="F")
313
+
314
+
315
+ def _logmap(o: Any) -> tuple[list[str], dict[str, np.ndarray]]:
316
+ """Seurat's LogMap (a logical matrix, rows x layers) -> (row names, {layer: mask})."""
317
+ a = _attrs(o)
318
+ dim = [int(v) for v in np.asarray(a["dim"]["data"])]
319
+ rows, cols = _dimnames(o)
320
+ data = np.asarray(o.get("data", []))
321
+ mat = (np.where(np.isnan(data), 0, data) if data.dtype.kind == "f" else data).reshape(dim, order="F").astype(bool)
322
+ return [str(r) for r in (rows or [])], {str(c): mat[:, j] for j, c in enumerate(cols or [])}
323
+
324
+
325
+ def _nonempty(m: Any) -> bool:
326
+ if _is_null(m):
327
+ return False
328
+ a = _attrs(m)
329
+ dim = a.get("Dim") or a.get("dim")
330
+ return not _is_null(dim) and all(int(v) for v in np.asarray(dim["data"]))
331
+
332
+
333
+ def _layer_matrix(m: Any) -> Any:
334
+ """A layer (dgCMatrix or dense) as features x cells CSC."""
335
+ out = _matrix(m)
336
+ return out if sp.issparse(out) else sp.csc_matrix(out)
337
+
338
+
339
+ def _align_cells(mat: Any, have: list[str] | None, want: list[str], what: str) -> Any:
340
+ """Reorder the columns of a features x cells matrix from `have` to `want` (the object's cells)."""
341
+ if have is None or list(have) == list(want):
342
+ if mat.shape[1] != len(want):
343
+ raise ValueError(f"{what}: {mat.shape[1]} cells, but the object has {len(want)}")
344
+ return mat
345
+ pos = {c: i for i, c in enumerate(have)}
346
+ try:
347
+ idx = np.fromiter((pos[c] for c in want), dtype=np.int64, count=len(want))
348
+ except KeyError as e:
349
+ raise ValueError(f"{what}: cell {e.args[0]!r} of the object is missing from the assay") from None
350
+ return mat[:, idx]
351
+
352
+
353
+ def _seurat_assay(obj: Any, name: str, cells: list[str]) -> tuple[list[str], dict[str, Any], pd.DataFrame]:
354
+ """One Seurat assay -> (features, {layer: features x cells CSC in the object's cell order}, feature data frame).
355
+ `Assay` / `ChromatinAssay` / `SCTAssay` keep `counts` and `data`; `Assay5` joins split layers (`counts.run1`,
356
+ `counts.run2`, ...) through its LogMaps. `scale.data` is never read."""
357
+ a = _attrs(obj)
358
+ cls = _cls(obj) or ""
359
+ layers: dict[str, Any] = {}
360
+ if cls == "Assay5":
361
+ feat_names, feat_masks = _logmap(a["features"])
362
+ cell_names, cell_masks = _logmap(a["cells"])
363
+ groups: dict[str, list[str]] = {}
364
+ for lname in _named_list(a["layers"]):
365
+ base = lname.split(".")[0]
366
+ if base == "scale":
367
+ continue
368
+ groups.setdefault(base, []).append(lname)
369
+ pieces = _named_list(a["layers"])
370
+ F, C = len(feat_names), len(cell_names)
371
+ for base, names in groups.items():
372
+ if len(names) == 1 and feat_masks[names[0]].all() and cell_masks[names[0]].all():
373
+ m = _layer_matrix(pieces[names[0]])
374
+ if m.shape != (F, C):
375
+ raise ValueError(f"assay {name}: layer {names[0]} is {m.shape}, expected {(F, C)}")
376
+ layers[base] = m
377
+ continue
378
+ rows_all, cols_all, vals_all = [], [], []
379
+ for lname in names:
380
+ m = _layer_matrix(pieces[lname]).tocoo()
381
+ fi = np.flatnonzero(feat_masks[lname])
382
+ ci = np.flatnonzero(cell_masks[lname])
383
+ if m.shape != (len(fi), len(ci)):
384
+ raise ValueError(f"assay {name}: layer {lname} is {m.shape}, but its maps cover {(len(fi), len(ci))} (join the layers in R with JoinLayers())")
385
+ rows_all.append(fi[m.row])
386
+ cols_all.append(ci[m.col])
387
+ vals_all.append(m.data)
388
+ layers[base] = sp.csc_matrix((np.concatenate(vals_all), (np.concatenate(rows_all), np.concatenate(cols_all))), shape=(F, C))
389
+ have = cell_names
390
+ features = feat_names
391
+ _, fcols = _r_dataframe(a.get("meta.data"))
392
+ else: # Assay, ChromatinAssay, SCTAssay
393
+ have = None
394
+ features = None
395
+ for lname in ("counts", "data"):
396
+ m = a.get(lname)
397
+ if _nonempty(m):
398
+ layers[lname] = _layer_matrix(m)
399
+ if features is None:
400
+ features, have = _dimnames(m)
401
+ if features is None:
402
+ rn, _ = _r_dataframe(a.get("meta.features"))
403
+ features = rn
404
+ _, fcols = _r_dataframe(a.get("meta.features"))
405
+ if not layers:
406
+ raise ValueError(f"assay {name}: no counts or data layer")
407
+ n_feat = next(iter(layers.values())).shape[0]
408
+ if features is None or len(features) != n_feat:
409
+ features = [f"{name}_{i}" for i in range(n_feat)]
410
+ layers = {k: _align_cells(v, have, cells, f"assay {name}, layer {k}") for k, v in layers.items()}
411
+ var = pd.DataFrame(index=pd.Index([str(f) for f in features]))
412
+ for cname, col in fcols.items():
413
+ s = _vector_to_series(col, n_feat)
414
+ if s is not None:
415
+ var[cname] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.astype(str).to_numpy()
416
+ return [str(f) for f in features], layers, var
417
+
418
+
419
+ def _peak_like(features: list[str]) -> bool:
420
+ sample = features[: min(len(features), 500)]
421
+ return bool(sample) and sum(1 for f in sample if PEAK_NAME.match(f)) >= 0.9 * len(sample)
422
+
423
+
424
+ def _feature_prefix(name: str, cls: str, features: list[str]) -> str:
425
+ low = name.lower()
426
+ if cls == "ChromatinAssay" or _peak_like(features):
427
+ return "peak: "
428
+ if "activ" in low:
429
+ return "activity: "
430
+ if "chromvar" in low or "motif" in low:
431
+ return "motif: "
432
+ if low in ("adt", "protein", "cite"):
433
+ return "protein: "
434
+ return f"{name}: "
435
+
436
+
437
+ def _fragments_of(obj: Any) -> list[RFragments]:
438
+ out = []
439
+ for fr in _attrs(obj).get("fragments", {}).get("data", []) if not _is_null(_attrs(obj).get("fragments")) else []:
440
+ fa = _attrs(fr)
441
+ path = (_strings(fa.get("path")) or [None])[0]
442
+ if not path:
443
+ continue
444
+ cells_obj = fa.get("cells")
445
+ cells = None
446
+ if not _is_null(cells_obj) and cells_obj.get("type") == "string":
447
+ names = _strings(_attrs(cells_obj).get("names"))
448
+ vals = _strings(cells_obj)
449
+ if names and len(names) == len(vals):
450
+ cells = {str(n): str(v) for n, v in zip(names, vals, strict=False) if n is not None and v is not None}
451
+ out.append(RFragments(path=path, cells=cells))
452
+ return out
453
+
454
+
455
+ def _read_seurat(root: Any, path: Path) -> RExperiment:
456
+ a = _attrs(root)
457
+ cells, cols = _r_dataframe(a.get("meta.data"))
458
+ if not cells:
459
+ raise ValueError(f"{path}: Seurat object without cell names in meta.data")
460
+ n = len(cells)
461
+ obs = pd.DataFrame(index=pd.Index(cells))
462
+ for name, col in cols.items():
463
+ s = _vector_to_series(col, n)
464
+ if s is not None:
465
+ obs[name] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.values
466
+ ident = a.get("active.ident")
467
+ if not _is_null(ident) and "ident" not in obs:
468
+ s = _vector_to_series(ident, n)
469
+ names = _strings(_attrs(ident).get("names"))
470
+ if s is not None:
471
+ if names and len(names) == n and list(names) != cells:
472
+ s = s.set_axis(names).reindex(cells)
473
+ obs["ident"] = s.values
474
+
475
+ assays = _named_list(a.get("assays"))
476
+ if not assays:
477
+ raise ValueError(f"{path}: Seurat object without assays")
478
+ parsed = {name: (_cls(obj) or "", *_seurat_assay(obj, name, cells)) for name, obj in assays.items()}
479
+ active = (_strings(a.get("active.assay")) or [None])[0]
480
+ gene_like = [nm for nm, (cls, feats, _, _) in parsed.items() if cls != "ChromatinAssay" and not _peak_like(feats)]
481
+ primary = active if active in gene_like else (gene_like[0] if gene_like else next(iter(parsed)))
482
+ _pcls, pfeat, players, pvar = parsed[primary]
483
+ notes: list[str] = []
484
+ sets: list[RFeatureSet] = []
485
+ primary_set = set(pfeat)
486
+ for nm, (cls, feats, layers, var) in parsed.items():
487
+ if nm == primary:
488
+ continue
489
+ shared = sum(1 for f in feats[:2000] if f in primary_set)
490
+ if feats and shared >= 0.5 * min(len(feats), 2000):
491
+ notes.append(f"assay {nm} left out: its features are the {primary} genes again")
492
+ continue
493
+ sets.append(RFeatureSet(name=nm, prefix=_feature_prefix(nm, cls, feats), var=var, assays=layers))
494
+ fragments = [f for nm, obj in assays.items() if _cls(obj) == "ChromatinAssay" for f in _fragments_of(obj)]
495
+ fcols = fragment_columns(cells, fragments)
496
+ if fcols is not None:
497
+ obs["fragment_run"], obs["fragment_barcode"] = fcols[0].to_numpy(), fcols[1].to_numpy()
498
+
499
+ reduced: dict[str, np.ndarray] = {}
500
+ for name, dr in _named_list(a.get("reductions")).items():
501
+ emb = _dense(_attrs(dr).get("cell.embeddings"))
502
+ if emb is None or emb.shape[0] != n:
503
+ continue
504
+ rows, _ = _dimnames(_attrs(dr).get("cell.embeddings"))
505
+ if rows and list(rows) != cells:
506
+ pos = {c: i for i, c in enumerate(rows)}
507
+ emb = emb[[pos[c] for c in cells]] if all(c in pos for c in cells) else emb
508
+ reduced[name] = emb
509
+ if not _is_null(a.get("images")) and a["images"].get("data"):
510
+ notes.append("Seurat spatial images are not read yet; export a SpatialExperiment for spatial samples")
511
+ return RExperiment(
512
+ class_name="Seurat", assays=players, obs=obs, var=pvar, spatial=None, reduced_dims=reduced, images=[],
513
+ feature_sets=sets, fragments=fragments, assay_name=primary, notes=notes,
514
+ )
515
+
516
+
517
+ # ---------------------------------------------------------------- public API
518
+ def read_experiment(path: str | Path, object_name: str | None = None) -> RExperiment:
519
+ """Parse a SpatialExperiment / SingleCellExperiment / SummarizedExperiment / Seurat object from .rds or .rda/.RData."""
520
+ path = Path(path)
521
+ if path.suffix.lower() in (".rda", ".rdata"):
522
+ objs = _parse(path, object_name)
523
+ if not isinstance(objs, dict):
524
+ raise ValueError(f"{path}: could not read the .rda file")
525
+ candidates = {k: v for k, v in objs.items() if _cls(v) in SUPPORTED_CLASSES}
526
+ if object_name:
527
+ if object_name not in objs:
528
+ raise KeyError(f"{path}: no object named {object_name!r}; found {list(objs)}")
529
+ root = objs[object_name]
530
+ elif len(candidates) == 1:
531
+ root = next(iter(candidates.values()))
532
+ else:
533
+ raise ValueError(f"{path}: contains {list(objs)}; pick one with --object")
534
+ else:
535
+ root = _parse(path, None)
536
+ cls = _cls(root)
537
+ if cls == "Seurat":
538
+ return _read_seurat(root, path)
539
+ if cls not in SUPPORTED_CLASSES:
540
+ raise ValueError(f"{path}: expected a SpatialExperiment / SingleCellExperiment / Seurat object, got {cls}")
541
+ a = _attrs(root)
542
+
543
+ # assays
544
+ assays_list = _attrs(_attrs(a["assays"]).get("data")).get("listData")
545
+ assays = {name: _matrix(m) for name, m in _named_list(assays_list).items()}
546
+ if not assays:
547
+ raise ValueError(f"{path}: no assays")
548
+ first = next(iter(assays.values()))
549
+ n_vars, n_obs = first.shape
550
+
551
+ # names: rowRanges partitioning NAMES (RangedSE) or NAMES slot; colData rownames; else Dimnames
552
+ rownames = None
553
+ rr = a.get("rowRanges")
554
+ if not _is_null(rr):
555
+ part = _attrs(rr).get("partitioning")
556
+ if not _is_null(part) and not _is_null(_attrs(part).get("NAMES")):
557
+ rownames = _strings(_attrs(part)["NAMES"])
558
+ if rownames is None and not _is_null(a.get("NAMES")):
559
+ rownames = _strings(a["NAMES"])
560
+ col_rn, col_cols = _dframe(a["colData"])
561
+ dn_rows, dn_cols = _dimnames(_named_list(assays_list)[next(iter(assays))])
562
+ if rownames is None or len(rownames) != n_vars:
563
+ rownames = dn_rows
564
+ colnames = col_rn if col_rn else dn_cols
565
+ if rownames is None or len(rownames) != n_vars:
566
+ rownames = [f"gene{i}" for i in range(n_vars)]
567
+ if colnames is None or len(colnames) != n_obs:
568
+ colnames = [f"cell{i}" for i in range(n_obs)]
569
+
570
+ # colData
571
+ obs = pd.DataFrame(index=pd.Index([str(c) for c in colnames], name=None))
572
+ for name, col in col_cols.items():
573
+ s = _vector_to_series(col, n_obs)
574
+ if s is not None:
575
+ obs[name] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.values
576
+ # rowData: elementMetadata of rowRanges (RangedSE) or of the object
577
+ var = pd.DataFrame(index=pd.Index([str(r) for r in rownames]))
578
+ em = _attrs(rr).get("elementMetadata") if not _is_null(rr) else None
579
+ if _is_null(em):
580
+ em = a.get("elementMetadata")
581
+ if not _is_null(em):
582
+ _, rcols = _dframe(em)
583
+ for name, col in rcols.items():
584
+ s = _vector_to_series(col, n_vars)
585
+ if s is not None:
586
+ var[name] = s.to_numpy() if not isinstance(s.dtype, pd.CategoricalDtype) else s.astype(str).to_numpy()
587
+
588
+ # int_colData: reducedDims, spatialCoords
589
+ spatial = None
590
+ reduced: dict[str, np.ndarray] = {}
591
+ icd = a.get("int_colData")
592
+ if not _is_null(icd):
593
+ _, icols = _dframe(icd)
594
+ sc = icols.get("spatialCoords")
595
+ if not _is_null(sc) and "dim" in _attrs(sc):
596
+ dim = [int(v) for v in np.asarray(_attrs(sc)["dim"]["data"])]
597
+ spatial = np.asarray(sc["data"], dtype=np.float64).reshape(dim, order="F")[:, :2]
598
+ rd = icols.get("reducedDims")
599
+ if not _is_null(rd):
600
+ _, rcols = _dframe(rd)
601
+ for name, m in rcols.items():
602
+ if isinstance(m, dict) and "dim" in _attrs(m) and m.get("type") in ("double", "integer"):
603
+ dim = [int(v) for v in np.asarray(_attrs(m)["dim"]["data"])]
604
+ reduced[name] = np.asarray(m["data"], dtype=np.float64).reshape(dim, order="F")
605
+ images = _images(_named_list(a.get("int_metadata"))) if not _is_null(a.get("int_metadata")) else []
606
+ return RExperiment(class_name=cls or "", assays=assays, obs=obs, var=var, spatial=spatial, reduced_dims=reduced, images=images)
607
+
608
+
609
+ SUPPORTED_CLASSES = {"SpatialExperiment", "SingleCellExperiment", "SummarizedExperiment", "RangedSummarizedExperiment", "Seurat"}