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.
- spacelab/__init__.py +6 -0
- spacelab/__main__.py +3 -0
- spacelab/cli.py +97 -0
- spacelab/hlod/__init__.py +8 -0
- spacelab/hlod/__main__.py +5 -0
- spacelab/hlod/build.py +349 -0
- spacelab/hlod/cells.py +82 -0
- spacelab/hlod/cli.py +90 -0
- spacelab/hlod/hashing.py +38 -0
- spacelab/hlod/hilbert.py +91 -0
- spacelab/hlod/read.py +150 -0
- spacelab/hlod/verify.py +142 -0
- spacelab/prep_spatialdata.py +700 -0
- spacelab/server.py +130 -0
- spacelab/static/AI_lens_black.png +0 -0
- spacelab/static/ai-new-logo.png +0 -0
- spacelab/static/aifi_logo.png +0 -0
- spacelab/static/assets/AllenInstitutePlusText-Bold-DxLtQ4oE.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-BoldItalic-mxzHQhQO.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-Italic-CaKmxpsW.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-Light-DUThXl1m.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-LightItalic-CxfKknzG.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-Regular-B0ryviHQ.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-Semibold-3PoiDF1d.woff2 +0 -0
- spacelab/static/assets/AllenInstitutePlusText-SemiboldItalic-uXaVqnFo.woff2 +0 -0
- spacelab/static/assets/blosc-BKQbfHyB.js +18 -0
- spacelab/static/assets/blosc-j6ygecpn.js +18 -0
- spacelab/static/assets/chunk-INHXZS53-D3tQiqtZ.js +2 -0
- spacelab/static/assets/chunk-INHXZS53-QjxmivJp.js +2 -0
- spacelab/static/assets/deflate-BRVR_fNk.js +2 -0
- spacelab/static/assets/hlodTranscripts.worker-CjhNLk56.js +2 -0
- spacelab/static/assets/index-BT-mDgg2.js +5025 -0
- spacelab/static/assets/index-CNIqsj6m.css +1 -0
- spacelab/static/assets/jpeg-JUDfHHDo.js +2 -0
- spacelab/static/assets/lerc-CXQdKTps.js +2 -0
- spacelab/static/assets/lz4-C2MQfzIA.js +16 -0
- spacelab/static/assets/lz4-rzy_Siro.js +16 -0
- spacelab/static/assets/lzw-DBCCrYEo.js +2 -0
- spacelab/static/assets/packbits-LrtZ2mhj.js +2 -0
- spacelab/static/assets/pako.esm-C69tvH9t.js +2 -0
- spacelab/static/assets/polygons.worker-ClOPdWlW.js +2 -0
- spacelab/static/assets/raw-lPmjnySZ.js +2 -0
- spacelab/static/assets/spatialdataExpression.worker-D4W_OHS3.js +4 -0
- spacelab/static/assets/spatialdataShapes.worker-DQV1zeTt.js +974 -0
- spacelab/static/assets/spatialdataTranscripts.worker-DH9L-MwZ.js +974 -0
- spacelab/static/assets/transcripts.worker-CtxyO4n3.js +2 -0
- spacelab/static/assets/umap.worker-BPHuoLPV.js +2 -0
- spacelab/static/assets/webimage-xg0Pp5Jv.js +2 -0
- spacelab/static/assets/zstd-Cp7BEDDH.js +16 -0
- spacelab/static/assets/zstd-WlDjm0Vu.js +16 -0
- spacelab/static/blurred-bg.jpg +0 -0
- spacelab/static/favicon.ico +0 -0
- spacelab/static/index.html +22 -0
- spacelab/static/logo192.png +0 -0
- spacelab/static/logo512.png +0 -0
- spacelab/static/manifest.json +15 -0
- spacelab/static/robots.txt +3 -0
- spacelab-0.28.0.dist-info/METADATA +30 -0
- spacelab-0.28.0.dist-info/RECORD +62 -0
- spacelab-0.28.0.dist-info/WHEEL +4 -0
- spacelab-0.28.0.dist-info/entry_points.txt +2 -0
- spacelab-0.28.0.dist-info/licenses/LICENSE +21 -0
spacelab/hlod/hashing.py
ADDED
|
@@ -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)
|
spacelab/hlod/hilbert.py
ADDED
|
@@ -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
|
spacelab/hlod/verify.py
ADDED
|
@@ -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))
|