pyadata-cli 0.5.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.
- adata/__init__.py +10 -0
- adata/cli.py +1139 -0
- adata/commands/__init__.py +8 -0
- adata/commands/concat.py +21 -0
- adata/commands/create.py +93 -0
- adata/commands/export.py +105 -0
- adata/commands/import_data.py +144 -0
- adata/commands/info.py +199 -0
- adata/commands/ls.py +114 -0
- adata/commands/split.py +152 -0
- adata/commands/subset.py +17 -0
- adata/core/__init__.py +1 -0
- adata/core/concat.py +1084 -0
- adata/core/info.py +276 -0
- adata/core/query.py +268 -0
- adata/core/read.py +28 -0
- adata/core/select.py +122 -0
- adata/core/subset.py +767 -0
- adata/elements/__init__.py +10 -0
- adata/elements/read.py +310 -0
- adata/elements/spec.py +125 -0
- adata/elements/strings.py +90 -0
- adata/elements/write.py +255 -0
- adata/formats/__init__.py +1 -0
- adata/formats/array.py +109 -0
- adata/formats/common.py +51 -0
- adata/formats/dataframe.py +234 -0
- adata/formats/image.py +85 -0
- adata/formats/json_data.py +196 -0
- adata/formats/sparse.py +253 -0
- adata/formats/validate.py +180 -0
- adata/info.py +3 -0
- adata/py.typed +0 -0
- adata/read.py +19 -0
- adata/storage/__init__.py +529 -0
- adata/util/__init__.py +1 -0
- adata/util/path.py +9 -0
- pyadata_cli-0.5.0.dist-info/METADATA +135 -0
- pyadata_cli-0.5.0.dist-info/RECORD +42 -0
- pyadata_cli-0.5.0.dist-info/WHEEL +4 -0
- pyadata_cli-0.5.0.dist-info/entry_points.txt +4 -0
- pyadata_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
adata/core/concat.py
ADDED
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
"""Concatenating AnnData stores on disk, along the obs axis.
|
|
2
|
+
|
|
3
|
+
Mirrors the useful core of `anndata.experimental.concat_on_disk`: inputs are
|
|
4
|
+
streamed a block of rows at a time and written straight into the output, so
|
|
5
|
+
peak memory is set by the block size rather than by the total size of the
|
|
6
|
+
inputs.
|
|
7
|
+
|
|
8
|
+
Columns of the concatenated axis follow `join`; elements of the alternative
|
|
9
|
+
axis and of uns follow a `merge` strategy, since there is no single correct
|
|
10
|
+
way to reconcile values that differ between inputs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from adata.core.info import axis_len
|
|
22
|
+
from adata.core.select import read_names
|
|
23
|
+
from adata.elements import spec
|
|
24
|
+
from adata.elements.read import (
|
|
25
|
+
dataframe_columns,
|
|
26
|
+
element_len,
|
|
27
|
+
read_str_all,
|
|
28
|
+
read_categories,
|
|
29
|
+
is_ordered,
|
|
30
|
+
resolve_index,
|
|
31
|
+
)
|
|
32
|
+
from adata.elements.write import (
|
|
33
|
+
ensure_anndata_skeleton,
|
|
34
|
+
set_column_order,
|
|
35
|
+
write_categorical,
|
|
36
|
+
write_dataframe_header,
|
|
37
|
+
write_dense,
|
|
38
|
+
write_masked,
|
|
39
|
+
write_mapping,
|
|
40
|
+
write_string_array,
|
|
41
|
+
)
|
|
42
|
+
from adata.storage import (
|
|
43
|
+
copy_tree,
|
|
44
|
+
create_dataset,
|
|
45
|
+
is_dataset,
|
|
46
|
+
is_group,
|
|
47
|
+
open_store,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
MERGE_STRATEGIES = ("same", "unique", "first", "only")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _index_union(per_input: Sequence[List[str]]) -> List[str]:
|
|
54
|
+
"""Union of indices, in order of first appearance."""
|
|
55
|
+
seen: Dict[str, None] = {}
|
|
56
|
+
for names in per_input:
|
|
57
|
+
for name in names:
|
|
58
|
+
seen.setdefault(name, None)
|
|
59
|
+
return list(seen)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _index_intersection(per_input: Sequence[List[str]]) -> List[str]:
|
|
63
|
+
"""Intersection of indices, ordered by the first input."""
|
|
64
|
+
common = set(per_input[0])
|
|
65
|
+
for names in per_input[1:]:
|
|
66
|
+
common &= set(names)
|
|
67
|
+
return [name for name in per_input[0] if name in common]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _column_map(source: List[str], target: List[str]) -> np.ndarray:
|
|
71
|
+
"""Map each source position to its target position, or -1 if dropped."""
|
|
72
|
+
lookup = {name: i for i, name in enumerate(target)}
|
|
73
|
+
return np.fromiter(
|
|
74
|
+
(lookup.get(name, -1) for name in source), dtype=np.int64, count=len(source)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _apply_index_unique(
|
|
79
|
+
names: List[str], key: Optional[str], delimiter: Optional[str]
|
|
80
|
+
) -> List[str]:
|
|
81
|
+
if delimiter is None or key is None:
|
|
82
|
+
return names
|
|
83
|
+
return [f"{name}{delimiter}{key}" for name in names]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_keys(
|
|
87
|
+
files: Sequence[Path], keys: Optional[Sequence[str]]
|
|
88
|
+
) -> List[str]:
|
|
89
|
+
"""Name each input, defaulting to its filename stem.
|
|
90
|
+
|
|
91
|
+
Keys must be distinct: `--label` writes them as categorical categories,
|
|
92
|
+
which have to be unique, and `--index-unique` uses them to disambiguate obs
|
|
93
|
+
names. Duplicates would otherwise produce an output that reports success
|
|
94
|
+
but cannot be read back.
|
|
95
|
+
"""
|
|
96
|
+
if keys is None:
|
|
97
|
+
resolved = [f.stem if f.suffix else f.name for f in files]
|
|
98
|
+
duplicates = _duplicates(resolved)
|
|
99
|
+
if duplicates:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Inputs in different directories share the filename(s) "
|
|
102
|
+
f"{', '.join(duplicates)}, so the default keys are not unique. "
|
|
103
|
+
"Pass --keys to name them explicitly."
|
|
104
|
+
)
|
|
105
|
+
return resolved
|
|
106
|
+
|
|
107
|
+
if len(keys) != len(files):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"--keys has {len(keys)} entries but {len(files)} inputs were given."
|
|
110
|
+
)
|
|
111
|
+
duplicates = _duplicates(keys)
|
|
112
|
+
if duplicates:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"--keys must be unique; repeated: {', '.join(duplicates)}."
|
|
115
|
+
)
|
|
116
|
+
return list(keys)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _duplicates(values: Sequence[str]) -> List[str]:
|
|
120
|
+
seen: Dict[str, int] = {}
|
|
121
|
+
for value in values:
|
|
122
|
+
seen[value] = seen.get(value, 0) + 1
|
|
123
|
+
return sorted(v for v, n in seen.items() if n > 1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# merge strategies
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _merge_values(values: List[Any], strategy: Optional[str]) -> Tuple[bool, Any]:
|
|
131
|
+
"""Reconcile one element's value across inputs.
|
|
132
|
+
|
|
133
|
+
Returns ``(keep, value)``. The strategies match anndata's: "same" keeps a
|
|
134
|
+
value only when every input agrees, "unique" when there is exactly one
|
|
135
|
+
distinct value among those present, "first" takes the earliest present,
|
|
136
|
+
and "only" keeps it when exactly one input has it at all.
|
|
137
|
+
"""
|
|
138
|
+
if strategy is None:
|
|
139
|
+
return False, None
|
|
140
|
+
|
|
141
|
+
present = [v for v in values if v is not _MISSING]
|
|
142
|
+
if not present:
|
|
143
|
+
return False, None
|
|
144
|
+
|
|
145
|
+
if strategy == "first":
|
|
146
|
+
return True, present[0]
|
|
147
|
+
|
|
148
|
+
if strategy == "only":
|
|
149
|
+
return (len(present) == 1), (present[0] if len(present) == 1 else None)
|
|
150
|
+
|
|
151
|
+
distinct: List[Any] = []
|
|
152
|
+
for value in present:
|
|
153
|
+
if not any(_equal(value, seen) for seen in distinct):
|
|
154
|
+
distinct.append(value)
|
|
155
|
+
|
|
156
|
+
if strategy == "unique":
|
|
157
|
+
return (len(distinct) == 1), (distinct[0] if len(distinct) == 1 else None)
|
|
158
|
+
|
|
159
|
+
if strategy == "same":
|
|
160
|
+
keep = len(distinct) == 1 and len(present) == len(values)
|
|
161
|
+
return keep, (distinct[0] if keep else None)
|
|
162
|
+
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"Unknown merge strategy {strategy!r}. "
|
|
165
|
+
f"Choose from: {', '.join(MERGE_STRATEGIES)}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _Missing:
|
|
170
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
171
|
+
return "<missing>"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_MISSING = _Missing()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _equal(a: Any, b: Any) -> bool:
|
|
178
|
+
if isinstance(a, _Incomparable) or isinstance(b, _Incomparable):
|
|
179
|
+
return False
|
|
180
|
+
try:
|
|
181
|
+
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
|
|
182
|
+
return np.array_equal(np.asarray(a), np.asarray(b))
|
|
183
|
+
return bool(a == b)
|
|
184
|
+
except Exception:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# obs columns
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _column_kind(obj: Any) -> str:
|
|
193
|
+
"""Classify a column by how it must be concatenated."""
|
|
194
|
+
enc = spec.encoding_type(obj)
|
|
195
|
+
if enc == spec.CATEGORICAL or (is_group(obj) and "codes" in obj):
|
|
196
|
+
return "categorical"
|
|
197
|
+
if enc in spec.MASKED_TYPES or (is_group(obj) and "values" in obj):
|
|
198
|
+
return "masked"
|
|
199
|
+
if is_dataset(obj):
|
|
200
|
+
from adata.elements.strings import is_string_dtype
|
|
201
|
+
|
|
202
|
+
return "string" if is_string_dtype(obj.dtype) else "numeric"
|
|
203
|
+
return "other"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _concat_categorical(
|
|
207
|
+
parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Concatenate categorical columns, unioning their category sets.
|
|
210
|
+
|
|
211
|
+
Inputs rarely share a category order, so codes are remapped rather than
|
|
212
|
+
concatenated directly. A row from an input lacking the column gets code
|
|
213
|
+
-1, which anndata reads back as a missing value.
|
|
214
|
+
"""
|
|
215
|
+
categories: List[str] = []
|
|
216
|
+
for col in columns:
|
|
217
|
+
if col is None:
|
|
218
|
+
continue
|
|
219
|
+
for category in read_categories(col):
|
|
220
|
+
if category not in categories:
|
|
221
|
+
categories.append(str(category))
|
|
222
|
+
|
|
223
|
+
lookup = {c: i for i, c in enumerate(categories)}
|
|
224
|
+
ordered = all(is_ordered(c) for c in columns if c is not None)
|
|
225
|
+
|
|
226
|
+
codes = np.full(sum(lengths), -1, dtype=np.int64)
|
|
227
|
+
offset = 0
|
|
228
|
+
for col, length in zip(columns, lengths):
|
|
229
|
+
if col is not None:
|
|
230
|
+
source_cats = [str(c) for c in read_categories(col)]
|
|
231
|
+
remap = np.array(
|
|
232
|
+
[lookup[c] for c in source_cats] + [-1], dtype=np.int64
|
|
233
|
+
)
|
|
234
|
+
source_codes = np.asarray(col["codes"][...], dtype=np.int64)
|
|
235
|
+
source_codes[source_codes < 0] = len(source_cats)
|
|
236
|
+
codes[offset : offset + length] = remap[source_codes]
|
|
237
|
+
offset += length
|
|
238
|
+
|
|
239
|
+
write_categorical(parent, name, codes, categories, ordered=ordered)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _concat_numeric(
|
|
243
|
+
parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Concatenate numeric columns, promoting to float when rows are missing.
|
|
246
|
+
|
|
247
|
+
An integer column cannot represent "absent", so a column missing from some
|
|
248
|
+
input is promoted to float and padded with NaN -- the same thing pandas
|
|
249
|
+
does on an outer join.
|
|
250
|
+
"""
|
|
251
|
+
present = [c for c in columns if c is not None]
|
|
252
|
+
dtype = np.result_type(*[c.dtype for c in present])
|
|
253
|
+
complete = len(present) == len(columns)
|
|
254
|
+
|
|
255
|
+
if not complete and dtype.kind in ("i", "u", "b"):
|
|
256
|
+
dtype = np.dtype("float64")
|
|
257
|
+
|
|
258
|
+
out = np.empty(sum(lengths), dtype=dtype)
|
|
259
|
+
if not complete:
|
|
260
|
+
out[:] = np.nan if dtype.kind == "f" else 0
|
|
261
|
+
|
|
262
|
+
offset = 0
|
|
263
|
+
for col, length in zip(columns, lengths):
|
|
264
|
+
if col is not None:
|
|
265
|
+
out[offset : offset + length] = np.asarray(col[...], dtype=dtype)
|
|
266
|
+
offset += length
|
|
267
|
+
|
|
268
|
+
write_dense(parent, name, out)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _concat_masked(
|
|
272
|
+
parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Concatenate nullable columns, masking rows from inputs that lack them."""
|
|
275
|
+
kinds = {spec.encoding_type(c) for c in columns if c is not None}
|
|
276
|
+
enc = kinds.pop() if len(kinds) == 1 else spec.NULLABLE_STRING_ARRAY
|
|
277
|
+
|
|
278
|
+
total = sum(lengths)
|
|
279
|
+
mask = np.ones(total, dtype=bool)
|
|
280
|
+
values: List[Any] = [None] * total
|
|
281
|
+
|
|
282
|
+
offset = 0
|
|
283
|
+
for col, length in zip(columns, lengths):
|
|
284
|
+
if col is not None:
|
|
285
|
+
chunk = np.asarray(col["values"][...])
|
|
286
|
+
chunk_mask = np.asarray(col["mask"][...], dtype=bool)
|
|
287
|
+
for i in range(length):
|
|
288
|
+
values[offset + i] = chunk[i]
|
|
289
|
+
mask[offset : offset + length] = chunk_mask
|
|
290
|
+
offset += length
|
|
291
|
+
|
|
292
|
+
if enc == spec.NULLABLE_STRING_ARRAY:
|
|
293
|
+
from adata.elements.read import decode_str_array
|
|
294
|
+
|
|
295
|
+
filled = [
|
|
296
|
+
"" if v is None else decode_str_array(np.asarray([v]))[0] for v in values
|
|
297
|
+
]
|
|
298
|
+
write_masked(parent, name, filled, mask, enc)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
present = [c for c in columns if c is not None]
|
|
302
|
+
dtype = np.result_type(*[c["values"].dtype for c in present])
|
|
303
|
+
filled_num = np.array(
|
|
304
|
+
[0 if v is None else v for v in values], dtype=dtype
|
|
305
|
+
)
|
|
306
|
+
write_masked(parent, name, filled_num, mask, enc)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _concat_string(
|
|
310
|
+
parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Concatenate string columns, masking rows from inputs that lack them."""
|
|
313
|
+
total = sum(lengths)
|
|
314
|
+
values: List[str] = [""] * total
|
|
315
|
+
mask = np.zeros(total, dtype=bool)
|
|
316
|
+
|
|
317
|
+
offset = 0
|
|
318
|
+
for col, length in zip(columns, lengths):
|
|
319
|
+
if col is None:
|
|
320
|
+
mask[offset : offset + length] = True
|
|
321
|
+
else:
|
|
322
|
+
values[offset : offset + length] = read_str_all(col)
|
|
323
|
+
offset += length
|
|
324
|
+
|
|
325
|
+
if mask.any():
|
|
326
|
+
write_masked(parent, name, values, mask, spec.NULLABLE_STRING_ARRAY)
|
|
327
|
+
else:
|
|
328
|
+
write_string_array(parent, name, values)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _concat_obs_column(
|
|
332
|
+
parent: Any,
|
|
333
|
+
name: str,
|
|
334
|
+
columns: List[Optional[Any]],
|
|
335
|
+
lengths: List[int],
|
|
336
|
+
console: Console,
|
|
337
|
+
) -> bool:
|
|
338
|
+
"""Write one concatenated annotation column. Returns whether it was kept."""
|
|
339
|
+
kinds = {_column_kind(c) for c in columns if c is not None}
|
|
340
|
+
|
|
341
|
+
if kinds == {"categorical"}:
|
|
342
|
+
_concat_categorical(parent, name, columns, lengths)
|
|
343
|
+
elif kinds == {"numeric"}:
|
|
344
|
+
_concat_numeric(parent, name, columns, lengths)
|
|
345
|
+
elif kinds == {"masked"}:
|
|
346
|
+
_concat_masked(parent, name, columns, lengths)
|
|
347
|
+
elif kinds <= {"string", "categorical", "masked"}:
|
|
348
|
+
# Mixed text-like encodings: fall back to plain strings, which every
|
|
349
|
+
# one of them can represent.
|
|
350
|
+
_concat_string(parent, name, columns, lengths)
|
|
351
|
+
else:
|
|
352
|
+
console.print(
|
|
353
|
+
f"[yellow]Dropping column {name!r}: "
|
|
354
|
+
f"cannot concatenate encodings {sorted(kinds)}[/]"
|
|
355
|
+
)
|
|
356
|
+
return False
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# matrices
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _matrix_kind(obj: Any) -> str:
|
|
365
|
+
enc = spec.encoding_type(obj)
|
|
366
|
+
if enc in spec.SPARSE_TYPES:
|
|
367
|
+
return enc
|
|
368
|
+
if is_dataset(obj):
|
|
369
|
+
return "dense"
|
|
370
|
+
return "other"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _concat_sparse(
|
|
374
|
+
dst_parent: Any,
|
|
375
|
+
name: str,
|
|
376
|
+
sources: List[Any],
|
|
377
|
+
col_maps: List[np.ndarray],
|
|
378
|
+
n_rows: int,
|
|
379
|
+
n_cols: int,
|
|
380
|
+
chunk_rows: int,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Concatenate CSR matrices row-wise, remapping columns as they stream.
|
|
383
|
+
|
|
384
|
+
Absent columns need no fill: a sparse matrix's zeros are implicit, so
|
|
385
|
+
dropped entries simply do not appear in the output.
|
|
386
|
+
"""
|
|
387
|
+
from adata.core.subset import _append, _growable
|
|
388
|
+
|
|
389
|
+
group = dst_parent.create_group(name)
|
|
390
|
+
spec.set_encoding(group, spec.CSR_MATRIX)
|
|
391
|
+
from adata.elements.write import set_shape_attr
|
|
392
|
+
|
|
393
|
+
set_shape_attr(group, (n_rows, n_cols))
|
|
394
|
+
|
|
395
|
+
dtype = np.result_type(*[s["data"].dtype for s in sources])
|
|
396
|
+
out_data = _growable(group, "data", dtype)
|
|
397
|
+
out_indices = _growable(group, "indices", np.int64)
|
|
398
|
+
indptr = [0]
|
|
399
|
+
nnz = 0
|
|
400
|
+
|
|
401
|
+
for source, col_map in zip(sources, col_maps):
|
|
402
|
+
src_indptr = np.asarray(source["indptr"][...], dtype=np.int64)
|
|
403
|
+
src_data, src_indices = source["data"], source["indices"]
|
|
404
|
+
n_src_rows = len(src_indptr) - 1
|
|
405
|
+
|
|
406
|
+
for start in range(0, n_src_rows, chunk_rows):
|
|
407
|
+
end = min(start + chunk_rows, n_src_rows)
|
|
408
|
+
lo, hi = int(src_indptr[start]), int(src_indptr[end])
|
|
409
|
+
block_idx = (
|
|
410
|
+
np.asarray(src_indices[lo:hi], dtype=np.int64)
|
|
411
|
+
if hi > lo
|
|
412
|
+
else np.empty(0, dtype=np.int64)
|
|
413
|
+
)
|
|
414
|
+
block_data = (
|
|
415
|
+
np.asarray(src_data[lo:hi]) if hi > lo else np.empty(0, dtype=dtype)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
kept_idx: List[np.ndarray] = []
|
|
419
|
+
kept_data: List[np.ndarray] = []
|
|
420
|
+
for row in range(start, end):
|
|
421
|
+
sl = slice(int(src_indptr[row]) - lo, int(src_indptr[row + 1]) - lo)
|
|
422
|
+
mapped = col_map[block_idx[sl]]
|
|
423
|
+
keep = mapped >= 0
|
|
424
|
+
kept_idx.append(mapped[keep])
|
|
425
|
+
kept_data.append(block_data[sl][keep])
|
|
426
|
+
nnz += int(keep.sum())
|
|
427
|
+
indptr.append(nnz)
|
|
428
|
+
|
|
429
|
+
if kept_idx:
|
|
430
|
+
_append(out_indices, np.concatenate(kept_idx))
|
|
431
|
+
_append(out_data, np.concatenate(kept_data).astype(dtype))
|
|
432
|
+
|
|
433
|
+
create_dataset(group, "indptr", data=np.asarray(indptr, dtype=np.int64))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _concat_dense(
|
|
437
|
+
dst_parent: Any,
|
|
438
|
+
name: str,
|
|
439
|
+
sources: List[Any],
|
|
440
|
+
col_maps: List[np.ndarray],
|
|
441
|
+
n_rows: int,
|
|
442
|
+
n_cols: int,
|
|
443
|
+
chunk_rows: int,
|
|
444
|
+
fill_value: float,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Concatenate dense matrices row-wise, scattering columns into place."""
|
|
447
|
+
dtype = np.result_type(*[s.dtype for s in sources])
|
|
448
|
+
if fill_value != 0 and dtype.kind in ("i", "u", "b"):
|
|
449
|
+
dtype = np.dtype("float64")
|
|
450
|
+
|
|
451
|
+
out = create_dataset(dst_parent, name, shape=(n_rows, n_cols), dtype=dtype)
|
|
452
|
+
spec.set_encoding(out, spec.ARRAY)
|
|
453
|
+
|
|
454
|
+
row_offset = 0
|
|
455
|
+
for source, col_map in zip(sources, col_maps):
|
|
456
|
+
keep = col_map >= 0
|
|
457
|
+
targets = col_map[keep]
|
|
458
|
+
n_src_rows = source.shape[0]
|
|
459
|
+
|
|
460
|
+
for start in range(0, n_src_rows, chunk_rows):
|
|
461
|
+
end = min(start + chunk_rows, n_src_rows)
|
|
462
|
+
block = np.full((end - start, n_cols), fill_value, dtype=dtype)
|
|
463
|
+
block[:, targets] = np.asarray(source[start:end, :])[:, keep]
|
|
464
|
+
out[row_offset + start : row_offset + end, :] = block
|
|
465
|
+
|
|
466
|
+
row_offset += n_src_rows
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _inverse_column_map(col_map: np.ndarray, n_cols: int) -> np.ndarray:
|
|
470
|
+
"""Invert a source->target column map into target->source, -1 where absent."""
|
|
471
|
+
inverse = np.full(n_cols, -1, dtype=np.int64)
|
|
472
|
+
present = col_map >= 0
|
|
473
|
+
inverse[col_map[present]] = np.nonzero(present)[0]
|
|
474
|
+
return inverse
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _concat_csc(
|
|
478
|
+
dst_parent: Any,
|
|
479
|
+
name: str,
|
|
480
|
+
sources: List[Any],
|
|
481
|
+
col_maps: List[np.ndarray],
|
|
482
|
+
row_counts: List[int],
|
|
483
|
+
n_rows: int,
|
|
484
|
+
n_cols: int,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Concatenate CSC matrices row-wise, keeping the CSC encoding.
|
|
487
|
+
|
|
488
|
+
A CSC matrix is stored by column, so concatenating along obs means, for
|
|
489
|
+
each target column, appending each input's row indices in turn with that
|
|
490
|
+
input's row offset added. Because every input's column is already sorted
|
|
491
|
+
and the offsets increase, the result is sorted without a re-sort.
|
|
492
|
+
"""
|
|
493
|
+
from adata.core.subset import _append, _growable
|
|
494
|
+
from adata.elements.write import set_shape_attr
|
|
495
|
+
|
|
496
|
+
group = dst_parent.create_group(name)
|
|
497
|
+
spec.set_encoding(group, spec.CSC_MATRIX)
|
|
498
|
+
set_shape_attr(group, (n_rows, n_cols))
|
|
499
|
+
|
|
500
|
+
dtype = np.result_type(*[s["data"].dtype for s in sources])
|
|
501
|
+
out_data = _growable(group, "data", dtype)
|
|
502
|
+
out_indices = _growable(group, "indices", np.int64)
|
|
503
|
+
indptr = [0]
|
|
504
|
+
nnz = 0
|
|
505
|
+
|
|
506
|
+
inverses = [_inverse_column_map(cm, n_cols) for cm in col_maps]
|
|
507
|
+
indptrs = [np.asarray(s["indptr"][...], dtype=np.int64) for s in sources]
|
|
508
|
+
row_offsets = np.cumsum([0] + list(row_counts[:-1]))
|
|
509
|
+
|
|
510
|
+
for target_col in range(n_cols):
|
|
511
|
+
rows: List[np.ndarray] = []
|
|
512
|
+
values: List[np.ndarray] = []
|
|
513
|
+
for source, src_indptr, inverse, offset in zip(
|
|
514
|
+
sources, indptrs, inverses, row_offsets
|
|
515
|
+
):
|
|
516
|
+
src_col = int(inverse[target_col])
|
|
517
|
+
if src_col < 0:
|
|
518
|
+
continue
|
|
519
|
+
lo, hi = int(src_indptr[src_col]), int(src_indptr[src_col + 1])
|
|
520
|
+
if hi <= lo:
|
|
521
|
+
continue
|
|
522
|
+
rows.append(np.asarray(source["indices"][lo:hi], dtype=np.int64) + offset)
|
|
523
|
+
values.append(np.asarray(source["data"][lo:hi]))
|
|
524
|
+
|
|
525
|
+
if rows:
|
|
526
|
+
_append(out_indices, np.concatenate(rows))
|
|
527
|
+
_append(out_data, np.concatenate(values).astype(dtype))
|
|
528
|
+
nnz += int(sum(len(r) for r in rows))
|
|
529
|
+
indptr.append(nnz)
|
|
530
|
+
|
|
531
|
+
create_dataset(group, "indptr", data=np.asarray(indptr, dtype=np.int64))
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def check_matrix_encodings(roots: List[Any], console: Console) -> None:
|
|
535
|
+
"""Fail before writing anything if a matrix cannot be concatenated.
|
|
536
|
+
|
|
537
|
+
Checked up front rather than mid-write: discovering this half way through
|
|
538
|
+
would leave a partial store behind, and skipping the matrix would produce
|
|
539
|
+
an output silently missing X.
|
|
540
|
+
"""
|
|
541
|
+
def _check(label: str, sources: List[Any]) -> None:
|
|
542
|
+
kinds = {_matrix_kind(s) for s in sources}
|
|
543
|
+
if kinds in ({spec.CSR_MATRIX}, {spec.CSC_MATRIX}, {"dense"}):
|
|
544
|
+
return
|
|
545
|
+
raise ValueError(
|
|
546
|
+
f"Cannot concatenate {label!r}: inputs use "
|
|
547
|
+
f"{', '.join(sorted(kinds))}. Every input must use the same "
|
|
548
|
+
"encoding -- convert them to match first."
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if all("X" in r for r in roots):
|
|
552
|
+
_check("X", [r["X"] for r in roots])
|
|
553
|
+
elif any("X" in r for r in roots):
|
|
554
|
+
console.print("[yellow]Skipping X: not present in every input[/]")
|
|
555
|
+
|
|
556
|
+
names = _index_union(
|
|
557
|
+
[list(r["layers"].keys()) if "layers" in r else [] for r in roots]
|
|
558
|
+
)
|
|
559
|
+
for name in names:
|
|
560
|
+
if all("layers" in r and name in r["layers"] for r in roots):
|
|
561
|
+
_check(f"layers/{name}", [r["layers"][name] for r in roots])
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _concat_matrix(
|
|
565
|
+
dst_parent: Any,
|
|
566
|
+
name: str,
|
|
567
|
+
sources: List[Any],
|
|
568
|
+
col_maps: List[np.ndarray],
|
|
569
|
+
row_counts: List[int],
|
|
570
|
+
n_rows: int,
|
|
571
|
+
n_cols: int,
|
|
572
|
+
chunk_rows: int,
|
|
573
|
+
fill_value: float,
|
|
574
|
+
console: Console,
|
|
575
|
+
) -> bool:
|
|
576
|
+
"""Concatenate X or one layer across inputs. Returns whether it was written.
|
|
577
|
+
|
|
578
|
+
Encodings are validated by :func:`check_matrix_encodings` before the output
|
|
579
|
+
store exists, so anything reaching here is concatenable.
|
|
580
|
+
"""
|
|
581
|
+
kinds = {_matrix_kind(s) for s in sources}
|
|
582
|
+
|
|
583
|
+
if kinds == {spec.CSR_MATRIX}:
|
|
584
|
+
_concat_sparse(
|
|
585
|
+
dst_parent, name, sources, col_maps, n_rows, n_cols, chunk_rows
|
|
586
|
+
)
|
|
587
|
+
return True
|
|
588
|
+
|
|
589
|
+
if kinds == {spec.CSC_MATRIX}:
|
|
590
|
+
_concat_csc(
|
|
591
|
+
dst_parent, name, sources, col_maps, row_counts, n_rows, n_cols
|
|
592
|
+
)
|
|
593
|
+
return True
|
|
594
|
+
|
|
595
|
+
if kinds == {"dense"}:
|
|
596
|
+
_concat_dense(
|
|
597
|
+
dst_parent,
|
|
598
|
+
name,
|
|
599
|
+
sources,
|
|
600
|
+
col_maps,
|
|
601
|
+
n_rows,
|
|
602
|
+
n_cols,
|
|
603
|
+
chunk_rows,
|
|
604
|
+
fill_value,
|
|
605
|
+
)
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
raise ValueError(
|
|
609
|
+
f"Cannot concatenate {name!r}: inputs use {', '.join(sorted(kinds))}."
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _concat_obsm(
|
|
614
|
+
dst_parent: Any,
|
|
615
|
+
name: str,
|
|
616
|
+
sources: List[Any],
|
|
617
|
+
n_rows: int,
|
|
618
|
+
chunk_rows: int,
|
|
619
|
+
console: Console,
|
|
620
|
+
) -> bool:
|
|
621
|
+
"""Concatenate an obsm entry row-wise; its columns are not an axis."""
|
|
622
|
+
if not all(is_dataset(s) for s in sources):
|
|
623
|
+
console.print(f"[yellow]Skipping obsm/{name}: not a dense array in every input[/]")
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
widths = {tuple(s.shape[1:]) for s in sources}
|
|
627
|
+
if len(widths) != 1:
|
|
628
|
+
console.print(
|
|
629
|
+
f"[yellow]Skipping obsm/{name}: inputs disagree on shape {sorted(widths)}[/]"
|
|
630
|
+
)
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
trailing = widths.pop()
|
|
634
|
+
dtype = np.result_type(*[s.dtype for s in sources])
|
|
635
|
+
out = create_dataset(
|
|
636
|
+
dst_parent, name, shape=(n_rows,) + trailing, dtype=dtype
|
|
637
|
+
)
|
|
638
|
+
spec.set_encoding(out, spec.ARRAY)
|
|
639
|
+
|
|
640
|
+
offset = 0
|
|
641
|
+
for source in sources:
|
|
642
|
+
for start in range(0, source.shape[0], chunk_rows):
|
|
643
|
+
end = min(start + chunk_rows, source.shape[0])
|
|
644
|
+
out[offset + start : offset + end, ...] = source[start:end, ...]
|
|
645
|
+
offset += source.shape[0]
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# orchestration
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _merge_group(
|
|
654
|
+
dst_parent: Any,
|
|
655
|
+
name: str,
|
|
656
|
+
groups: List[Optional[Any]],
|
|
657
|
+
strategy: Optional[str],
|
|
658
|
+
console: Console,
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Merge a mapping (uns, varm, ...) across inputs under `strategy`."""
|
|
661
|
+
target = write_mapping(dst_parent, name)
|
|
662
|
+
if strategy is None:
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
keys: List[str] = []
|
|
666
|
+
for group in groups:
|
|
667
|
+
if group is None:
|
|
668
|
+
continue
|
|
669
|
+
for key in group.keys():
|
|
670
|
+
if key not in keys:
|
|
671
|
+
keys.append(key)
|
|
672
|
+
|
|
673
|
+
for key in keys:
|
|
674
|
+
members = [
|
|
675
|
+
g[key] if g is not None and key in g else _MISSING for g in groups
|
|
676
|
+
]
|
|
677
|
+
present = [m for m in members if m is not _MISSING]
|
|
678
|
+
|
|
679
|
+
if all(is_group(m) for m in present) and strategy in ("same", "unique"):
|
|
680
|
+
# Nested mappings are merged member-wise rather than compared whole.
|
|
681
|
+
if all(spec.encoding_type(m) == spec.DICT for m in present):
|
|
682
|
+
_merge_group(target, key, [
|
|
683
|
+
m if m is not _MISSING else None for m in members
|
|
684
|
+
], strategy, console)
|
|
685
|
+
continue
|
|
686
|
+
|
|
687
|
+
comparable = [
|
|
688
|
+
_MISSING if m is _MISSING else _readable_value(m) for m in members
|
|
689
|
+
]
|
|
690
|
+
keep, _ = _merge_values(comparable, strategy)
|
|
691
|
+
if keep:
|
|
692
|
+
source = next(m for m in members if m is not _MISSING)
|
|
693
|
+
copy_tree(source, target, key)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
#: Elements larger than this are not compared value-by-value.
|
|
697
|
+
MAX_COMPARABLE_ELEMENTS = 1_000_000
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class _Incomparable:
|
|
701
|
+
"""Stands for a value too large to compare, and equal to nothing."""
|
|
702
|
+
|
|
703
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
704
|
+
return "<incomparable>"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _readable_value(obj: Any, budget: Optional[List[int]] = None) -> Any:
|
|
708
|
+
"""A comparable snapshot of an element, for the merge strategies.
|
|
709
|
+
|
|
710
|
+
Recurses into groups and reads their datasets, including attributes. Using
|
|
711
|
+
only child key names -- as an earlier version did -- would make two
|
|
712
|
+
dataframes with the same columns but different values compare equal, so
|
|
713
|
+
`same` and `unique` would keep conflicting metadata.
|
|
714
|
+
|
|
715
|
+
Very large elements are reported as incomparable, which equals nothing and
|
|
716
|
+
so is never kept by `same` or `unique`.
|
|
717
|
+
"""
|
|
718
|
+
if budget is None:
|
|
719
|
+
budget = [MAX_COMPARABLE_ELEMENTS]
|
|
720
|
+
|
|
721
|
+
try:
|
|
722
|
+
attrs = tuple(
|
|
723
|
+
sorted(
|
|
724
|
+
(str(k), _hashable(spec.decode_attr(v)))
|
|
725
|
+
for k, v in obj.attrs.items()
|
|
726
|
+
if k not in ("encoding-version",)
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
if is_dataset(obj):
|
|
731
|
+
size = int(np.prod(obj.shape)) if obj.shape else 1
|
|
732
|
+
budget[0] -= size
|
|
733
|
+
if budget[0] < 0:
|
|
734
|
+
return _Incomparable()
|
|
735
|
+
return ("dataset", attrs, _hashable(np.asarray(obj[...])))
|
|
736
|
+
|
|
737
|
+
children = []
|
|
738
|
+
for key in sorted(obj.keys()):
|
|
739
|
+
value = _readable_value(obj[key], budget)
|
|
740
|
+
if isinstance(value, _Incomparable):
|
|
741
|
+
return value
|
|
742
|
+
children.append((str(key), value))
|
|
743
|
+
return ("group", attrs, tuple(children))
|
|
744
|
+
except Exception:
|
|
745
|
+
return _Incomparable()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _hashable(value: Any) -> Any:
|
|
749
|
+
"""Reduce a value to something `_equal` can compare reliably."""
|
|
750
|
+
if isinstance(value, np.ndarray):
|
|
751
|
+
return (value.shape, value.dtype.kind, value.tobytes()
|
|
752
|
+
if value.dtype.kind not in ("O", "T") else tuple(map(str, value.reshape(-1).tolist())))
|
|
753
|
+
if isinstance(value, np.generic):
|
|
754
|
+
return value.item()
|
|
755
|
+
if isinstance(value, (list, tuple)):
|
|
756
|
+
return tuple(_hashable(v) for v in value)
|
|
757
|
+
return value
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def concat_on_disk(
|
|
761
|
+
files: Sequence[Path],
|
|
762
|
+
output: Path,
|
|
763
|
+
console: Console,
|
|
764
|
+
*,
|
|
765
|
+
join: str = "inner",
|
|
766
|
+
label: Optional[str] = None,
|
|
767
|
+
keys: Optional[Sequence[str]] = None,
|
|
768
|
+
index_unique: Optional[str] = None,
|
|
769
|
+
merge: Optional[str] = None,
|
|
770
|
+
uns_merge: Optional[str] = None,
|
|
771
|
+
fill_value: float = 0.0,
|
|
772
|
+
chunk_rows: int = 1024,
|
|
773
|
+
zarr_format: Optional[int] = None,
|
|
774
|
+
) -> None:
|
|
775
|
+
"""Concatenate stores along the obs axis, streaming each one in turn.
|
|
776
|
+
|
|
777
|
+
`join` aligns var: "inner" keeps only variables present in every input,
|
|
778
|
+
"outer" keeps their union. obs columns follow the same rule. Elements of
|
|
779
|
+
the var axis and of uns are reconciled by `merge`/`uns_merge`, since
|
|
780
|
+
inputs may legitimately disagree about them.
|
|
781
|
+
|
|
782
|
+
obsp and varp are not concatenated -- a pairwise matrix has no meaningful
|
|
783
|
+
value between cells that came from different inputs. anndata's own
|
|
784
|
+
implementation refuses these too.
|
|
785
|
+
"""
|
|
786
|
+
if len(files) < 2:
|
|
787
|
+
raise ValueError("Concatenation needs at least two input stores.")
|
|
788
|
+
if join not in ("inner", "outer"):
|
|
789
|
+
raise ValueError("--join must be 'inner' or 'outer'.")
|
|
790
|
+
if output.exists():
|
|
791
|
+
raise FileExistsError(f"'{output}' already exists.")
|
|
792
|
+
|
|
793
|
+
input_keys = _resolve_keys(files, keys)
|
|
794
|
+
stores = [open_store(f, "r") for f in files]
|
|
795
|
+
|
|
796
|
+
try:
|
|
797
|
+
roots = [s.root for s in stores]
|
|
798
|
+
|
|
799
|
+
var_names = [read_names(r, "var") for r in roots]
|
|
800
|
+
obs_names = [read_names(r, "obs") for r in roots]
|
|
801
|
+
obs_counts = [axis_len(r, "obs") for r in roots]
|
|
802
|
+
|
|
803
|
+
target_var = (
|
|
804
|
+
_index_intersection(var_names)
|
|
805
|
+
if join == "inner"
|
|
806
|
+
else _index_union(var_names)
|
|
807
|
+
)
|
|
808
|
+
if not target_var:
|
|
809
|
+
raise ValueError(
|
|
810
|
+
"No variables are shared by all inputs. Use --join outer to "
|
|
811
|
+
"keep their union instead."
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
col_maps = [_column_map(names, target_var) for names in var_names]
|
|
815
|
+
n_obs = sum(obs_counts)
|
|
816
|
+
n_var = len(target_var)
|
|
817
|
+
|
|
818
|
+
console.print(
|
|
819
|
+
f"[cyan]Concatenating {len(files)} stores: "
|
|
820
|
+
f"{n_obs} obs x {n_var} var ({join} join)[/]"
|
|
821
|
+
)
|
|
822
|
+
for f, key, count in zip(files, input_keys, obs_counts):
|
|
823
|
+
console.print(f" [dim]{key}[/]: {count} obs from {f.name}")
|
|
824
|
+
|
|
825
|
+
target_obs: List[str] = []
|
|
826
|
+
for names, key in zip(obs_names, input_keys):
|
|
827
|
+
target_obs.extend(_apply_index_unique(names, key, index_unique))
|
|
828
|
+
|
|
829
|
+
if len(set(target_obs)) != len(target_obs):
|
|
830
|
+
console.print(
|
|
831
|
+
"[yellow]Warning: obs names are not unique across inputs. "
|
|
832
|
+
"Pass --index-unique to disambiguate them.[/]"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# Validated before the output exists, so a mismatch cannot leave a
|
|
836
|
+
# half-written store behind.
|
|
837
|
+
check_matrix_encodings(roots, console)
|
|
838
|
+
|
|
839
|
+
with open_store(output, "w", zarr_format=zarr_format) as dst_store:
|
|
840
|
+
dst = dst_store.root
|
|
841
|
+
_write_obs(
|
|
842
|
+
dst, roots, target_obs, obs_counts, join, label, input_keys, console
|
|
843
|
+
)
|
|
844
|
+
_write_var(dst, roots, var_names, target_var, merge, console)
|
|
845
|
+
ensure_anndata_skeleton(dst)
|
|
846
|
+
|
|
847
|
+
_write_matrices(
|
|
848
|
+
dst,
|
|
849
|
+
roots,
|
|
850
|
+
col_maps,
|
|
851
|
+
obs_counts,
|
|
852
|
+
n_obs,
|
|
853
|
+
n_var,
|
|
854
|
+
chunk_rows,
|
|
855
|
+
fill_value,
|
|
856
|
+
console,
|
|
857
|
+
)
|
|
858
|
+
_write_obsm(dst, roots, n_obs, chunk_rows, console)
|
|
859
|
+
|
|
860
|
+
uns_groups = [r["uns"] if "uns" in r else None for r in roots]
|
|
861
|
+
if any(g is not None for g in uns_groups):
|
|
862
|
+
if uns_merge is None:
|
|
863
|
+
write_mapping(dst, "uns")
|
|
864
|
+
else:
|
|
865
|
+
_merge_group(dst, "uns", uns_groups, uns_merge, console)
|
|
866
|
+
|
|
867
|
+
for name in ("obsp", "varp"):
|
|
868
|
+
if any(name in r and len(list(r[name].keys())) for r in roots):
|
|
869
|
+
console.print(
|
|
870
|
+
f"[yellow]Dropping {name}: pairwise values have no "
|
|
871
|
+
f"meaning across concatenated inputs.[/]"
|
|
872
|
+
)
|
|
873
|
+
if any("raw" in r for r in roots):
|
|
874
|
+
console.print("[yellow]Dropping raw/: not concatenated.[/]")
|
|
875
|
+
|
|
876
|
+
console.print(f"[green]Wrote[/] {output} ({n_obs} obs x {n_var} var)")
|
|
877
|
+
finally:
|
|
878
|
+
for store in stores:
|
|
879
|
+
store.close()
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _write_obs(
|
|
883
|
+
dst: Any,
|
|
884
|
+
roots: List[Any],
|
|
885
|
+
target_obs: List[str],
|
|
886
|
+
obs_counts: List[int],
|
|
887
|
+
join: str,
|
|
888
|
+
label: Optional[str],
|
|
889
|
+
input_keys: List[str],
|
|
890
|
+
console: Console,
|
|
891
|
+
) -> None:
|
|
892
|
+
"""Write the concatenated obs frame, plus the batch column if requested."""
|
|
893
|
+
groups = [r["obs"] for r in roots]
|
|
894
|
+
per_input_cols = [
|
|
895
|
+
dataframe_columns(g, resolve_index(g, "obs")[1]) for g in groups
|
|
896
|
+
]
|
|
897
|
+
|
|
898
|
+
if join == "inner":
|
|
899
|
+
shared = set(per_input_cols[0]).intersection(*map(set, per_input_cols[1:]))
|
|
900
|
+
names = [c for c in per_input_cols[0] if c in shared]
|
|
901
|
+
else:
|
|
902
|
+
names = _index_union(per_input_cols)
|
|
903
|
+
|
|
904
|
+
written: List[str] = []
|
|
905
|
+
obs_group = write_dataframe_header(dst, "obs", target_obs, [])
|
|
906
|
+
|
|
907
|
+
for name in names:
|
|
908
|
+
columns = [g[name] if name in g else None for g in groups]
|
|
909
|
+
if _concat_obs_column(obs_group, name, columns, obs_counts, console):
|
|
910
|
+
written.append(name)
|
|
911
|
+
|
|
912
|
+
if label:
|
|
913
|
+
if label in written:
|
|
914
|
+
raise ValueError(
|
|
915
|
+
f"--label {label!r} collides with an existing obs column."
|
|
916
|
+
)
|
|
917
|
+
codes = np.concatenate(
|
|
918
|
+
[np.full(n, i, dtype=np.int64) for i, n in enumerate(obs_counts)]
|
|
919
|
+
)
|
|
920
|
+
write_categorical(obs_group, label, codes, input_keys, ordered=False)
|
|
921
|
+
written.append(label)
|
|
922
|
+
|
|
923
|
+
set_column_order(obs_group, written)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _write_var(
|
|
927
|
+
dst: Any,
|
|
928
|
+
roots: List[Any],
|
|
929
|
+
var_names: List[List[str]],
|
|
930
|
+
target_var: List[str],
|
|
931
|
+
merge: Optional[str],
|
|
932
|
+
console: Console,
|
|
933
|
+
) -> None:
|
|
934
|
+
"""Write the aligned var frame, keeping columns the merge strategy allows."""
|
|
935
|
+
var_group = write_dataframe_header(dst, "var", target_var, [])
|
|
936
|
+
if merge is None:
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
groups = [r["var"] for r in roots]
|
|
940
|
+
per_input_cols = [
|
|
941
|
+
dataframe_columns(g, resolve_index(g, "var")[1]) for g in groups
|
|
942
|
+
]
|
|
943
|
+
candidates = _index_union(per_input_cols)
|
|
944
|
+
|
|
945
|
+
positions = [_column_map(target_var, names) for names in var_names]
|
|
946
|
+
written: List[str] = []
|
|
947
|
+
|
|
948
|
+
for name in candidates:
|
|
949
|
+
aligned: List[Any] = []
|
|
950
|
+
for group, where in zip(groups, positions):
|
|
951
|
+
if name not in group or (where < 0).any():
|
|
952
|
+
aligned.append(_MISSING)
|
|
953
|
+
continue
|
|
954
|
+
aligned.append(tuple(read_str_all(group[name])[i] for i in where))
|
|
955
|
+
|
|
956
|
+
keep, _ = _merge_values(aligned, merge)
|
|
957
|
+
if not keep:
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
source_i = next(
|
|
961
|
+
i for i, v in enumerate(aligned) if v is not _MISSING
|
|
962
|
+
)
|
|
963
|
+
column = groups[source_i][name]
|
|
964
|
+
take = positions[source_i]
|
|
965
|
+
_write_var_column(var_group, name, column, take)
|
|
966
|
+
written.append(name)
|
|
967
|
+
|
|
968
|
+
set_column_order(var_group, written)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _write_var_column(
|
|
972
|
+
parent: Any, name: str, column: Any, take: np.ndarray
|
|
973
|
+
) -> None:
|
|
974
|
+
"""Write one var column, reordered onto the target var index.
|
|
975
|
+
|
|
976
|
+
Each encoding is rewritten as itself. Rendering everything as text -- as an
|
|
977
|
+
earlier version did for nullable columns -- turned missing values into
|
|
978
|
+
empty strings and lost the numeric and boolean dtypes.
|
|
979
|
+
"""
|
|
980
|
+
kind = _column_kind(column)
|
|
981
|
+
|
|
982
|
+
if kind == "categorical":
|
|
983
|
+
categories = [str(c) for c in read_categories(column)]
|
|
984
|
+
codes = np.asarray(column["codes"][...], dtype=np.int64)[take]
|
|
985
|
+
write_categorical(
|
|
986
|
+
parent, name, codes, categories, ordered=is_ordered(column)
|
|
987
|
+
)
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
if kind == "numeric":
|
|
991
|
+
write_dense(parent, name, np.asarray(column[...])[take])
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
if kind == "masked":
|
|
995
|
+
enc = spec.encoding_type(column) or spec.NULLABLE_STRING_ARRAY
|
|
996
|
+
mask = np.asarray(column["mask"][...], dtype=bool)[take]
|
|
997
|
+
raw_values = np.asarray(column["values"][...])
|
|
998
|
+
if enc == spec.NULLABLE_STRING_ARRAY:
|
|
999
|
+
from adata.elements.read import decode_str_array
|
|
1000
|
+
|
|
1001
|
+
values = decode_str_array(raw_values)[take].tolist()
|
|
1002
|
+
else:
|
|
1003
|
+
values = raw_values[take]
|
|
1004
|
+
na_value = spec.decode_attr(column.attrs.get("na-value", None))
|
|
1005
|
+
write_masked(parent, name, values, mask, enc, na_value=na_value)
|
|
1006
|
+
return
|
|
1007
|
+
|
|
1008
|
+
values = read_str_all(column)
|
|
1009
|
+
write_string_array(parent, name, [values[i] for i in take])
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _write_matrices(
|
|
1013
|
+
dst: Any,
|
|
1014
|
+
roots: List[Any],
|
|
1015
|
+
col_maps: List[np.ndarray],
|
|
1016
|
+
row_counts: List[int],
|
|
1017
|
+
n_obs: int,
|
|
1018
|
+
n_var: int,
|
|
1019
|
+
chunk_rows: int,
|
|
1020
|
+
fill_value: float,
|
|
1021
|
+
console: Console,
|
|
1022
|
+
) -> None:
|
|
1023
|
+
"""Concatenate X and every layer shared by all inputs."""
|
|
1024
|
+
if all("X" in r for r in roots):
|
|
1025
|
+
_concat_matrix(
|
|
1026
|
+
dst,
|
|
1027
|
+
"X",
|
|
1028
|
+
[r["X"] for r in roots],
|
|
1029
|
+
col_maps,
|
|
1030
|
+
row_counts,
|
|
1031
|
+
n_obs,
|
|
1032
|
+
n_var,
|
|
1033
|
+
chunk_rows,
|
|
1034
|
+
fill_value,
|
|
1035
|
+
console,
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
layer_names = _index_union(
|
|
1039
|
+
[list(r["layers"].keys()) if "layers" in r else [] for r in roots]
|
|
1040
|
+
)
|
|
1041
|
+
if not layer_names:
|
|
1042
|
+
return
|
|
1043
|
+
|
|
1044
|
+
layers = write_mapping(dst, "layers")
|
|
1045
|
+
for name in layer_names:
|
|
1046
|
+
if not all("layers" in r and name in r["layers"] for r in roots):
|
|
1047
|
+
console.print(
|
|
1048
|
+
f"[yellow]Skipping layer {name!r}: not present in every input[/]"
|
|
1049
|
+
)
|
|
1050
|
+
continue
|
|
1051
|
+
_concat_matrix(
|
|
1052
|
+
layers,
|
|
1053
|
+
name,
|
|
1054
|
+
[r["layers"][name] for r in roots],
|
|
1055
|
+
col_maps,
|
|
1056
|
+
row_counts,
|
|
1057
|
+
n_obs,
|
|
1058
|
+
n_var,
|
|
1059
|
+
chunk_rows,
|
|
1060
|
+
fill_value,
|
|
1061
|
+
console,
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _write_obsm(
|
|
1066
|
+
dst: Any, roots: List[Any], n_obs: int, chunk_rows: int, console: Console
|
|
1067
|
+
) -> None:
|
|
1068
|
+
"""Concatenate obsm entries shared by all inputs."""
|
|
1069
|
+
names = _index_union(
|
|
1070
|
+
[list(r["obsm"].keys()) if "obsm" in r else [] for r in roots]
|
|
1071
|
+
)
|
|
1072
|
+
if not names:
|
|
1073
|
+
return
|
|
1074
|
+
|
|
1075
|
+
obsm = write_mapping(dst, "obsm")
|
|
1076
|
+
for name in names:
|
|
1077
|
+
if not all("obsm" in r and name in r["obsm"] for r in roots):
|
|
1078
|
+
console.print(
|
|
1079
|
+
f"[yellow]Skipping obsm/{name}: not present in every input[/]"
|
|
1080
|
+
)
|
|
1081
|
+
continue
|
|
1082
|
+
_concat_obsm(
|
|
1083
|
+
obsm, name, [r["obsm"][name] for r in roots], n_obs, chunk_rows, console
|
|
1084
|
+
)
|