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
@@ -0,0 +1,300 @@
1
+ """Expression matrix: normalization detection, uint8 quantization, gene-major zarr v3 writer."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import anndata as ad
8
+ import numpy as np
9
+ import scipy.sparse as sp
10
+ import zarr
11
+ from zarr.codecs import ZstdCodec
12
+
13
+ TARGET_SHARD_BYTES = 128 * 1024 * 1024
14
+ ENTRY_BLOCK = 4 * 1024 * 1024 # stored values quantized per step: bounds the per-entry temporaries, not the shard
15
+
16
+
17
+ @dataclass
18
+ class ExprResult:
19
+ gmax: np.ndarray # float32 (n_genes_kept,)
20
+ keep: np.ndarray # bool (n_vars,) genes retained (non-zero)
21
+ shard_genes: int
22
+ sharded: bool
23
+ gmin: np.ndarray | None = None # float32 (n_genes_kept,): 0 for non-negative features, -gmax for signed ones
24
+
25
+
26
+ def get_matrix(adata: ad.AnnData, layer: str) -> sp.csr_matrix | sp.csc_matrix:
27
+ """The (n_obs, n_vars) matrix as float32, CSR or CSC as stored: h5ad files hold CSC, and the writer wants CSC,
28
+ so converting to CSR here only to convert back cost a full pass over the data twice."""
29
+ M = adata.X if layer == "X" else adata.layers[layer]
30
+ if sp.issparse(M):
31
+ M = M.tocsc() if M.format not in ("csr", "csc") else M
32
+ else:
33
+ M = sp.csc_matrix(np.asarray(M))
34
+ return M.astype(np.float32, copy=False)
35
+
36
+
37
+ def detect_lognorm(M: sp.csr_matrix, sample: int = 1_000_000) -> bool:
38
+ data = M.data
39
+ if data.size == 0:
40
+ return True
41
+ if data.size > sample:
42
+ rng = np.random.default_rng(0)
43
+ data = data[rng.choice(data.size, sample, replace=False)]
44
+ # log-normalized data is essentially never all-integer; raw counts always are (also at low depth, where the
45
+ # old "max <= 20" rule mistook Visium HD bins and small-panel runs for normalized data)
46
+ return not np.allclose(data, np.rint(data))
47
+
48
+
49
+ def log1p_cp10k(M):
50
+ """log1p(CP10k) of a CSR/CSC counts matrix: each stored value times its row's factor, in a new matrix that shares
51
+ the index arrays with the input. `diags(scale) @ M` was a full sparse product whose result came back with
52
+ unsorted indices, so every later reduction sorted the whole matrix again."""
53
+ if not sp.issparse(M) or M.format not in ("csr", "csc"):
54
+ M = sp.csc_matrix(M)
55
+ tot = np.asarray(M.sum(axis=1)).ravel()
56
+ scale = np.where(tot > 0, 1e4 / np.maximum(tot, 1e-12), 0.0).astype(np.float32)
57
+ rows = M.indices if M.format == "csc" else np.repeat(np.arange(M.shape[0]), np.diff(M.indptr))
58
+ data = M.data.astype(np.float32) # a copy: the caller's matrix is never modified
59
+ data *= scale[rows]
60
+ np.log1p(data, out=data)
61
+ out = M.__class__((data, M.indices, M.indptr), shape=M.shape)
62
+ if M.has_canonical_format:
63
+ out.has_canonical_format = True
64
+ return out
65
+
66
+
67
+ def choose_shard_genes(n_obs: int, requested: int) -> int:
68
+ return int(max(1, min(requested, TARGET_SHARD_BYTES // max(n_obs, 1))))
69
+
70
+
71
+ ZSTD_LEVEL = 7
72
+ EMPTY = np.iinfo(np.uint64).max # a shard index entry for a chunk that was never written
73
+
74
+
75
+ class ShardWriter:
76
+ """Writes the gene-major uint8 array's chunks itself, in the exact zarr v3 layout zarr-python declared in
77
+ zarr.json: per shard `c/<i>/0` = the zstd frames of its rows back to back, then the index (offset, nbytes as
78
+ little-endian uint64 per inner chunk, EMPTY for chunks past the end) followed by its crc32c; unsharded, one
79
+ `c/<g>/0` per gene. Going around zarr-python's per-chunk pipeline removes the few hundred thousand asyncio
80
+ tasks that made up ~90% of a build, and lets the zstd calls run in threads (they release the GIL)."""
81
+
82
+ def __init__(self, array_dir: Path, n_obs: int, shard_genes: int, *, sharded: bool, level: int = ZSTD_LEVEL, workers: int | None = None):
83
+ import os
84
+ from concurrent.futures import ThreadPoolExecutor
85
+
86
+ from numcodecs import Zstd
87
+
88
+ self.dir = Path(array_dir) / "c"
89
+ self.n_obs = n_obs
90
+ self.S = shard_genes
91
+ self.sharded = sharded
92
+ self.codec = Zstd(level=level)
93
+ self.pool = ThreadPoolExecutor(max_workers=workers or min(8, os.cpu_count() or 1))
94
+
95
+ def _encode_rows(self, q: np.ndarray) -> list[bytes]:
96
+ rows = [np.ascontiguousarray(q[i]) for i in range(q.shape[0])]
97
+ return list(self.pool.map(lambda r: bytes(self.codec.encode(r)), rows))
98
+
99
+ def write(self, g0: int, q: np.ndarray) -> None:
100
+ """Rows g0..g0+len(q) of the array; with sharding, g0 must be a multiple of the shard size."""
101
+ import google_crc32c
102
+
103
+ frames = self._encode_rows(q)
104
+ if not self.sharded:
105
+ for k, frame in enumerate(frames):
106
+ d = self.dir / str(g0 + k)
107
+ d.mkdir(parents=True, exist_ok=True)
108
+ (d / "0").write_bytes(frame)
109
+ return
110
+ if g0 % self.S:
111
+ raise ValueError("shard writes must start on a shard boundary")
112
+ index = np.full((self.S, 2), EMPTY, dtype="<u8")
113
+ offset = 0
114
+ for k, frame in enumerate(frames):
115
+ index[k] = (offset, len(frame))
116
+ offset += len(frame)
117
+ idx_bytes = index.tobytes()
118
+ d = self.dir / str(g0 // self.S)
119
+ d.mkdir(parents=True, exist_ok=True)
120
+ with open(d / "0", "wb") as fh:
121
+ fh.writelines(frames)
122
+ fh.write(idx_bytes)
123
+ fh.write(np.uint32(google_crc32c.value(idx_bytes)).tobytes())
124
+
125
+ def close(self) -> None:
126
+ self.pool.shutdown(wait=True)
127
+
128
+
129
+ def column_ranges(Mc: sp.csc_matrix) -> tuple[np.ndarray, np.ndarray]:
130
+ """Per-column (min, max) of a CSC matrix including its implicit zeros, straight from indptr/data. scipy's
131
+ `.max(axis=0)` first sorts and deduplicates the indices of a matrix read from disk, a 4 s pass on a 15k x 37k
132
+ object; this reads the data once and needs no particular index order."""
133
+ n_obs, n_vars = Mc.shape
134
+ nnz_col = np.diff(Mc.indptr)
135
+ gmax = np.zeros(n_vars, dtype=np.float32)
136
+ gmin = np.zeros(n_vars, dtype=np.float32)
137
+ has = nnz_col > 0
138
+ if has.any():
139
+ starts = Mc.indptr[:-1][has]
140
+ data = Mc.data
141
+ gmax[has] = np.maximum.reduceat(data, starts)
142
+ gmin[has] = np.minimum.reduceat(data, starts)
143
+ sparse_cols = nnz_col < n_obs # an implicit zero takes part in the range
144
+ gmax = np.where(sparse_cols, np.maximum(gmax, 0), gmax).astype(np.float32)
145
+ gmin = np.where(sparse_cols, np.minimum(gmin, 0), gmin).astype(np.float32)
146
+ return gmin, gmax
147
+
148
+
149
+ def column_moments(Mc: sp.csc_matrix) -> tuple[np.ndarray, np.ndarray]:
150
+ """Per-column (mean, variance) of a CSC matrix over all rows, implicit zeros included, in one pass over the data
151
+ and in any index order (scipy's `.mean()` sorts and deduplicates the matrix first)."""
152
+ n_obs, n_vars = Mc.shape
153
+ nnz_col = np.diff(Mc.indptr)
154
+ s1 = np.zeros(n_vars)
155
+ s2 = np.zeros(n_vars)
156
+ has = nnz_col > 0
157
+ if has.any():
158
+ starts = Mc.indptr[:-1][has]
159
+ d = Mc.data.astype(np.float64, copy=False)
160
+ s1[has] = np.add.reduceat(d, starts)
161
+ s2[has] = np.add.reduceat(d * d, starts)
162
+ n = max(n_obs, 1)
163
+ mean = s1 / n
164
+ return mean, np.maximum(s2 / n - mean * mean, 0.0)
165
+
166
+
167
+ def feature_ranges(gmin: np.ndarray, gmax: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
168
+ """The [lo, hi] each feature is quantized over: [0, max] for non-negative features; for features with negative
169
+ values (motif deviations, module scores) a symmetric [-m, m] with m = max |x|, so zero sits at the centre."""
170
+ signed = gmin < 0
171
+ m = np.maximum(np.abs(gmin), np.abs(gmax))
172
+ lo = np.where(signed, -m, 0.0).astype(np.float32)
173
+ hi = np.where(signed, m, gmax).astype(np.float32)
174
+ return lo, hi
175
+
176
+
177
+ def quantize_values(x: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> np.ndarray:
178
+ """uint8 codes of x over [lo, hi] (lo/hi broadcast against x): code = rint((x - lo) * 255 / (hi - lo)), and a
179
+ positive value of a non-negative feature never rounds to 0, so an expressed cell never renders as zero."""
180
+ lo = np.asarray(lo, dtype=np.float32)
181
+ span = (np.asarray(hi, dtype=np.float32) - lo).astype(np.float32)
182
+ scale = np.where(span > 0, 255.0 / np.maximum(span, 1e-12), 0.0).astype(np.float32)
183
+ q = np.subtract(x, lo, dtype=np.float32) # one working buffer, updated in place
184
+ np.multiply(q, scale, out=q)
185
+ np.rint(q, out=q)
186
+ np.clip(q, 0, 255, out=q)
187
+ bump = (lo >= 0) & (x > 0) & (q < 1)
188
+ if bump.any():
189
+ q[bump] = 1
190
+ return q.astype(np.uint8)
191
+
192
+
193
+ def quantize_block(block: np.ndarray, gmax: np.ndarray, gmin: np.ndarray | None = None) -> np.ndarray:
194
+ """block (g, n) float32 -> uint8 over [gmin, gmax] per row (gmin defaults to 0). value = gmin + u8 / 255 * (gmax - gmin)."""
195
+ lo = np.zeros_like(gmax, dtype=np.float32) if gmin is None else np.asarray(gmin, dtype=np.float32)
196
+ return quantize_values(block, lo[:, None], np.asarray(gmax, dtype=np.float32)[:, None])
197
+
198
+
199
+ def write_expression(
200
+ M: sp.csr_matrix,
201
+ out_dir: Path,
202
+ *,
203
+ shard_genes: int = 512,
204
+ sharded: bool = True,
205
+ keep_f16: bool = False,
206
+ order: np.ndarray | None = None,
207
+ log=lambda *_: None,
208
+ ) -> ExprResult:
209
+ """M is (n_obs, n_vars) normalized, CSR or CSC. Writes out_dir/expr.zarr/{u8,gmax[,f16]}. With `order` (a
210
+ permutation), row i of the written array is observation order[i]: the LOD shuffle is applied while scattering
211
+ the stored values, so the matrix itself is never permuted (a copy with unsorted indices that every later
212
+ reduction would sort again)."""
213
+ n_obs, n_vars = M.shape
214
+ Mc = M.tocsc()
215
+ if Mc.nnz and not Mc.has_canonical_format:
216
+ Mc = Mc.copy() # duplicate entries must sum, as in a dense view; canonicalise a copy, never the caller's matrix
217
+ Mc.sum_duplicates()
218
+ inv = None
219
+ if order is not None:
220
+ order = np.asarray(order)
221
+ if order.shape != (n_obs,) or not np.array_equal(np.sort(order), np.arange(n_obs)):
222
+ raise ValueError("order must be a permutation of the observations")
223
+ inv = np.empty(n_obs, dtype=np.int64)
224
+ inv[order] = np.arange(n_obs)
225
+ gmax_all = np.zeros(n_vars, dtype=np.float32)
226
+ gmin_all = np.zeros(n_vars, dtype=np.float32)
227
+ if Mc.nnz:
228
+ if not np.all(np.isfinite(Mc.data)):
229
+ bad = np.unique(Mc.indices[~np.isfinite(Mc.data)]) if hasattr(Mc, "indices") else []
230
+ cols = np.repeat(np.arange(n_vars), np.diff(Mc.indptr))[~np.isfinite(Mc.data)] if hasattr(Mc, "indptr") else bad
231
+ names = ", ".join(str(c) for c in np.unique(cols)[:5])
232
+ raise ValueError(f"expression has NaN or infinite values (feature columns {names}{'...' if len(np.unique(cols)) > 5 else ''}); fix or drop those features before building")
233
+ gmin_all, gmax_all = column_ranges(Mc)
234
+ lo_all, hi_all = feature_ranges(gmin_all, gmax_all)
235
+ keep = hi_all > 0 # all-zero features are dropped
236
+ gmax = hi_all[keep]
237
+ gmin = lo_all[keep]
238
+ G = int(keep.sum())
239
+
240
+ S = choose_shard_genes(n_obs, shard_genes) if sharded else max(1, min(shard_genes, G))
241
+ store = zarr.storage.LocalStore(str(out_dir / "expr.zarr"))
242
+ root = zarr.open_group(store, mode="w", zarr_format=3)
243
+ kwargs: dict = {
244
+ "shape": (G, n_obs),
245
+ "chunks": (1, n_obs),
246
+ "compressors": [ZstdCodec(level=ZSTD_LEVEL)],
247
+ "fill_value": 0,
248
+ "dimension_names": ("gene", "obs"),
249
+ }
250
+ if sharded:
251
+ kwargs["shards"] = (S, n_obs)
252
+ root.create_array("u8", dtype="uint8", **kwargs) # zarr writes the metadata; the chunks below are ours
253
+ f16 = root.create_array("f16", dtype="float16", **kwargs) if keep_f16 else None
254
+ writer = ShardWriter(out_dir / "expr.zarr" / "u8", n_obs, S if sharded else 1, sharded=sharded)
255
+
256
+ # Only the stored values are quantized; implicit zeros are the shard's fill (0 unless the feature is signed).
257
+ # That is one pass over the nnz values instead of six over a dense n_genes x n_obs float32 block, and the
258
+ # kept columns are gathered per block straight out of the input, so the matrix is never copied or sliced.
259
+ kept = np.flatnonzero(keep) # input column of each kept gene
260
+ nnz_col = np.diff(Mc.indptr)
261
+ kcum = np.zeros(G + 1, dtype=np.int64) # stored values before each kept gene
262
+ np.cumsum(nnz_col[kept], out=kcum[1:])
263
+ zero_code = quantize_values(np.zeros(G, dtype=np.float32), gmin, gmax)
264
+ for g0 in range(0, G, S):
265
+ g1 = min(G, g0 + S)
266
+ q = np.empty((g1 - g0, n_obs), dtype=np.uint8)
267
+ q[:] = zero_code[g0:g1, None]
268
+ h = np.zeros((g1 - g0, n_obs), dtype=np.float16) if keep_f16 else None
269
+ b0 = g0
270
+ while b0 < g1:
271
+ # whole genes holding about ENTRY_BLOCK stored values (at least one gene)
272
+ target = min(int(kcum[b0]) + ENTRY_BLOCK, int(kcum[-1]))
273
+ b1 = max(b0 + 1, min(g1, int(np.searchsorted(kcum, target, side="right")) - 1))
274
+ n_vals = int(kcum[b1] - kcum[b0])
275
+ if n_vals:
276
+ lengths = nnz_col[kept[b0:b1]]
277
+ starts = Mc.indptr[kept[b0:b1]].astype(np.int64)
278
+ # position in Mc.data of every stored value of the block, column segments back to back
279
+ pos = np.arange(n_vals, dtype=np.int64) + np.repeat(starts - (np.cumsum(lengths) - lengths), lengths)
280
+ g = np.repeat(np.arange(b0, b1, dtype=np.int64), lengths) # kept-gene index per stored value
281
+ rows = Mc.indices[pos]
282
+ if inv is not None:
283
+ rows = inv[rows]
284
+ flat = (g - g0) * n_obs + rows
285
+ vals = Mc.data[pos]
286
+ q.reshape(-1)[flat] = quantize_values(vals, gmin[g], gmax[g])
287
+ if h is not None:
288
+ h.reshape(-1)[flat] = vals.astype(np.float16)
289
+ b0 = b1
290
+ writer.write(g0, q)
291
+ if f16 is not None:
292
+ f16[g0:g1, :] = h
293
+ log(f" expr genes {g1}/{G}")
294
+ writer.close()
295
+
296
+ root.create_array("gmax", shape=(G,), chunks=(max(G, 1),), dtype="float32", compressors=[ZstdCodec(level=3)])
297
+ root["gmax"][:] = gmax
298
+ root.create_array("gmin", shape=(G,), chunks=(max(G, 1),), dtype="float32", compressors=[ZstdCodec(level=3)])
299
+ root["gmin"][:] = gmin
300
+ return ExprResult(gmax=gmax, keep=keep, shard_genes=S, sharded=sharded, gmin=gmin)
@@ -0,0 +1,152 @@
1
+ """Per-sample build fingerprints: how `build` decides whether a sample folder is still current.
2
+
3
+ After building a sample, `build` writes `samples/<id>/build.json` holding a fingerprint of everything that
4
+ went into it: the resolved sample settings, the size and modification time of every input file, the
5
+ declarations of the fields it maps (type, vocabulary, category order, aliases), the shard settings and the
6
+ format and recipe versions. The next `build` recomputes the fingerprint from the yaml and the files and
7
+ rebuilds the sample only when it differs. Display names, group labels and palette colors are not part of it:
8
+ they live in the dataset-level files, which every `build` rewrites.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+
18
+ from . import __version__
19
+ from .config import DatasetConfig, MicronsSpec, SampleSpec
20
+ from .discover import auto_images
21
+ from .manifest import FORMAT_VERSION
22
+
23
+ BUILD_RECIPE = 1 # bump when build_sample writes something different for the same inputs and settings
24
+ SIDECAR = "build.json"
25
+
26
+
27
+ def _remote_stat(url: str) -> dict:
28
+ """Size and mtime of an `ssh://host/path` file, asked over ssh (read-only); the URL alone when that fails."""
29
+ import subprocess
30
+
31
+ from .tracks import split_ssh_url
32
+
33
+ host, remote = split_ssh_url(url)
34
+ cmd = f"stat -c '%s %Y' {_q(remote)} 2>/dev/null || stat -f '%z %m' {_q(remote)}"
35
+ try:
36
+ out = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", host, cmd], capture_output=True, text=True, timeout=60)
37
+ size, mtime = out.stdout.split()[:2]
38
+ return {"size": int(size), "mtime_ns": int(mtime) * 1_000_000_000}
39
+ except Exception:
40
+ return {"remote": url}
41
+
42
+
43
+ def _q(s: str) -> str:
44
+ import shlex
45
+
46
+ return shlex.quote(s)
47
+
48
+
49
+ def _stat(path: Path | str) -> dict | None:
50
+ """Size and mtime of a file, or a digest over the file list of a directory (Zarr stores, OME-Zarr images)."""
51
+ if isinstance(path, str) and path.startswith("ssh://"):
52
+ return _remote_stat(path)
53
+ p = Path(path)
54
+ if not p.exists():
55
+ return None
56
+ if p.is_file():
57
+ st = p.stat()
58
+ return {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
59
+ h = hashlib.sha1()
60
+ n = 0
61
+ for root, dirs, files in os.walk(p):
62
+ dirs.sort()
63
+ for f in sorted(files):
64
+ fp = Path(root) / f
65
+ try:
66
+ st = fp.stat()
67
+ except OSError:
68
+ continue
69
+ h.update(f"{fp.relative_to(p)}\0{st.st_size}\0{st.st_mtime_ns}\n".encode())
70
+ n += 1
71
+ return {"tree": h.hexdigest(), "files": n}
72
+
73
+
74
+ def resolve_auto_images(spec: SampleSpec) -> SampleSpec:
75
+ """`images: auto` and the micron rule the files next to the sample imply (SpaceRanger scale factors), resolved
76
+ the way build_sample does it. The scale is taken even when the images are listed or switched off (`images: []`),
77
+ so dropping a section's image does not lose its scale."""
78
+ auto = spec.images == "auto"
79
+ if not auto and spec.microns != MicronsSpec():
80
+ return spec
81
+ imgs, microns = auto_images(spec if auto else spec.model_copy(update={"images": "auto"}))
82
+ upd: dict = {"images": imgs} if auto else {}
83
+ if microns is not None and spec.microns == MicronsSpec():
84
+ upd["microns"] = microns
85
+ return spec.model_copy(update=upd) if upd else spec
86
+
87
+
88
+ def sample_fingerprint(spec: SampleSpec, cfg: DatasetConfig, *, sharded: bool) -> dict:
89
+ resolved = resolve_auto_images(spec)
90
+ spec_json = resolved.model_dump(mode="json", exclude={"name", "group"})
91
+ paths: list[Path | None] = [resolved.path, resolved.microns.scalefactors_json]
92
+ paths += [im.path for im in resolved.images]
93
+ if resolved.polygons is not None and resolved.polygons.path is not None:
94
+ paths.append(resolved.polygons.path)
95
+ if resolved.tracks is not None:
96
+ from .tracks import TRACKS_RECIPE
97
+
98
+ paths += resolved.tracks.input_files
99
+ spec_json["tracks"]["recipe"] = TRACKS_RECIPE # a changed track writer rebuilds only the samples that carry tracks
100
+ inputs = {str(p): _stat(p) for p in paths if p is not None}
101
+ fields = []
102
+ for fid in resolved.fields if isinstance(resolved.fields, dict) else {}:
103
+ f = cfg.field_spec(fid)
104
+ if f is not None:
105
+ fields.append({"id": f.id, "type": f.type, "vocabulary": f.vocabulary or f.id, "categories": f.categories, "aliases": f.aliases})
106
+ fp = {
107
+ "recipe": BUILD_RECIPE,
108
+ "format": FORMAT_VERSION,
109
+ "spec": spec_json,
110
+ "inputs": inputs,
111
+ "fields": fields,
112
+ "shard_genes": cfg.shard_genes,
113
+ "sharded": sharded,
114
+ }
115
+ return json.loads(json.dumps(fp, sort_keys=True, default=str)) # the same shape the sidecar comes back in
116
+
117
+
118
+ def why_stale(old: dict | None, new: dict) -> str | None:
119
+ """None when the sample folder is current, else a short reason for rebuilding it."""
120
+ if old is None:
121
+ return "new"
122
+ if old == new:
123
+ return None
124
+ if old.get("recipe") != new["recipe"] or old.get("format") != new["format"]:
125
+ return "format changed"
126
+ if old.get("inputs") != new["inputs"]:
127
+ return "source changed"
128
+ if old.get("sharded") != new["sharded"]:
129
+ return "sharding changed"
130
+ if old.get("shard_genes") != new["shard_genes"]:
131
+ return "shard size changed"
132
+ if old.get("spec") != new["spec"]:
133
+ return "settings changed"
134
+ if old.get("fields") != new["fields"]:
135
+ return "field declarations changed"
136
+ return "settings changed"
137
+
138
+
139
+ def read_sidecar(sample_dir: Path) -> dict | None:
140
+ p = Path(sample_dir) / SIDECAR
141
+ if not p.exists():
142
+ return None
143
+ try:
144
+ return json.loads(p.read_text()).get("fingerprint")
145
+ except (OSError, ValueError, AttributeError):
146
+ return None
147
+
148
+
149
+ def write_sidecar(sample_dir: Path, fingerprint: dict) -> Path:
150
+ p = Path(sample_dir) / SIDECAR
151
+ p.write_text(json.dumps({"fingerprint": fingerprint, "built": datetime.now(UTC).isoformat(timespec="seconds"), "spatialscape": __version__}, indent=1))
152
+ return p