pyadata-cli 0.8.0__tar.gz → 0.9.0__tar.gz

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 (56) hide show
  1. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/PKG-INFO +3 -3
  2. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/README.md +2 -2
  3. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/pyproject.toml +1 -1
  4. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/cli.py +23 -1
  5. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/__init__.py +22 -2
  6. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/base.py +4 -0
  7. pyadata_cli-0.9.0/src/adata/conversion/cellbender.py +657 -0
  8. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/features.py +1 -1
  9. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/tenx.py +42 -29
  10. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/csr_build.py +50 -1
  11. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/subset.py +6 -2
  12. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/mtx.py +13 -35
  13. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/LICENSE +0 -0
  14. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/__init__.py +0 -0
  15. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/__init__.py +0 -0
  16. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/concat.py +0 -0
  17. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/convert.py +0 -0
  18. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/create.py +0 -0
  19. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/export.py +0 -0
  20. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/import_data.py +0 -0
  21. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/info.py +0 -0
  22. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/ls.py +0 -0
  23. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/recast.py +0 -0
  24. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/split.py +0 -0
  25. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/subset.py +0 -0
  26. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/anndata_copy.py +0 -0
  27. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/mtx.py +0 -0
  28. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/__init__.py +0 -0
  29. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/concat.py +0 -0
  30. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/info.py +0 -0
  31. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/query.py +0 -0
  32. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/read.py +0 -0
  33. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/recast.py +0 -0
  34. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/select.py +0 -0
  35. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/__init__.py +0 -0
  36. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/read.py +0 -0
  37. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/spec.py +0 -0
  38. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/strings.py +0 -0
  39. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/write.py +0 -0
  40. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/__init__.py +0 -0
  41. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/array.py +0 -0
  42. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/common.py +0 -0
  43. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/dataframe.py +0 -0
  44. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/image.py +0 -0
  45. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/json_data.py +0 -0
  46. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/sparse.py +0 -0
  47. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/textio.py +0 -0
  48. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/validate.py +0 -0
  49. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/info.py +0 -0
  50. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/py.typed +0 -0
  51. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/read.py +0 -0
  52. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/storage/__init__.py +0 -0
  53. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/__init__.py +0 -0
  54. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/names.py +0 -0
  55. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/path.py +0 -0
  56. {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/staging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyadata-cli
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Streaming CLI for exploring and editing large AnnData .h5ad and .zarr stores
5
5
  Keywords: anndata,h5ad,zarr,bioinformatics,single-cell,cli
6
6
  Author: Aljes Binkevich
@@ -48,7 +48,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
48
48
  - Auto-detects `.h5ad` files vs `.zarr` directories
49
49
  - Chunked processing for dense and sparse matrices (CSR/CSC)
50
50
  - Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
51
- - Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices and 10x / STARsolo MTX folders
51
+ - Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices and 10x / STARsolo MTX folders, and from CellBender output
52
52
  - Rich terminal output with progress indicators, kept on stderr so results pipe cleanly
53
53
 
54
54
  **Documentation: [cellgeni.github.io/adata-cli](https://cellgeni.github.io/adata-cli/)**
@@ -96,7 +96,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
96
96
  - `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
97
97
  - `split` – write one store per distinct value of an annotation column, with a CSV manifest.
98
98
  - `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
99
- - `convert` – change a store's format: h5ad ↔ Zarr v2/v3, 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) and 10x / STARsolo MTX folders in and out, streaming, with the output written beside its destination and moved into place only once complete.
99
+ - `convert` – change a store's format: h5ad ↔ Zarr v2/v3, 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) and 10x / STARsolo MTX folders in and out, CellBender output in, streaming, with the output written beside its destination and moved into place only once complete.
100
100
  - `recast` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced. Called `convert` until 0.7.0.
101
101
  - `export` – extract data from a store; subcommands: `dataframe` (any dataframe group to CSV), `array` (dense to `.npy`), `sparse` (CSR/CSC to `.mtx`), `dict` (JSON), `image` (PNG). Results go to stdout when no `--output` is given.
102
102
  - `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).
@@ -8,7 +8,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
8
8
  - Auto-detects `.h5ad` files vs `.zarr` directories
9
9
  - Chunked processing for dense and sparse matrices (CSR/CSC)
10
10
  - Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
11
- - Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices and 10x / STARsolo MTX folders
11
+ - Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices and 10x / STARsolo MTX folders, and from CellBender output
12
12
  - Rich terminal output with progress indicators, kept on stderr so results pipe cleanly
13
13
 
14
14
  **Documentation: [cellgeni.github.io/adata-cli](https://cellgeni.github.io/adata-cli/)**
@@ -56,7 +56,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
56
56
  - `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
57
57
  - `split` – write one store per distinct value of an annotation column, with a CSV manifest.
58
58
  - `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
59
- - `convert` – change a store's format: h5ad ↔ Zarr v2/v3, 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) and 10x / STARsolo MTX folders in and out, streaming, with the output written beside its destination and moved into place only once complete.
59
+ - `convert` – change a store's format: h5ad ↔ Zarr v2/v3, 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) and 10x / STARsolo MTX folders in and out, CellBender output in, streaming, with the output written beside its destination and moved into place only once complete.
60
60
  - `recast` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced. Called `convert` until 0.7.0.
61
61
  - `export` – extract data from a store; subcommands: `dataframe` (any dataframe group to CSV), `array` (dense to `.npy`), `sparse` (CSR/CSC to `.mtx`), `dict` (JSON), `image` (PNG). Results go to stdout when no `--output` is given.
62
62
  - `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).
@@ -3,7 +3,7 @@
3
3
  # so the distribution is published as `pyadata-cli`. The import package
4
4
  # and the command are both still `adata`.
5
5
  name = "pyadata-cli"
6
- version = "0.8.0"
6
+ version = "0.9.0"
7
7
  description = "Streaming CLI for exploring and editing large AnnData .h5ad and .zarr stores"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
@@ -522,6 +522,8 @@ _CONVERT_FLAGS = {
522
522
  "gene_ids_column": "--gene-ids-column",
523
523
  "gene_names_column": "--gene-names-column",
524
524
  "feature_types_column": "--feature-types-column",
525
+ "raw": "--raw",
526
+ "min_cell_probability": "--min-cell-probability",
525
527
  }
526
528
 
527
529
  #: Flags that only make sense for format conversion; with recast's flags,
@@ -645,6 +647,21 @@ def convert(
645
647
  "--feature-types-column",
646
648
  help="10x output: var column holding feature types (default feature_types)",
647
649
  ),
650
+ raw: Optional[Path] = typer.Option(
651
+ None,
652
+ "--raw",
653
+ help="CellBender input: the raw Cell Ranger .h5 it ran on, added as "
654
+ "layers['cellranger']",
655
+ exists=True,
656
+ dir_okay=False,
657
+ readable=True,
658
+ ),
659
+ min_cell_probability: Optional[float] = typer.Option(
660
+ None,
661
+ "--min-cell-probability",
662
+ help="CellBender input: keep droplets with cell_probability at least "
663
+ "this (CellBender's own cutoff is 0.5)",
664
+ ),
648
665
  # Deprecated: recast's flags, so `convert` keeps working as it did until
649
666
  # 1.0.0. Hidden from --help; the old matrix paths arrive in ctx.args.
650
667
  inplace: bool = typer.Option(False, "--inplace", hidden=True),
@@ -662,7 +679,8 @@ def convert(
662
679
  Genomics matrix (Cell Ranger's *_feature_bc_matrix.h5); --to 10x-mtx
663
680
  writes a Matrix Market folder. Between h5ad and zarr every element is
664
681
  copied, streamed, keeping the source's chunking and compression where the
665
- target can express them. A 10x matrix is read as scanpy's read_10x_h5 or
682
+ target can express them. CellBender output keeps its latent variables, as
683
+ CellBender's own loader names them. A 10x matrix is read as scanpy's read_10x_h5 or
666
684
  read_10x_mtx (gex_only=False) reads it, keeping integer counts, and
667
685
  written as Cell Ranger writes it. MTX folders from Cell Ranger and
668
686
  STARsolo, velocyto's included, are read in two streaming passes.
@@ -684,6 +702,8 @@ def convert(
684
702
  adata convert Solo.out/Gene/filtered -o counts.h5ad
685
703
  adata convert Solo.out/Velocyto/raw -o velocyto.h5ad
686
704
  adata convert counts.h5ad -o counts_mtx --to 10x-mtx --layer counts
705
+ adata convert cellbender_output.h5 -o cb.h5ad --raw raw_feature_bc_matrix.h5
706
+ adata convert cellbender_output.h5 -o cells.h5ad --min-cell-probability 0.5
687
707
  """
688
708
  entries = list(ctx.args)
689
709
  recast_flags = _given(ctx, _RECAST_FLAGS)
@@ -767,6 +787,8 @@ def convert(
767
787
  gene_ids_column=gene_ids_column,
768
788
  gene_names_column=gene_names_column,
769
789
  feature_types_column=feature_types_column,
790
+ raw=raw,
791
+ min_cell_probability=min_cell_probability,
770
792
  ),
771
793
  )
772
794
  except Exception as e:
@@ -17,7 +17,7 @@ from typing import Dict, FrozenSet, List, Optional
17
17
 
18
18
  import h5py
19
19
 
20
- from adata.conversion import mtx, tenx
20
+ from adata.conversion import cellbender, mtx, tenx
21
21
  from adata.conversion.anndata_copy import AnnDataCopy
22
22
  from adata.conversion.base import ConversionPlan, Converter, Format, Options
23
23
  from adata.storage import has_valid_anndata_root_attrs, is_zarr_path
@@ -66,6 +66,15 @@ FORMATS: Dict[str, Format] = {
66
66
  anndata=True,
67
67
  sniff=is_zarr_path,
68
68
  ),
69
+ # Before 10x-h5: a CellBender file is a 10x matrix too, and the first
70
+ # sniffer to match wins. `--from 10x-h5` still reads one as plain 10x.
71
+ "cellbender": Format(
72
+ name="cellbender",
73
+ description="CellBender remove-background output (0.2 and later)",
74
+ extensions=(".h5",),
75
+ sniff=cellbender.sniff,
76
+ importer=cellbender.CellbenderImport(),
77
+ ),
69
78
  "10x-h5": Format(
70
79
  name="10x-h5",
71
80
  description="10x Genomics feature-barcode matrix (Cell Ranger v2 or v3)",
@@ -136,10 +145,21 @@ def output_format(path: Path, requested: Optional[str] = None) -> str:
136
145
  path = Path(path)
137
146
  if requested is not None and requested not in FORMATS:
138
147
  raise ValueError(f"Unknown format {requested!r}; use one of: {_names()}.")
148
+ if requested is not None and not (
149
+ FORMATS[requested].anndata or FORMATS[requested].exporter is not None
150
+ ):
151
+ raise ValueError(f"{requested} can be read but not written.")
139
152
 
140
153
  suffix = path.suffix.lower()
154
+ # Only formats that can be written: `.h5` names CellBender output too,
155
+ # but an `.h5` output is a 10x matrix.
141
156
  by_extension = next(
142
- (fmt.name for fmt in FORMATS.values() if suffix in fmt.extensions), None
157
+ (
158
+ fmt.name
159
+ for fmt in FORMATS.values()
160
+ if suffix in fmt.extensions and (fmt.anndata or fmt.exporter is not None)
161
+ ),
162
+ None,
143
163
  )
144
164
  if requested is None:
145
165
  if by_extension is None:
@@ -35,6 +35,10 @@ class Options:
35
35
  gene_names_column: Optional[str] = None
36
36
  feature_types_column: Optional[str] = None
37
37
 
38
+ # Reading CellBender output.
39
+ raw: Optional[Path] = None
40
+ min_cell_probability: Optional[float] = None
41
+
38
42
 
39
43
  @dataclass
40
44
  class ConversionPlan:
@@ -0,0 +1,657 @@
1
+ """CellBender `remove-background` output.
2
+
3
+ CellBender writes the denoised counts as a 10x v3 matrix, readable as any
4
+ other (see `tenx`), plus its latent variables:
5
+
6
+ * 0.3 and later: `/droplet_latents` (per analysed droplet: cell probability,
7
+ size, background fraction, efficiency, the gene-expression encoding),
8
+ `/global_latents` (the ambient profile and fitted constants) and
9
+ `/metadata` (learning curve, analysed barcodes, settings).
10
+ * 0.2: the same kinds of value inside the matrix group itself, named
11
+ `latent_cell_probability`, `latent_scale`, `latent_RT_efficiency`,
12
+ `latent_gene_encoding`, `ambient_expression`, and so on. The matrix group
13
+ is `/background_removed` when CellBender's input was Cell Ranger 2.
14
+
15
+ The AnnData uses the names CellBender's own loaders give, so code written
16
+ against them keeps working: per-droplet latents become obs columns (NaN for
17
+ droplets CellBender did not analyse) plus a `cellbender_analyzed` flag, the
18
+ encoding `obsm`, per-feature globals `var`, and everything else
19
+ `uns["cellbender"]`. With `--raw`, the Cell Ranger counts CellBender started
20
+ from are added as `layers["cellranger"]`, and the output's own as
21
+ `layers["cellbender"]`, as `load_anndata_from_input_and_output` does.
22
+
23
+ Which latent belongs to which barcode follows CellBender's own rule. In the
24
+ full `output.h5`, every input barcode is a row and `barcode_indices_for_latents`
25
+ places the analysed ones. In `output_filtered.h5` there is one latent row per
26
+ barcode -- but 0.3 and later still store every analysed droplet's index, so
27
+ the index is used only when its length matches the latents and it points
28
+ inside the file.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from contextlib import ExitStack
34
+ from dataclasses import dataclass, field, replace
35
+ from pathlib import Path
36
+ from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
37
+
38
+ import h5py
39
+ import numpy as np
40
+ from rich.console import Console
41
+
42
+ from adata.conversion import tenx
43
+ from adata.conversion.base import ConversionPlan, Converter, Options
44
+ from adata.conversion.features import (
45
+ STRING_STEP,
46
+ Target,
47
+ write_obs_index,
48
+ write_var,
49
+ )
50
+ from adata.core.csr_build import CsrAppender, count_keys, scratch_group, write_runs
51
+ from adata.core.recast import Matrix
52
+ from adata.core.subset import fan_out_sparse_matrix
53
+ from adata.elements import spec
54
+ from adata.elements.read import decode_str_array
55
+ from adata.elements.write import (
56
+ ensure_anndata_skeleton,
57
+ set_column_order,
58
+ write_dense,
59
+ write_mapping,
60
+ write_scalar,
61
+ write_string_array,
62
+ )
63
+ from adata.formats.mtx import EntryMap, iter_matrix_entries
64
+ from adata.storage import create_dataset, is_zarr_group, open_store
65
+
66
+ #: Datasets of a 0.2 matrix group that are the matrix, not a latent.
67
+ _MATRIX_KEYS = frozenset({
68
+ "barcodes", "data", "indices", "indptr", "shape", "features", "genes",
69
+ "gene_names", "genome", "filtered_barcodes",
70
+ })
71
+
72
+ _INDEX = "barcode_indices_for_latents"
73
+
74
+ #: The cell-probability column, by CellBender version.
75
+ _PROBABILITY = ("cell_probability", "latent_cell_probability")
76
+
77
+ #: Rows of obsm written per block.
78
+ _ROW_STEP = 1 << 16
79
+
80
+
81
+ def _matrix_group(handle: h5py.File) -> Optional[str]:
82
+ if "matrix" in handle:
83
+ return "matrix"
84
+ if "background_removed" in handle:
85
+ return "background_removed"
86
+ return None
87
+
88
+
89
+ def _has_latents(handle: h5py.File) -> bool:
90
+ if isinstance(handle.get("droplet_latents"), h5py.Group):
91
+ return True
92
+ name = _matrix_group(handle)
93
+ return name is not None and any(
94
+ key in handle[name] for key in ("latent_cell_probability", _INDEX)
95
+ )
96
+
97
+
98
+ def sniff(path: Path) -> bool:
99
+ if not tenx.sniff(path):
100
+ return False
101
+ with h5py.File(path, "r") as handle:
102
+ return _has_latents(handle)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # the latents
107
+
108
+
109
+ @dataclass
110
+ class _Latents:
111
+ version: str
112
+ #: obs column -> dataset path, one value per latent row.
113
+ per_droplet: Dict[str, str]
114
+ #: obsm key -> dataset path, one row per latent row.
115
+ embeddings: Dict[str, str]
116
+ #: var column -> dataset path, one value per feature.
117
+ per_feature: Dict[str, str]
118
+ #: uns key -> dataset path.
119
+ other: Dict[str, str]
120
+ n_latent: int
121
+ #: The file row of each latent row; None when there is one per row.
122
+ rows: Optional[np.ndarray]
123
+ probability: Optional[str] = None
124
+ n_analyzed: int = 0
125
+
126
+
127
+ def _datasets(group: h5py.Group, prefix: str) -> Iterator[Tuple[str, str, h5py.Dataset]]:
128
+ for key, item in group.items():
129
+ if isinstance(item, h5py.Dataset):
130
+ yield key, f"{prefix}/{key}", item
131
+
132
+
133
+ def _read_latents(handle: h5py.File, group: str, n_barcodes: int, n_features: int) -> _Latents:
134
+ """Classify every latent dataset, and work out which row each belongs to."""
135
+ per_droplet: Dict[str, str] = {}
136
+ embeddings: Dict[str, str] = {}
137
+ per_feature: Dict[str, str] = {}
138
+ other: Dict[str, str] = {}
139
+ lengths: Dict[str, int] = {}
140
+ index_path: Optional[str] = None
141
+
142
+ def place_global(key: str, path: str, item: h5py.Dataset) -> None:
143
+ if item.ndim == 1 and item.shape[0] == n_features and item.dtype.kind in "iuf":
144
+ per_feature[key] = path
145
+ else:
146
+ other[key] = path
147
+
148
+ if isinstance(handle.get("droplet_latents"), h5py.Group):
149
+ version = "0.3"
150
+ for key, path, item in _datasets(handle["droplet_latents"], "droplet_latents"):
151
+ if key == _INDEX:
152
+ index_path = path
153
+ elif item.ndim == 2:
154
+ embeddings[key] = path
155
+ lengths[key] = item.shape[0]
156
+ elif item.ndim == 1:
157
+ per_droplet[key] = path
158
+ lengths[key] = item.shape[0]
159
+ else:
160
+ other[key] = path
161
+ if isinstance(handle.get("global_latents"), h5py.Group):
162
+ for key, path, item in _datasets(handle["global_latents"], "global_latents"):
163
+ place_global(key, path, item)
164
+ # Metadata is uns whatever its length: `features_analyzed_inds` has
165
+ # one entry per feature when every feature was analysed, and is
166
+ # still not a property of each feature.
167
+ if isinstance(handle.get("metadata"), h5py.Group):
168
+ for key, path, _ in _datasets(handle["metadata"], "metadata"):
169
+ other[key if key not in other else f"metadata_{key}"] = path
170
+ else:
171
+ version = "0.2"
172
+ for key, path, item in _datasets(handle[group], group):
173
+ if key in _MATRIX_KEYS:
174
+ continue
175
+ if key == _INDEX:
176
+ index_path = path
177
+ elif key.startswith("latent_") and item.ndim == 2:
178
+ embeddings[key] = path
179
+ lengths[key] = item.shape[0]
180
+ elif key.startswith("latent_") and item.ndim == 1:
181
+ per_droplet[key] = path
182
+ lengths[key] = item.shape[0]
183
+ else:
184
+ place_global(key, path, item)
185
+
186
+ sizes = set(lengths.values())
187
+ if len(sizes) > 1:
188
+ raise ValueError(
189
+ "CellBender's per-droplet latents differ in length: "
190
+ + ", ".join(f"{k} {n}" for k, n in lengths.items()) + "."
191
+ )
192
+ n_latent = sizes.pop() if sizes else 0
193
+
194
+ index = None if index_path is None else np.asarray(handle[index_path][...], dtype=np.int64)
195
+ n_analyzed = int(index.size) if index is not None else n_latent
196
+ rows: Optional[np.ndarray]
197
+ if index is not None and index.size == n_latent and (
198
+ n_latent == 0 or (index.min() >= 0 and index.max() < n_barcodes)
199
+ ):
200
+ if np.unique(index).size != index.size:
201
+ raise ValueError(f"{_INDEX} repeats a barcode.")
202
+ rows = index
203
+ elif n_latent in (0, n_barcodes):
204
+ rows = None
205
+ if index_path is not None:
206
+ # A filtered 0.3+ file still lists every analysed droplet, which
207
+ # places nothing here; CellBender's loader keeps it in uns.
208
+ other[_INDEX] = index_path
209
+ else:
210
+ raise ValueError(
211
+ f"Cannot tell which barcode each of CellBender's {n_latent:,} latent "
212
+ f"rows belongs to: the matrix has {n_barcodes:,} barcodes, and "
213
+ f"{_INDEX} {'is missing' if index is None else f'has {index.size:,} entries'}."
214
+ )
215
+
216
+ probability = next((name for name in _PROBABILITY if name in per_droplet), None)
217
+ return _Latents(
218
+ version, per_droplet, embeddings, per_feature, other, n_latent, rows,
219
+ probability, n_analyzed,
220
+ )
221
+
222
+
223
+ def _spread(values: np.ndarray, rows: Optional[np.ndarray], n: int) -> np.ndarray:
224
+ """Per-latent-row values as per-barcode ones: NaN where none was inferred."""
225
+ if rows is None:
226
+ return values.astype(np.float64, copy=False)
227
+ full = np.full(n, np.nan)
228
+ full[rows] = values
229
+ return full
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # --raw
234
+
235
+
236
+ @dataclass
237
+ class _Raw:
238
+ path: Path
239
+ group: str
240
+ n_barcodes: int
241
+ dtype: np.dtype
242
+ #: For each raw barcode, its row in the output, or -1.
243
+ to_output: np.ndarray
244
+ #: Whether output rows follow the raw file's order.
245
+ in_order: bool
246
+
247
+
248
+ def _barcode_blocks(
249
+ dataset: h5py.Dataset, rows: Optional[np.ndarray] = None
250
+ ) -> Iterator[np.ndarray]:
251
+ """The barcodes, or those at `rows` (ascending), a block of the column at a time.
252
+
253
+ Rows are picked out of contiguous reads. h5py's fancy indexing pays per
254
+ index selected: 65,536 scattered rows read some 500 times slower than a
255
+ slice of the whole column.
256
+ """
257
+ n = dataset.shape[0]
258
+ for lo in range(0, n, STRING_STEP):
259
+ hi = min(lo + STRING_STEP, n)
260
+ if rows is None:
261
+ yield decode_str_array(np.asarray(dataset[lo:hi]))
262
+ continue
263
+ first, last = np.searchsorted(rows, (lo, hi))
264
+ if first < last:
265
+ yield decode_str_array(np.asarray(dataset[lo:hi])[rows[first:last] - lo])
266
+
267
+
268
+ def _plan_raw(
269
+ path: Path, options: Options, matrix: Any, barcodes: h5py.Dataset, out_rows: Optional[np.ndarray]
270
+ ) -> _Raw:
271
+ """Check the raw file against the output, and map its barcodes to output rows."""
272
+ with h5py.File(path, "r") as handle:
273
+ if not tenx.sniff(path):
274
+ raise ValueError(f"--raw {path.name} is not a 10x HDF5 matrix.")
275
+ raw = tenx._read_matrix(handle, options.genome)
276
+ same = (
277
+ raw.n_features == matrix.n_features
278
+ and np.array_equal(raw.ids, matrix.ids)
279
+ and np.array_equal(raw.names, matrix.names)
280
+ )
281
+ if same and raw.feature_types is not None and matrix.feature_types is not None:
282
+ same = np.array_equal(raw.feature_types, matrix.feature_types)
283
+ if not same:
284
+ raise ValueError(
285
+ f"--raw {path.name} does not list the same features, in the same "
286
+ "order, as the CellBender output; it must be the matrix CellBender "
287
+ "was run on."
288
+ )
289
+
290
+ n_out = barcodes.shape[0] if out_rows is None else out_rows.size
291
+ raw_barcodes = handle[raw.group]["barcodes"]
292
+ to_output = np.full(raw.n_barcodes, -1, dtype=np.int64)
293
+
294
+ # The full output.h5 lists the raw file's barcodes in its own order:
295
+ # compare block by block before building a lookup of them all.
296
+ if out_rows is None and raw.n_barcodes == barcodes.shape[0] and all(
297
+ np.array_equal(a, b)
298
+ for a, b in zip(_barcode_blocks(raw_barcodes), _barcode_blocks(barcodes))
299
+ ):
300
+ to_output[:] = np.arange(raw.n_barcodes)
301
+ elif n_out:
302
+ # The output's barcodes, sorted, to look each raw block up in:
303
+ # a numpy array, rather than a dict whose per-string overhead is
304
+ # several times the barcode itself.
305
+ wanted = np.concatenate(list(_barcode_blocks(barcodes, out_rows)))
306
+ order = np.argsort(wanted, kind="stable")
307
+ ranked = wanted[order]
308
+ start = 0
309
+ for block in _barcode_blocks(raw_barcodes):
310
+ at = np.minimum(np.searchsorted(ranked, block), ranked.size - 1)
311
+ hit = ranked[at] == block
312
+ to_output[start : start + block.size][hit] = order[at[hit]]
313
+ start += block.size
314
+ found = int((to_output >= 0).sum())
315
+ if found != n_out:
316
+ raise ValueError(
317
+ f"{n_out - found:,} of the output's {n_out:,} barcodes are not in "
318
+ f"--raw {path.name}; it must be the matrix CellBender was run on."
319
+ )
320
+ mapped = to_output[to_output >= 0]
321
+ return _Raw(
322
+ path=path,
323
+ group=raw.group,
324
+ n_barcodes=raw.n_barcodes,
325
+ dtype=handle[raw.group]["data"].dtype,
326
+ to_output=to_output,
327
+ in_order=bool(np.all(np.diff(mapped) > 0)),
328
+ )
329
+
330
+
331
+ def _write_raw(
332
+ raw: _Raw, roots: Sequence[Any], targets: Sequence[Target], n_out: int,
333
+ n_features: int, compression: Optional[str],
334
+ ) -> None:
335
+ """layers["cellranger"]: the raw counts of each output's barcodes and features."""
336
+ emap = _entry_map(n_out, n_features, targets)
337
+ destinations = [
338
+ [CsrAppender(
339
+ root["layers"], "cellranger", shape=(n_out, emap.target_cols[t]),
340
+ data_dtype=raw.dtype, compression=compression,
341
+ )]
342
+ for t, root in enumerate(roots)
343
+ ]
344
+ with h5py.File(raw.path, "r") as handle:
345
+ group = handle[raw.group]
346
+ source = Matrix(
347
+ tenx._AsCsr(group, (raw.n_barcodes, n_features)), spec.CSR_MATRIX,
348
+ (raw.n_barcodes, n_features), group["data"].dtype,
349
+ )
350
+ def blocks():
351
+ for rows, cols, values in iter_matrix_entries(source, transpose=False):
352
+ out = raw.to_output[rows]
353
+ kept = out >= 0
354
+ yield emap.apply(out[kept], cols[kept], values[kept])
355
+
356
+ grouped = raw.in_order and emap.n_targets == 1
357
+ # Entries per output row, which size the buckets. Without a feature
358
+ # filter that is each raw row's length; with one, only a counting
359
+ # pass knows, and an estimate from widths alone can put a target's
360
+ # concentrated entries all in one bucket.
361
+ counts = np.zeros(emap.n_targets * n_out, dtype=np.int64)
362
+ if grouped:
363
+ pass # streamed straight through: no buckets to size
364
+ elif emap.owner is None:
365
+ indptr = np.asarray(group["indptr"][...], dtype=np.int64)
366
+ hit = raw.to_output >= 0
367
+ counts[raw.to_output[hit]] = np.diff(indptr)[hit]
368
+ else:
369
+ for keys, _, _ in blocks():
370
+ count_keys(counts, keys)
371
+
372
+ write_runs(
373
+ blocks(),
374
+ n_rows=n_out,
375
+ n_targets=emap.n_targets,
376
+ destinations=destinations,
377
+ scratch_parent=roots[0],
378
+ grouped=grouped,
379
+ canonical=False,
380
+ counts=counts,
381
+ value_dtype=raw.dtype,
382
+ chunk=1 << 20,
383
+ )
384
+ for appenders in destinations:
385
+ for appender in appenders:
386
+ appender.finish()
387
+
388
+
389
+ def _entry_map(n_rows: int, n_features: int, targets: Sequence[Target]) -> EntryMap:
390
+ if len(targets) == 1 and targets[0].keep is None:
391
+ return EntryMap(n_rows=n_rows, n_cols=n_features)
392
+ owner = np.full(n_features, -1, dtype=np.int64)
393
+ local = np.zeros(n_features, dtype=np.int64)
394
+ widths = []
395
+ for t, target in enumerate(targets):
396
+ keep = np.arange(n_features) if target.keep is None else target.keep
397
+ owner[keep] = t
398
+ local[keep] = np.arange(keep.size)
399
+ widths.append(int(keep.size))
400
+ return EntryMap(
401
+ n_rows=n_rows, n_cols=n_features, owner=owner, local=local,
402
+ n_targets=len(targets), target_cols=widths,
403
+ )
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # import
408
+
409
+
410
+ @dataclass
411
+ class _Plan:
412
+ matrix: Any
413
+ targets: List[Target]
414
+ latents: _Latents
415
+ out_rows: Optional[np.ndarray]
416
+ raw: Optional[_Raw]
417
+ zarr_format: Optional[int]
418
+ filtered_barcodes: bool = False
419
+ extra: Dict[str, Any] = field(default_factory=dict)
420
+
421
+
422
+ class CellbenderImport(Converter):
423
+ options = tenx.TenxImport.options | frozenset({"--raw", "--min-cell-probability"})
424
+
425
+ def plan(
426
+ self,
427
+ source: Path,
428
+ output: Path,
429
+ src_format: str,
430
+ dst_format: str,
431
+ options: Options,
432
+ ) -> ConversionPlan:
433
+ with h5py.File(source, "r") as handle:
434
+ matrix, outputs, targets, warnings = tenx.plan_matrix_import(
435
+ handle, output, options
436
+ )
437
+ latents = _read_latents(
438
+ handle, matrix.group, matrix.n_barcodes, matrix.n_features
439
+ )
440
+
441
+ out_rows: Optional[np.ndarray] = None
442
+ if options.min_cell_probability is not None:
443
+ if not 0 <= options.min_cell_probability <= 1:
444
+ raise ValueError("--min-cell-probability must lie between 0 and 1.")
445
+ if latents.probability is None:
446
+ raise ValueError(
447
+ "This CellBender file records no cell probabilities to "
448
+ "filter on."
449
+ )
450
+ values = np.asarray(handle[latents.per_droplet[latents.probability]][...])
451
+ probability = _spread(values, latents.rows, matrix.n_barcodes)
452
+ with np.errstate(invalid="ignore"):
453
+ out_rows = np.flatnonzero(probability >= options.min_cell_probability)
454
+
455
+ raw = None
456
+ if options.raw is not None:
457
+ raw = _plan_raw(
458
+ Path(options.raw), options, matrix,
459
+ handle[matrix.group]["barcodes"], out_rows,
460
+ )
461
+
462
+ n_out = matrix.n_barcodes if out_rows is None else out_rows.size
463
+ described = (
464
+ f"{matrix.n_barcodes:,} droplets ({latents.n_analyzed:,} analysed) x "
465
+ f"{matrix.n_features:,} features"
466
+ )
467
+ if out_rows is not None:
468
+ described += (
469
+ f", keeping {n_out:,} with cell probability ≥ "
470
+ f"{options.min_cell_probability:g}"
471
+ )
472
+ if len(outputs) > 1:
473
+ described += f", into {len(outputs)} outputs by feature type"
474
+ if raw is not None:
475
+ described += f", with the raw counts of {raw.path.name}"
476
+ return ConversionPlan(
477
+ source=source,
478
+ src_format=src_format,
479
+ dst_format=dst_format,
480
+ outputs=outputs,
481
+ summary=f"CellBender {latents.version} → {dst_format}, {described}",
482
+ warnings=warnings,
483
+ detail=_Plan(
484
+ matrix=matrix,
485
+ targets=targets,
486
+ latents=latents,
487
+ out_rows=out_rows,
488
+ raw=raw,
489
+ zarr_format=(options.zarr_format or 3) if dst_format == "zarr" else None,
490
+ ),
491
+ )
492
+
493
+ def write(
494
+ self, plan: ConversionPlan, staged: Sequence[Path], console: Console
495
+ ) -> None:
496
+ detail: _Plan = plan.detail
497
+ matrix, latents, out_rows = detail.matrix, detail.latents, detail.out_rows
498
+ n_out = matrix.n_barcodes if out_rows is None else out_rows.size
499
+
500
+ with h5py.File(plan.source, "r") as handle, ExitStack() as stack:
501
+ group = handle[matrix.group]
502
+ roots = [
503
+ stack.enter_context(
504
+ open_store(path, "w", zarr_format=detail.zarr_format)
505
+ ).root
506
+ for path in staged
507
+ ]
508
+ per_feature = {
509
+ key: np.asarray(handle[path][...]) for key, path in latents.per_feature.items()
510
+ }
511
+ if "features_analyzed_inds" in latents.other:
512
+ # As CellBender's anndata_from_h5 adds it.
513
+ analysed = np.zeros(matrix.n_features, dtype=bool)
514
+ analysed[np.asarray(handle[latents.other["features_analyzed_inds"]][...])] = True
515
+ per_feature["cellbender_analyzed"] = analysed
516
+ with console.status("[cyan]Writing obs, var and the latents...[/]"):
517
+ for root, target in zip(roots, detail.targets):
518
+ _write_obs(root, handle, group, latents, matrix.n_barcodes, out_rows)
519
+ columns = dict(target.columns)
520
+ for key, values in per_feature.items():
521
+ columns[key] = values if target.keep is None else values[target.keep]
522
+ write_var(root, replace(target, columns=columns))
523
+ ensure_anndata_skeleton(root)
524
+ _write_embeddings(root, handle, latents, matrix.n_barcodes, out_rows)
525
+ tenx._write_uns(root, matrix)
526
+ if matrix.group == "background_removed":
527
+ # CellBender's group, not a genome.
528
+ del root["uns"]["cellranger"]["genome"]
529
+ _write_uns(root, handle, latents)
530
+
531
+ # One read of X, for X and, with --raw, layers["cellbender"].
532
+ entries = [(root, out_rows, target.keep) for root, target in zip(roots, detail.targets)]
533
+ if detail.raw is not None:
534
+ entries += [
535
+ (root["layers"], out_rows, target.keep, "cellbender")
536
+ for root, target in zip(roots, detail.targets)
537
+ ]
538
+ with console.status("[cyan]Copying X...[/]"):
539
+ fan_out_sparse_matrix(
540
+ tenx._AsCsr(group, (matrix.n_barcodes, matrix.n_features)), "X", entries
541
+ )
542
+
543
+ if detail.raw is not None:
544
+ with console.status(f"[cyan]Reading the raw counts of {detail.raw.path.name}...[/]"):
545
+ _write_raw(
546
+ detail.raw, roots, detail.targets, n_out, matrix.n_features,
547
+ None if is_zarr_group(roots[0]) else "gzip",
548
+ )
549
+
550
+
551
+ def _write_obs(
552
+ root: Any, handle: h5py.File, group: h5py.Group, latents: _Latents,
553
+ n_barcodes: int, out_rows: Optional[np.ndarray],
554
+ ) -> None:
555
+ n = n_barcodes if out_rows is None else out_rows.size
556
+ blocks = ([str(v) for v in block] for block in _barcode_blocks(group["barcodes"], out_rows))
557
+ obs = write_obs_index(root, n, blocks)
558
+
559
+ analyzed = np.zeros(n_barcodes, dtype=bool)
560
+ if latents.rows is None:
561
+ analyzed[:] = latents.n_latent == n_barcodes
562
+ else:
563
+ analyzed[latents.rows] = True
564
+ columns = ["cellbender_analyzed"]
565
+ write_dense(obs, "cellbender_analyzed", analyzed if out_rows is None else analyzed[out_rows])
566
+ for key, path in latents.per_droplet.items():
567
+ values = _spread(np.asarray(handle[path][...]), latents.rows, n_barcodes)
568
+ write_dense(obs, key, values if out_rows is None else values[out_rows])
569
+ columns.append(key)
570
+ set_column_order(obs, columns)
571
+
572
+
573
+ def _write_embeddings(
574
+ root: Any, handle: h5py.File, latents: _Latents, n_barcodes: int,
575
+ out_rows: Optional[np.ndarray],
576
+ ) -> None:
577
+ """Each embedding, a block of output rows at a time, NaN where none was inferred.
578
+
579
+ Latent rows come in CellBender's order, analysed droplets by count, and
580
+ output rows in the file's, so one block of output draws on latents from
581
+ anywhere. Reading those rows by fancy indexing is some 500 times slower
582
+ than a slice, so the latents are read in slices and sorted, by the output
583
+ block they belong to, into a scratch copy that each block then reads in
584
+ one piece.
585
+ """
586
+ if not latents.embeddings:
587
+ return
588
+ n_out = n_barcodes if out_rows is None else out_rows.size
589
+ position = np.arange(n_barcodes) if out_rows is None else np.full(n_barcodes, -1)
590
+ if out_rows is not None:
591
+ position[out_rows] = np.arange(n_out)
592
+ # Each latent row's output row, or -1.
593
+ target = (
594
+ position[: latents.n_latent] if latents.rows is None else position[latents.rows]
595
+ )
596
+ kept = np.flatnonzero(target >= 0)
597
+ block_of = target[kept] // _ROW_STEP
598
+ by_block = np.argsort(block_of, kind="stable")
599
+ n_blocks = -(-n_out // _ROW_STEP)
600
+ starts = np.concatenate(([0], np.cumsum(np.bincount(block_of, minlength=n_blocks))))
601
+ # The output row of each scratch row.
602
+ placed = target[kept][by_block]
603
+
604
+ with scratch_group(root, "__embedding_scratch__") as scratch:
605
+ for key, path in latents.embeddings.items():
606
+ source = handle[path]
607
+ width = source.shape[1]
608
+ dtype = source.dtype if source.dtype.kind == "f" else np.float64
609
+ sorted_rows = create_dataset(
610
+ scratch, key, shape=(kept.size, width), dtype=dtype,
611
+ chunks=(min(max(kept.size, 1), _ROW_STEP), width),
612
+ )
613
+ cursor = starts[:-1].copy()
614
+ for lo in range(0, latents.n_latent, _ROW_STEP):
615
+ hi = min(lo + _ROW_STEP, latents.n_latent)
616
+ here = target[lo:hi]
617
+ have = np.flatnonzero(here >= 0)
618
+ if have.size == 0:
619
+ continue
620
+ values = np.asarray(source[lo:hi])[have]
621
+ blocks = here[have] // _ROW_STEP
622
+ order = np.argsort(blocks, kind="stable")
623
+ values, blocks = values[order], blocks[order]
624
+ edges = np.flatnonzero(np.diff(blocks)) + 1
625
+ for run, b in zip(np.split(values, edges), blocks[np.r_[0, edges]]):
626
+ sorted_rows[cursor[b] : cursor[b] + len(run)] = run
627
+ cursor[b] += len(run)
628
+
629
+ out = create_dataset(
630
+ root["obsm"], key, shape=(n_out, width), dtype=dtype,
631
+ chunks=(min(max(n_out, 1), _ROW_STEP), width),
632
+ )
633
+ for b in range(n_blocks):
634
+ lo = b * _ROW_STEP
635
+ block = np.full((min(_ROW_STEP, n_out - lo), width), np.nan, dtype=dtype)
636
+ a, z = starts[b], starts[b + 1]
637
+ if a < z:
638
+ block[placed[a:z] - lo] = sorted_rows[a:z]
639
+ out[lo : lo + block.shape[0]] = block
640
+ spec.set_encoding(out, spec.ARRAY)
641
+
642
+
643
+ def _write_uns(root: Any, handle: h5py.File, latents: _Latents) -> None:
644
+ """Everything else CellBender recorded, as `uns["cellbender"]`."""
645
+ meta = write_mapping(root["uns"], "cellbender")
646
+ write_scalar(meta, "version", latents.version)
647
+ for key, path in latents.other.items():
648
+ # A setting stays the one-element array CellBender stores it as,
649
+ # as its own loader gives it.
650
+ value = np.asarray(handle[path][...])
651
+ if value.ndim == 0:
652
+ item = value.item()
653
+ write_scalar(meta, key, item.decode("utf-8", "replace") if isinstance(item, bytes) else item)
654
+ elif value.dtype.kind in "SOU":
655
+ write_string_array(meta, key, decode_str_array(value))
656
+ else:
657
+ write_dense(meta, key, value)
@@ -215,7 +215,7 @@ def write_var(root: Any, target: Target) -> None:
215
215
  """
216
216
  var = write_dataframe_header(root, "var", target.var_names, list(target.columns))
217
217
  for key, values in target.columns.items():
218
- if values.dtype.kind == "b":
218
+ if values.dtype.kind in "biuf":
219
219
  write_dense(var, key, values)
220
220
  continue
221
221
  categories, codes = np.unique(values, return_inverse=True)
@@ -261,6 +261,44 @@ class _AsCsr:
261
261
  # import
262
262
 
263
263
 
264
+ def plan_matrix_import(
265
+ handle: h5py.File, output: Path, options: Options
266
+ ) -> Tuple[_Matrix, List[Path], List[Target], List[str]]:
267
+ """Read a 10x matrix's description and plan the AnnData outputs from it.
268
+
269
+ Shared by every format built on a 10x matrix: the import flags mean the
270
+ same thing for all of them.
271
+ """
272
+ check_import_options(options)
273
+ matrix = _read_matrix(handle, options.genome)
274
+
275
+ if matrix.version == 2 and (
276
+ options.feature_type is not None or options.split_feature_types
277
+ ):
278
+ raise ValueError(
279
+ "This is a Cell Ranger 2 file, which records no feature types; "
280
+ "--feature-type and --split-feature-types need a v3 file."
281
+ )
282
+
283
+ keep = np.ones(matrix.n_features, dtype=bool)
284
+ if options.genome is not None and matrix.version == 3:
285
+ genomes = matrix.columns.get("genome")
286
+ if genomes is None:
287
+ raise ValueError("This file records no genome per feature to filter on.")
288
+ if options.genome not in set(genomes):
289
+ raise ValueError(
290
+ f"No features of genome {options.genome!r}; this file has: "
291
+ f"{', '.join(sorted(set(genomes)))}."
292
+ )
293
+ keep &= genomes == options.genome
294
+
295
+ outputs, targets, warnings = plan_targets(
296
+ output, matrix.names, matrix.columns, matrix.feature_types, keep, options,
297
+ noun="file",
298
+ )
299
+ return matrix, outputs, targets, warnings
300
+
301
+
264
302
  class TenxImport(Converter):
265
303
  options = frozenset(
266
304
  {"--genome", "--var-names", "--make-unique", "--feature-type",
@@ -275,38 +313,13 @@ class TenxImport(Converter):
275
313
  dst_format: str,
276
314
  options: Options,
277
315
  ) -> ConversionPlan:
278
- check_import_options(options)
279
-
280
316
  with h5py.File(source, "r") as handle:
281
- matrix = _read_matrix(handle, options.genome)
282
- has_filtered = "filtered_barcodes" in handle[matrix.group]
283
-
284
- if matrix.version == 2 and (
285
- options.feature_type is not None or options.split_feature_types
286
- ):
287
- raise ValueError(
288
- "This is a Cell Ranger 2 file, which records no feature types; "
289
- "--feature-type and --split-feature-types need a v3 file."
317
+ matrix, outputs, targets, warnings = plan_matrix_import(
318
+ handle, output, options
290
319
  )
291
-
292
- keep = np.ones(matrix.n_features, dtype=bool)
293
- if options.genome is not None and matrix.version == 3:
294
- genomes = matrix.columns.get("genome")
295
- if genomes is None:
296
- raise ValueError("This file records no genome per feature to filter on.")
297
- if options.genome not in set(genomes):
298
- raise ValueError(
299
- f"No features of genome {options.genome!r}; this file has: "
300
- f"{', '.join(sorted(set(genomes)))}."
301
- )
302
- keep &= genomes == options.genome
303
-
304
- types = matrix.feature_types
305
- outputs, targets, warnings = plan_targets(
306
- output, matrix.names, matrix.columns, types, keep, options, noun="file"
307
- )
320
+ has_filtered = "filtered_barcodes" in handle[matrix.group]
308
321
  described = describe_import(
309
- matrix.n_barcodes, matrix.n_features, types, targets
322
+ matrix.n_barcodes, matrix.n_features, matrix.feature_types, targets
310
323
  )
311
324
 
312
325
  return ConversionPlan(
@@ -23,7 +23,7 @@ import os
23
23
  import tempfile
24
24
  from contextlib import contextmanager
25
25
  from pathlib import Path
26
- from typing import Any, Iterator, List, Optional, Tuple
26
+ from typing import Any, Iterator, List, Optional, Sequence, Tuple
27
27
 
28
28
  import h5py
29
29
  import numpy as np
@@ -326,3 +326,52 @@ class CsrAppender:
326
326
  )
327
327
  dataset[:] = indptr
328
328
  return self.nnz
329
+
330
+
331
+ def write_runs(
332
+ blocks: Iterator[Triple],
333
+ *,
334
+ n_rows: int,
335
+ n_targets: int,
336
+ destinations: Sequence[Sequence[CsrAppender]],
337
+ scratch_parent: Any,
338
+ grouped: bool,
339
+ canonical: bool,
340
+ counts: np.ndarray,
341
+ value_dtype: Any,
342
+ chunk: int,
343
+ ) -> int:
344
+ """Append entries, given in any order, to each output's CSR matrix.
345
+
346
+ `blocks` yield `(key, column, value)` with `key = target * n_rows + row`,
347
+ so outputs share one sort. `counts` holds entries per key, from a first
348
+ pass, and `grouped` / `canonical` what that pass learned of the order:
349
+ grouped entries go straight through, others through on-disk buckets.
350
+ `destinations[t]` are the appenders for output `t`. Returns how many
351
+ duplicate entries were summed away.
352
+ """
353
+ summer = DuplicateSummer()
354
+
355
+ def consume(runs: Iterator[Triple]) -> None:
356
+ for keys, minors, values in summer(runs):
357
+ targets = keys // n_rows if n_targets > 1 else None
358
+ for t in np.unique(targets) if targets is not None else (0,):
359
+ if targets is not None:
360
+ pick = targets == t
361
+ rows, cols, vals = keys[pick] - t * n_rows, minors[pick], values[pick]
362
+ else:
363
+ rows, cols, vals = keys, minors, values
364
+ for appender in destinations[int(t)]:
365
+ appender.append(rows, cols, vals)
366
+
367
+ if grouped:
368
+ consume(grouped_runs(blocks, sort=not canonical))
369
+ else:
370
+ indptr = np.concatenate(([0], np.cumsum(counts)))
371
+ bounds = bucket_bounds(indptr, chunk=chunk)
372
+ with scratch_group(scratch_parent, "__csr_scratch__") as scratch:
373
+ sorter = BucketSorter(scratch, bounds, value_dtype)
374
+ for keys, minors, values in blocks:
375
+ sorter.add(keys, minors, values)
376
+ consume(sorter.runs())
377
+ return summer.summed
@@ -476,7 +476,11 @@ def fan_out_sparse_matrix(
476
476
  n_major, n_minor = (n_rows, n_cols) if csr else (n_cols, n_rows)
477
477
 
478
478
  outputs: List[_SparseOutput] = []
479
- for dst_parent, obs_idx, var_idx in targets:
479
+ for target in targets:
480
+ # A fourth element names this target's matrix, where it differs
481
+ # from `name`: one read can then fill X and a layer alike.
482
+ dst_parent, obs_idx, var_idx = target[:3]
483
+ out_name = target[3] if len(target) > 3 else name
480
484
  major_idx, minor_keep = (obs_idx, var_idx) if csr else (var_idx, obs_idx)
481
485
  _check_sorted(major_idx, "obs" if csr else "var")
482
486
  out_shape = (
@@ -487,7 +491,7 @@ def fan_out_sparse_matrix(
487
491
  _SparseOutput(
488
492
  src,
489
493
  dst_parent,
490
- name,
494
+ out_name,
491
495
  enc,
492
496
  out_shape,
493
497
  major_idx,
@@ -29,14 +29,10 @@ from typing import Any, BinaryIO, Iterator, List, Optional, Sequence, Tuple
29
29
  import numpy as np
30
30
 
31
31
  from adata.core.csr_build import (
32
- BucketSorter,
33
32
  CsrAppender,
34
- DuplicateSummer,
35
- bucket_bounds,
36
33
  count_keys,
37
- grouped_runs,
38
- scratch_group,
39
34
  sum_keys,
35
+ write_runs,
40
36
  )
41
37
  from adata.core.recast import DEFAULT_CHUNK, Matrix
42
38
  from adata.elements import spec
@@ -331,7 +327,7 @@ def read_into(
331
327
  destinations: Sequence[Sequence[CsrAppender]],
332
328
  scratch_parent: Any,
333
329
  *,
334
- chunk: int = DEFAULT_CHUNK,
330
+ chunk: Optional[int] = None,
335
331
  block_bytes: int = TEXT_BLOCK_BYTES,
336
332
  ) -> int:
337
333
  """Pass 2: append the file's entries to each output, in CSR order.
@@ -340,36 +336,18 @@ def read_into(
340
336
  every entry of that output. Returns how many duplicate entries were
341
337
  summed away.
342
338
  """
343
- blocks = (
344
- emap.apply(*entry)
345
- for entry in iter_entries(header, block_bytes=block_bytes)
339
+ return write_runs(
340
+ (emap.apply(*entry) for entry in iter_entries(header, block_bytes=block_bytes)),
341
+ n_rows=emap.n_rows,
342
+ n_targets=emap.n_targets,
343
+ destinations=destinations,
344
+ scratch_parent=scratch_parent,
345
+ grouped=scanned.grouped,
346
+ canonical=scanned.canonical,
347
+ counts=scanned.counts,
348
+ value_dtype=np.float64 if header.field == "real" else np.int64,
349
+ chunk=chunk or DEFAULT_CHUNK,
346
350
  )
347
- summer = DuplicateSummer()
348
-
349
- def consume(runs: Iterator[Tuple[np.ndarray, np.ndarray, np.ndarray]]) -> None:
350
- for keys, minors, values in summer(runs):
351
- targets = keys // emap.n_rows
352
- for t in np.unique(targets) if emap.n_targets > 1 else (0,):
353
- if emap.n_targets > 1:
354
- pick = targets == t
355
- rows, cols, vals = keys[pick] - t * emap.n_rows, minors[pick], values[pick]
356
- else:
357
- rows, cols, vals = keys, minors, values
358
- for appender in destinations[int(t)]:
359
- appender.append(rows, cols, vals)
360
-
361
- if scanned.grouped:
362
- consume(grouped_runs(blocks, sort=not scanned.canonical))
363
- else:
364
- indptr = np.concatenate(([0], np.cumsum(scanned.counts)))
365
- bounds = bucket_bounds(indptr, chunk=chunk)
366
- value_type = np.float64 if header.field == "real" else np.int64
367
- with scratch_group(scratch_parent, "__mtx_scratch__") as scratch:
368
- sorter = BucketSorter(scratch, bounds, value_type)
369
- for keys, minors, values in blocks:
370
- sorter.add(keys, minors, values)
371
- consume(sorter.runs())
372
- return summer.summed
373
351
 
374
352
 
375
353
  # ---------------------------------------------------------------------------
File without changes