Spacelab 0.28.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 (62) hide show
  1. spacelab/__init__.py +6 -0
  2. spacelab/__main__.py +3 -0
  3. spacelab/cli.py +97 -0
  4. spacelab/hlod/__init__.py +8 -0
  5. spacelab/hlod/__main__.py +5 -0
  6. spacelab/hlod/build.py +349 -0
  7. spacelab/hlod/cells.py +82 -0
  8. spacelab/hlod/cli.py +90 -0
  9. spacelab/hlod/hashing.py +38 -0
  10. spacelab/hlod/hilbert.py +91 -0
  11. spacelab/hlod/read.py +150 -0
  12. spacelab/hlod/verify.py +142 -0
  13. spacelab/prep_spatialdata.py +700 -0
  14. spacelab/server.py +130 -0
  15. spacelab/static/AI_lens_black.png +0 -0
  16. spacelab/static/ai-new-logo.png +0 -0
  17. spacelab/static/aifi_logo.png +0 -0
  18. spacelab/static/assets/AllenInstitutePlusText-Bold-DxLtQ4oE.woff2 +0 -0
  19. spacelab/static/assets/AllenInstitutePlusText-BoldItalic-mxzHQhQO.woff2 +0 -0
  20. spacelab/static/assets/AllenInstitutePlusText-Italic-CaKmxpsW.woff2 +0 -0
  21. spacelab/static/assets/AllenInstitutePlusText-Light-DUThXl1m.woff2 +0 -0
  22. spacelab/static/assets/AllenInstitutePlusText-LightItalic-CxfKknzG.woff2 +0 -0
  23. spacelab/static/assets/AllenInstitutePlusText-Regular-B0ryviHQ.woff2 +0 -0
  24. spacelab/static/assets/AllenInstitutePlusText-Semibold-3PoiDF1d.woff2 +0 -0
  25. spacelab/static/assets/AllenInstitutePlusText-SemiboldItalic-uXaVqnFo.woff2 +0 -0
  26. spacelab/static/assets/blosc-BKQbfHyB.js +18 -0
  27. spacelab/static/assets/blosc-j6ygecpn.js +18 -0
  28. spacelab/static/assets/chunk-INHXZS53-D3tQiqtZ.js +2 -0
  29. spacelab/static/assets/chunk-INHXZS53-QjxmivJp.js +2 -0
  30. spacelab/static/assets/deflate-BRVR_fNk.js +2 -0
  31. spacelab/static/assets/hlodTranscripts.worker-CjhNLk56.js +2 -0
  32. spacelab/static/assets/index-BT-mDgg2.js +5025 -0
  33. spacelab/static/assets/index-CNIqsj6m.css +1 -0
  34. spacelab/static/assets/jpeg-JUDfHHDo.js +2 -0
  35. spacelab/static/assets/lerc-CXQdKTps.js +2 -0
  36. spacelab/static/assets/lz4-C2MQfzIA.js +16 -0
  37. spacelab/static/assets/lz4-rzy_Siro.js +16 -0
  38. spacelab/static/assets/lzw-DBCCrYEo.js +2 -0
  39. spacelab/static/assets/packbits-LrtZ2mhj.js +2 -0
  40. spacelab/static/assets/pako.esm-C69tvH9t.js +2 -0
  41. spacelab/static/assets/polygons.worker-ClOPdWlW.js +2 -0
  42. spacelab/static/assets/raw-lPmjnySZ.js +2 -0
  43. spacelab/static/assets/spatialdataExpression.worker-D4W_OHS3.js +4 -0
  44. spacelab/static/assets/spatialdataShapes.worker-DQV1zeTt.js +974 -0
  45. spacelab/static/assets/spatialdataTranscripts.worker-DH9L-MwZ.js +974 -0
  46. spacelab/static/assets/transcripts.worker-CtxyO4n3.js +2 -0
  47. spacelab/static/assets/umap.worker-BPHuoLPV.js +2 -0
  48. spacelab/static/assets/webimage-xg0Pp5Jv.js +2 -0
  49. spacelab/static/assets/zstd-Cp7BEDDH.js +16 -0
  50. spacelab/static/assets/zstd-WlDjm0Vu.js +16 -0
  51. spacelab/static/blurred-bg.jpg +0 -0
  52. spacelab/static/favicon.ico +0 -0
  53. spacelab/static/index.html +22 -0
  54. spacelab/static/logo192.png +0 -0
  55. spacelab/static/logo512.png +0 -0
  56. spacelab/static/manifest.json +15 -0
  57. spacelab/static/robots.txt +3 -0
  58. spacelab-0.28.0.dist-info/METADATA +30 -0
  59. spacelab-0.28.0.dist-info/RECORD +62 -0
  60. spacelab-0.28.0.dist-info/WHEEL +4 -0
  61. spacelab-0.28.0.dist-info/entry_points.txt +2 -0
  62. spacelab-0.28.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,38 @@
1
+ """splitmix64, vectorized over uint64 arrays.
2
+
3
+ hash(k) is the first output of a splitmix64 generator whose state is k, so
4
+ hash(seed_state) reproduces the published sequence for that seed.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ GOLDEN = 0x9E3779B97F4A7C15
10
+ _M1 = 0xBF58476D1CE4E5B9
11
+ _M2 = 0x94D049BB133111EB
12
+ _MASK = (1 << 64) - 1
13
+
14
+
15
+ def splitmix64_scalar(k: int) -> int:
16
+ z = (k + GOLDEN) & _MASK
17
+ z = ((z ^ (z >> 30)) * _M1) & _MASK
18
+ z = ((z ^ (z >> 27)) * _M2) & _MASK
19
+ return z ^ (z >> 31)
20
+
21
+
22
+ def splitmix64(k: np.ndarray) -> np.ndarray:
23
+ z = np.asarray(k, dtype=np.uint64)
24
+ with np.errstate(over="ignore"): # wrapping uint64 arithmetic is the algorithm
25
+ z = z + np.uint64(GOLDEN)
26
+ z = (z ^ (z >> np.uint64(30))) * np.uint64(_M1)
27
+ z = (z ^ (z >> np.uint64(27))) * np.uint64(_M2)
28
+ return z ^ (z >> np.uint64(31))
29
+
30
+
31
+ def gene_xy_key(gene_code: int, x: np.ndarray, y: np.ndarray) -> np.ndarray:
32
+ """Fold the bytes of (gene_code uint32, x float32, y float32) into a uint64
33
+ key, for sources without a transcript id. Duplicate (x, y) within a gene
34
+ share a key, so they land in the same level."""
35
+ xb = np.ascontiguousarray(x, dtype="<f4").view("<u4").astype(np.uint64)
36
+ yb = np.ascontiguousarray(y, dtype="<f4").view("<u4").astype(np.uint64)
37
+ hi = (np.uint64(gene_code) << np.uint64(32)) | xb
38
+ return hi ^ splitmix64(yb)
@@ -0,0 +1,91 @@
1
+ """Hilbert curve index on a 2^order x 2^order grid.
2
+
3
+ The standard iterative xy2d (Wikipedia's), fixed so the TypeScript client
4
+ (src/hlod/hilbert.ts) produces identical indices. Its prefix property is what
5
+ the store relies on: the depth-m cell of a point is the top 2m bits of its
6
+ order-K index, so every quadtree cell is one contiguous run of h-sorted rows.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ # Rows per vectorized block; bounds the temporaries to a few hundred MB.
12
+ BLOCK = 10_000_000
13
+
14
+
15
+ def xy2d_scalar(order: int, x: int, y: int) -> int:
16
+ n = 1 << order
17
+ d = 0
18
+ s = n >> 1
19
+ while s > 0:
20
+ rx = 1 if x & s else 0
21
+ ry = 1 if y & s else 0
22
+ d += s * s * ((3 * rx) ^ ry)
23
+ if ry == 0:
24
+ if rx == 1:
25
+ x = n - 1 - x # n, not s
26
+ y = n - 1 - y
27
+ x, y = y, x
28
+ s >>= 1
29
+ return d
30
+
31
+
32
+ def d2xy_scalar(order: int, d: int) -> tuple[int, int]:
33
+ """Inverse of xy2d_scalar; used by the tests only."""
34
+ x = y = 0
35
+ t = d
36
+ s = 1
37
+ n = 1 << order
38
+ while s < n:
39
+ rx = 1 & (t // 2)
40
+ ry = 1 & (t ^ rx)
41
+ if ry == 0:
42
+ if rx == 1:
43
+ x = s - 1 - x
44
+ y = s - 1 - y
45
+ x, y = y, x
46
+ x += s * rx
47
+ y += s * ry
48
+ t //= 4
49
+ s <<= 1
50
+ return x, y
51
+
52
+
53
+ def _xy2d_block(order: int, x: np.ndarray, y: np.ndarray) -> np.ndarray:
54
+ n = np.uint32((1 << order) - 1)
55
+ x = x.astype(np.uint32, copy=True)
56
+ y = y.astype(np.uint32, copy=True)
57
+ # uint64: at order 16 the last step can add 3 * 4^15 to a sum near 4^16.
58
+ d = np.zeros(x.shape, dtype=np.uint64)
59
+ s = 1 << (order - 1)
60
+ while s > 0:
61
+ rx = (x & np.uint32(s)) != 0
62
+ ry = (y & np.uint32(s)) != 0
63
+ d += np.uint64(s * s) * ((3 * rx.astype(np.uint64)) ^ ry.astype(np.uint64))
64
+ flip = ~ry & rx
65
+ x[flip] = n - x[flip]
66
+ y[flip] = n - y[flip]
67
+ swap = ~ry
68
+ x[swap], y[swap] = y[swap], x[swap].copy()
69
+ s >>= 1
70
+ return d
71
+
72
+
73
+ def xy2d(order: int, x: np.ndarray, y: np.ndarray) -> np.ndarray:
74
+ """Vectorized xy2d. uint32 for order <= 16, else uint64."""
75
+ if order < 1:
76
+ return np.zeros(np.shape(x), dtype=np.uint32)
77
+ x = np.asarray(x)
78
+ y = np.asarray(y)
79
+ out_dtype = np.uint32 if order <= 16 else np.uint64
80
+ out = np.empty(x.shape, dtype=out_dtype)
81
+ flat_x, flat_y, flat_out = x.reshape(-1), y.reshape(-1), out.reshape(-1)
82
+ for a in range(0, flat_x.size, BLOCK):
83
+ flat_out[a:a + BLOCK] = _xy2d_block(order, flat_x[a:a + BLOCK], flat_y[a:a + BLOCK])
84
+ return out
85
+
86
+
87
+ def quantize(v: np.ndarray, v0: float, size: float, bits: int) -> np.ndarray:
88
+ """floor((v - v0) / size * 2^bits), clamped to [0, 2^bits - 1], as uint32."""
89
+ n = 1 << bits
90
+ q = np.floor((np.asarray(v, dtype=np.float64) - v0) / size * n)
91
+ return np.clip(q, 0, n - 1).astype(np.uint32)
spacelab/hlod/read.py ADDED
@@ -0,0 +1,150 @@
1
+ """Analysis-side reader. The store is plain Zarr (zarr.open(path)["coords"][:]
2
+ works); this adds the gene, level and region lookups the sort order makes cheap.
3
+
4
+ store = hlod.open("points.zarr") # local path, s3://, https://
5
+ g = store.gene("CD3E")
6
+ g.coords() # (N_g, 2) float32, one contiguous slice
7
+ g.coords(levels=2) # levels 0..2: a uniform subsample of fraction F[2]
8
+ g.table(columns=["cell_id", "qv"]) # pyarrow Table, co-sorted
9
+ g.roi(xmin, ymin, xmax, ymax) # every transcript of the gene in the box
10
+ store.roi(xmin, ymin, xmax, ymax) # across genes, with a gene column
11
+ """
12
+
13
+ from collections.abc import Sequence
14
+ from functools import cached_property
15
+
16
+ import numpy as np
17
+ import pyarrow as pa
18
+ import zarr
19
+
20
+ from .build import FORMAT
21
+ from .cells import DEFAULT_MAX_GAP_ROWS, Extent, block_offsets, cells_in_box, coalesce, ranges_for
22
+
23
+
24
+ def open(path: str, storage_options: dict | None = None) -> "Store": # noqa: A001
25
+ return Store(path, storage_options)
26
+
27
+
28
+ class Store:
29
+ def __init__(self, path: str, storage_options: dict | None = None):
30
+ self.root = zarr.open_group(path, mode="r", storage_options=storage_options)
31
+ self.attrs = dict(self.root.attrs)
32
+ if self.attrs.get("format") != FORMAT:
33
+ raise ValueError(f"{path} is not a {FORMAT} store")
34
+ self.genes: list[str] = list(self.attrs["genes"])
35
+ self.columns: list[str] = list(self.attrs.get("columns", []))
36
+ self.categories: dict[str, str] = dict(self.attrs.get("categories", {}))
37
+ self.extent = Extent.from_attrs(self.attrs)
38
+ self.K, self.g, self.P, self.Dmax = (int(self.attrs[k]) for k in ("K", "g", "P", "Dmax"))
39
+ self.B = block_offsets(self.g, self.Dmax)
40
+ self._gene_of = {name: i for i, name in enumerate(self.genes)}
41
+
42
+ @cached_property
43
+ def gene_offsets(self) -> np.ndarray:
44
+ return self.root["index/gene_offsets"][:].astype(np.int64)
45
+
46
+ @cached_property
47
+ def level_offsets(self) -> np.ndarray:
48
+ return self.root["index/level_offsets"][:].astype(np.int64)
49
+
50
+ @property
51
+ def n_rows(self) -> int:
52
+ return int(self.gene_offsets[-1])
53
+
54
+ def gene(self, gene: str | int) -> "Gene":
55
+ gi = gene if isinstance(gene, (int, np.integer)) else self._gene_of.get(gene)
56
+ if gi is None or not 0 <= gi < len(self.genes):
57
+ raise KeyError(gene)
58
+ return Gene(self, int(gi))
59
+
60
+ def fraction(self, level: int, n_gene: int) -> float:
61
+ """F[level]: the share of a gene's rows in levels 0..level."""
62
+ return 1.0 if level >= self.Dmax or not n_gene else min(1.0, self.P * 4 ** level / n_gene)
63
+
64
+ def read_rows(self, name: str, start: int, end: int) -> np.ndarray:
65
+ return self.root[name][start:end]
66
+
67
+ @cached_property
68
+ def _category_names(self) -> dict[str, np.ndarray]:
69
+ return {c: np.asarray(self.root[a][:], dtype=object) for c, a in self.categories.items()}
70
+
71
+ def column_array(self, name: str, values: np.ndarray) -> pa.Array:
72
+ if name in self.categories:
73
+ names = pa.array(self._category_names[name].tolist(), type=pa.string())
74
+ return pa.DictionaryArray.from_arrays(pa.array(values.astype(np.int32)), names)
75
+ return pa.array(values)
76
+
77
+ def roi(self, xmin, ymin, xmax, ymax, genes: Sequence[str] | None = None, exact: bool = True,
78
+ columns: Sequence[str] = ()) -> pa.Table:
79
+ names = self.genes if genes is None else list(genes)
80
+ tables = []
81
+ for name in names:
82
+ t = self.gene(name).roi(xmin, ymin, xmax, ymax, exact=exact, columns=columns)
83
+ tables.append(t.append_column("gene", pa.array([name] * t.num_rows, pa.string())))
84
+ if not tables:
85
+ return pa.table({"x": pa.array([], pa.float32()), "y": pa.array([], pa.float32()),
86
+ "gene": pa.array([], pa.string())})
87
+ return pa.concat_tables(tables)
88
+
89
+
90
+ class Gene:
91
+ def __init__(self, store: Store, gi: int):
92
+ self.store = store
93
+ self.index = gi
94
+ self.name = store.genes[gi]
95
+ self.start = int(store.gene_offsets[gi])
96
+ self.end = int(store.gene_offsets[gi + 1])
97
+ self.level_offsets = store.level_offsets[gi]
98
+
99
+ def __len__(self) -> int:
100
+ return self.end - self.start
101
+
102
+ @cached_property
103
+ def cell_index(self) -> np.ndarray:
104
+ return self.store.root["index/cell_index"][self.index].astype(np.int64)
105
+
106
+ def _stop(self, levels: int | None) -> int:
107
+ if levels is None or levels >= self.store.Dmax:
108
+ return self.end
109
+ return int(self.level_offsets[levels + 1])
110
+
111
+ def coords(self, levels: int | None = None) -> np.ndarray:
112
+ return self.store.read_rows("coords", self.start, self._stop(levels))
113
+
114
+ def table(self, columns: Sequence[str] | None = None, levels: int | None = None) -> pa.Table:
115
+ stop = self._stop(levels)
116
+ return self._table([(self.start, stop)], columns)
117
+
118
+ def _table(self, spans, columns) -> pa.Table:
119
+ columns = self.store.columns if columns is None else list(columns)
120
+ xy = (np.concatenate([self.store.read_rows("coords", s, e) for s, e in spans])
121
+ if spans else np.empty((0, 2), np.float32))
122
+ out = {"x": pa.array(xy[:, 0]), "y": pa.array(xy[:, 1])}
123
+ for c in columns:
124
+ vals = np.concatenate([self.store.read_rows(c, s, e) for s, e in spans]) if spans \
125
+ else np.empty(0, self.store.root[c].dtype)
126
+ out[c] = self.store.column_array(c, vals)
127
+ return pa.table(out)
128
+
129
+ def roi_ranges(self, xmin, ymin, xmax, ymax,
130
+ max_gap_rows: int = DEFAULT_MAX_GAP_ROWS) -> list[tuple[int, int]]:
131
+ """Coalesced absolute row ranges covering the box at every level; the
132
+ same cellsInBox -> rangesFor -> coalesce the viewer runs."""
133
+ s = self.store
134
+ ranges = []
135
+ for d in range(s.Dmax + 1):
136
+ cells = cells_in_box((xmin, ymin, xmax, ymax), d + s.g, s.extent)
137
+ ranges.extend(coalesce(ranges_for(self.cell_index, self.start, d, cells, s.g, s.Dmax),
138
+ max_gap_rows))
139
+ return [(r.start, r.end) for r in coalesce(ranges, max_gap_rows)]
140
+
141
+ def roi(self, xmin, ymin, xmax, ymax, exact: bool = True,
142
+ columns: Sequence[str] = ()) -> pa.Table:
143
+ """Transcripts of this gene in the box; never reads the gene's full array.
144
+ exact=False keeps the overhang of the cells the box touches."""
145
+ t = self._table(self.roi_ranges(xmin, ymin, xmax, ymax), columns)
146
+ if exact and t.num_rows:
147
+ x = t.column("x").to_numpy()
148
+ y = t.column("y").to_numpy()
149
+ t = t.filter(pa.array((x >= xmin) & (x <= xmax) & (y >= ymin) & (y <= ymax)))
150
+ return t
@@ -0,0 +1,142 @@
1
+ """Structural and statistical checks a store must pass before it is published."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ from .build import FORMAT_VERSION
8
+ from .hilbert import quantize, xy2d
9
+ from .read import Store
10
+
11
+
12
+ def ks_2samp(a: np.ndarray, b: np.ndarray) -> tuple[float, float]:
13
+ """Two-sample Kolmogorov-Smirnov statistic and asymptotic p-value
14
+ (Numerical Recipes' kstwo), so verify needs no scipy."""
15
+ a = np.sort(a)
16
+ b = np.sort(b)
17
+ both = np.concatenate([a, b])
18
+ cdf_a = np.searchsorted(a, both, side="right") / a.size
19
+ cdf_b = np.searchsorted(b, both, side="right") / b.size
20
+ d = float(np.abs(cdf_a - cdf_b).max())
21
+ en = math.sqrt(a.size * b.size / (a.size + b.size))
22
+ lam = (en + 0.12 + 0.11 / en) * d
23
+ if lam < 1e-3:
24
+ return d, 1.0
25
+ p = 2 * sum((-1) ** (j - 1) * math.exp(-2 * j * j * lam * lam) for j in range(1, 101))
26
+ return d, float(min(max(p, 0.0), 1.0))
27
+
28
+
29
+ class Report:
30
+ def __init__(self):
31
+ self.failures: list[str] = []
32
+ self.notes: list[str] = []
33
+
34
+ def check(self, ok: bool, msg: str):
35
+ if not ok:
36
+ self.failures.append(msg)
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return not self.failures
41
+
42
+
43
+ def verify(path: str, roi_samples: int = 50, seed: int = 7, ks_p: float = 0.001,
44
+ binomial_sd: float = 4.0) -> Report:
45
+ store = Store(path)
46
+ rep = Report()
47
+ a = store.attrs
48
+ rep.check(a.get("format_version") == FORMAT_VERSION, f"format_version {a.get('format_version')}")
49
+ K, g, dmax, P = store.K, store.g, store.Dmax, store.P
50
+ n = store.n_rows
51
+ G = len(store.genes)
52
+ go, lo = store.gene_offsets, store.level_offsets
53
+ rep.check(go.shape == (G + 1,) and go[0] == 0, "gene_offsets shape/start")
54
+ rep.check(bool((np.diff(go) >= 0).all()), "gene_offsets not monotone")
55
+ rep.check(store.root["coords"].shape == (n, 2), "coords rows != gene_offsets[G]")
56
+ rep.check(lo.shape == (G, dmax + 2), "level_offsets shape")
57
+ for c in store.columns:
58
+ rep.check(store.root[c].shape[0] == n, f"column {c} has the wrong length")
59
+
60
+ if "original_index" in store.root:
61
+ orig = store.root["original_index"][:]
62
+ rep.check(bool((np.sort(orig) == np.arange(n, dtype=orig.dtype)).all()),
63
+ "original_index is not a permutation: some input row is missing or repeated")
64
+ del orig
65
+
66
+ ext = store.extent
67
+ B = store.B
68
+ cell_index = store.root["index/cell_index"][:].astype(np.int64)
69
+ use_binomial = a.get("hash", {}).get("key") == "transcript_id"
70
+ rows_per_level = np.zeros(dmax + 1, dtype=np.int64)
71
+ for gi, name in enumerate(store.genes):
72
+ s, e = int(go[gi]), int(go[gi + 1])
73
+ ng = e - s
74
+ rel = lo[gi] - s
75
+ rep.check(lo[gi, 0] == s and lo[gi, -1] == e, f"{name}: level_offsets don't span the gene")
76
+ rep.check(bool((np.diff(rel) >= 0).all()), f"{name}: level_offsets not monotone")
77
+ rows_per_level += np.diff(rel)
78
+ xy = store.read_rows("coords", s, e)
79
+ h = xy2d(K, quantize(xy[:, 0], ext.x0, ext.size, K), quantize(xy[:, 1], ext.y0, ext.size, K))
80
+ prev_f = 0.0
81
+ for d in range(dmax + 1):
82
+ ls, le = int(rel[d]), int(rel[d + 1])
83
+ hd = h[ls:le]
84
+ rep.check(bool((np.diff(hd.astype(np.int64)) >= 0).all()),
85
+ f"{name} level {d}: h not sorted")
86
+ block = cell_index[gi, B[d]:B[d + 1]]
87
+ rep.check(block[0] == ls and block[-1] == le,
88
+ f"{name} level {d}: cell_index block disagrees with level_offsets")
89
+ rep.check(bool((np.diff(block) >= 0).all()), f"{name} level {d}: cell_index not monotone")
90
+ m = d + g
91
+ expect = np.repeat(np.arange(4 ** m), np.diff(block))
92
+ got = (hd >> np.uint32(2 * (K - m))).astype(np.int64)
93
+ rep.check(expect.size == got.size and bool((expect == got).all()),
94
+ f"{name} level {d}: rows outside the cell cell_index assigns them")
95
+ f = store.fraction(d, ng)
96
+ if use_binomial and ng:
97
+ p = f - prev_f
98
+ sd = math.sqrt(ng * p * (1 - p))
99
+ rep.check(abs((le - ls) - ng * p) <= binomial_sd * sd + 1,
100
+ f"{name} level {d}: {le - ls} rows, expected {ng * p:.0f} ± {sd:.0f}")
101
+ prev_f = f
102
+ # A level prefix that is a real subsample should look like the whole gene.
103
+ for d in range(dmax):
104
+ k = int(rel[d + 1])
105
+ if 1000 <= k < ng:
106
+ for axis, label in ((0, "x"), (1, "y")):
107
+ _, p = ks_2samp(xy[:k, axis], xy[:, axis])
108
+ rep.check(p > ks_p, f"{name} levels 0..{d}: KS on {label} p={p:.2g}")
109
+
110
+ rep.notes.append(f"rows per level: {rows_per_level.tolist()}")
111
+ rep.notes.append(f"index bytes per gene: {cell_index.shape[1] * 4 + (dmax + 2) * 8 + 8:,}")
112
+ if cell_index.size:
113
+ rep.notes.append(f"largest cell: {int(np.diff(cell_index, axis=1).max()):,} rows")
114
+
115
+ rng = np.random.default_rng(seed)
116
+ if G and roi_samples:
117
+ for i in range(roi_samples):
118
+ gi = int(rng.integers(G))
119
+ gene = store.gene(gi)
120
+ box = _random_box(rng, ext, i)
121
+ got = gene.roi(*box, exact=True)
122
+ xy = gene.coords()
123
+ want = xy[(xy[:, 0] >= box[0]) & (xy[:, 0] <= box[2])
124
+ & (xy[:, 1] >= box[1]) & (xy[:, 1] <= box[3])]
125
+ gx = np.stack([got.column("x").to_numpy(), got.column("y").to_numpy()], axis=1)
126
+ same = gx.shape == want.shape and bool(
127
+ (gx[np.lexsort(gx.T)] == want[np.lexsort(want.T)]).all())
128
+ rep.check(same, f"roi {box} on {gene.name}: {len(gx)} rows, brute force {len(want)}")
129
+ return rep
130
+
131
+
132
+ def _random_box(rng, ext, i: int):
133
+ """Mostly in-extent boxes of varied size, with every 10th smaller than a
134
+ deep cell and every 7th wholly outside the extent."""
135
+ if i % 7 == 6:
136
+ x = ext.x0 + ext.size * (1.5 + rng.random())
137
+ return (x, ext.y0, x + ext.size * 0.1, ext.y0 + ext.size * 0.1)
138
+ scale = 1e-4 if i % 10 == 9 else rng.uniform(0.01, 0.6)
139
+ w = ext.size * scale
140
+ x = ext.x0 + rng.uniform(-0.05, 1.0) * ext.size
141
+ y = ext.y0 + rng.uniform(-0.05, 1.0) * ext.size
142
+ return (x, y, x + w, y + w * rng.uniform(0.5, 2.0))