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.
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/PKG-INFO +3 -3
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/README.md +2 -2
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/pyproject.toml +1 -1
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/cli.py +23 -1
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/__init__.py +22 -2
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/base.py +4 -0
- pyadata_cli-0.9.0/src/adata/conversion/cellbender.py +657 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/features.py +1 -1
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/tenx.py +42 -29
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/csr_build.py +50 -1
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/subset.py +6 -2
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/mtx.py +13 -35
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/LICENSE +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/concat.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/convert.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/create.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/export.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/import_data.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/info.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/ls.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/recast.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/split.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/commands/subset.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/anndata_copy.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/conversion/mtx.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/concat.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/info.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/query.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/read.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/recast.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/core/select.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/read.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/spec.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/strings.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/elements/write.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/array.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/common.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/dataframe.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/image.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/json_data.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/sparse.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/textio.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/formats/validate.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/info.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/py.typed +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/read.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/storage/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/__init__.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/names.py +0 -0
- {pyadata_cli-0.8.0 → pyadata_cli-0.9.0}/src/adata/util/path.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
(
|
|
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
|
|
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 =
|
|
282
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
344
|
-
emap.apply(*entry)
|
|
345
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|