diff --git a/cytearc/datastore/_operations/presentation.py b/cytearc/datastore/_operations/presentation.py index d8dd1c8..1008de4 100644 --- a/cytearc/datastore/_operations/presentation.py +++ b/cytearc/datastore/_operations/presentation.py @@ -32,7 +32,7 @@ MembershipStrengthArguments, SmartLabelArguments, ) -from ...metadata.membership import exported_membership, membership_declaration +from ...metadata.membership import exported_membership from ...metadata.rows import apply_missing_mask, read_metadata_missing_rows from ...metadata.selection import resolve_complete_labels from ...metadata.artifacts import ( @@ -47,12 +47,20 @@ ) if TYPE_CHECKING: - from ...writers.export import H5adExportPlan, H5adMatrix + from ...writers.export import ( + H5adCategorical, + H5adExportPlan, + H5adMatrix, + H5adSource, + ) from ..mapping_datastore import MappingDatastore as _PresentationOperationsBase from ..pipeline_run import PipelineAxisView, PipelineRun else: _PresentationOperationsBase = object +# The run outputs that a run export writes to obsm, under Scanpy's keys. +_RUN_OBSM_OUTPUTS = {"pca": "X_pca", "harmony": "X_pca_harmony"} + def _letter_suffix(position: int) -> str: """Return a lowercase base-26 suffix: 1 is ``a``, 26 is ``z``, 27 is ``aa``.""" @@ -179,25 +187,109 @@ def _require_unique_layer_ids(selected_ids: np.ndarray) -> None: def _anndata_from_plan(anndata: Any, plan: "H5adExportPlan") -> Any: """Materialize an export plan as an in-memory AnnData object. - ``obs`` and ``var`` are the tables of ``cytearc.writers.export.h5ad_frame``, - so their categoricals are the ones that the file of the plan stores. + ``obs`` and ``var`` are the tables of ``cytearc.writers.export.h5ad_table`` + and ``uns`` is ``plan_uns`` of the plan, so their categoricals, colors, + and declarations are the ones that the file of the plan stores. """ - from ...writers.export import h5ad_frame, materialize_h5ad_matrix + from ...writers.export import ( + h5ad_table, + materialize_h5ad_matrix, + plain_uns, + plan_uns, + read_obsm, + read_obsp, + read_varm, + ) n_obs, n_vars = plan.x.shape + obs, colors = h5ad_table(plan.obs_index, plan.obs, n_obs) + var, _var_colors = h5ad_table(plan.var_index, plan.var, n_vars) adata = anndata( materialize_h5ad_matrix(plan.x), - obs=h5ad_frame(plan.obs_index, plan.obs, n_obs), - var=h5ad_frame(plan.var_index, plan.var, n_vars), - uns=membership_declaration(dict(plan.membership)), + obs=obs, + var=var, + uns=plain_uns(plan_uns(plan, obs.columns, colors)), ) - for name, read in plan.obsm.items(): - adata.obsm[name] = read() + for name in plan.obsm: + adata.obsm[name] = read_obsm(plan, name) for name, layer in plan.layers.items(): adata.layers[name] = materialize_h5ad_matrix(layer, f"Layer {name!r}") + for name in plan.obsp: + adata.obsp[name] = read_obsp(plan, name) + for name in plan.varm: + adata.varm[name] = read_varm(plan, name) return adata +def _run_label_categorical( + view: "PipelineAxisView", + column: str, +) -> "H5adCategorical | None": + """Return how a run export writes a frozen label field, or None. + + A field with a categorical display, such as ``clusters``, ``leiden_*``, + ``paris``, or ``cell_cycle_phase``, is a categorical whose categories are + its labels as text in natural order, with the display colors. + """ + from ...writers.export import H5adCategorical + + display = view._field_display(column) + if display is None or display.get("kind") != "categorical": + return None + return H5adCategorical( + colors={ + str(category["value"]): str(category["color"]) + for category in display["categories"] + } + ) + + +def _materialized(matrix: csr_matrix) -> Iterator[csr_matrix]: + yield matrix + + +def _aligned_coordinates(values: Any, cell_ids: np.ndarray) -> np.ndarray: + """Return imported coordinates with one row per selected cell, in order. + + An array must already have those rows. A DataFrame is aligned by its + index of cell IDs; rows of other cells are left out. + """ + if isinstance(values, pd.DataFrame): + index = values.index.astype(str) + if index.has_duplicates: + repeated = index[index.duplicated()].unique().tolist() + raise ValueError( + f"The coordinates repeat cell IDs, such as {repeated[0]!r}" + ) + positions = index.get_indexer(cell_ids) + absent = positions < 0 + if absent.any(): + raise ValueError( + f"The coordinates have no row for {int(absent.sum())} of the " + f"{cell_ids.size} selected cells, such as " + f"{str(cell_ids[absent][0])!r}; their index must hold the cell IDs" + ) + if positions.size < len(values): + logger.info( + f"Left out {len(values) - positions.size} rows of cells outside " + "the cell selection" + ) + array: np.ndarray = values.to_numpy()[positions] + else: + array = np.asarray(values) + if array.ndim != 2: + raise ValueError( + f"Imported coordinates must be two-dimensional; found {array.ndim} " + "dimensions" + ) + if array.shape[0] != cell_ids.size: + raise ValueError( + f"Imported coordinates have {array.shape[0]} rows; the cell selection " + f"holds {cell_ids.size} cells" + ) + return array + + class _PresentationOperationsMixin(_PresentationOperationsBase): def to_anndata( self, @@ -209,16 +301,42 @@ def to_anndata( matrix: Literal["raw", "normed"] = "raw", feature_indexes: Sequence[int] | None = None, feature_names: Sequence[str] | None = None, + cell_selection: ArtifactRef | None = None, + obsm: "Mapping[str, H5adSource] | None" = None, + obs: "Mapping[str, H5adSource] | None" = None, + graph: ArtifactRef | None = None, + dtype: Any | None = None, ) -> Any: """Return an assay as an in-memory AnnData object. - Cell and feature metadata are copied to ``obs`` and ``var``. Without - ``run``, layout coordinates remain ordinary ``obs`` columns and this - method does not populate ``obsm``. With ``run``, consecutive frozen - ``umap_*`` fields are written to ``obsm["X_umap"]`` and removed from - ``obs``. Cluster and QC labels stay in ``obs``. Rows that a nullable - column's linked missing mask flags are missing values in ``obs`` and - ``var``, with or without ``run``. + Cell and feature metadata are copied to ``obs`` and ``var``. A column + that keeps a source categorical's categories, as H5AD and Seurat + imports store them, becomes that categorical, with its category + order, unused categories, ``ordered`` flag, and colors in + ``uns["_colors"]``. Rows that a nullable column's linked + missing mask flags are missing values in ``obs`` and ``var``, with + or without ``run``. + + Without ``run``, the live export holds the cells of ``cell_key`` or + ``cell_selection``, and ``obsm``, ``obs``, and ``graph`` export + artifacts beside them: coordinates and embeddings in ``obsm``, such + as ``{"X_pca": pca, "X_umap": umap}``, where ``X_pca`` also writes the + PCA loadings to ``varm["PCs"]``; cell labels and scores in ``obs``, + labels as categoricals; and a connectivity map in ``obsp`` with + ``uns["neighbors"]`` in Scanpy's layout. With ``run``, consecutive + frozen ``umap_*`` fields are written to ``obsm["X_umap"]`` and + removed from ``obs``, the run's PCA is ``obsm["X_pca"]`` (and its + Harmony correction ``obsm["X_pca_harmony"]``), and label fields + (``clusters``, ``leiden_*``, ``paris``) are categoricals with string + categories in natural order and the run's display colors. + + ``matrix="normed"`` means two different things. Without ``run``, + ``X`` holds the assay's ``normed`` values of the exported features: + for RNA, counts scaled by each cell's library size over all of its + features to 1,000, without a log transform. With ``run``, ``X`` holds + the run's stored ``normalized`` artifact, the values that its PCA + read: log1p of counts scaled over the highly variable features only, + which are then the only features. Args: from_assay: Name of assay to be used. If no value is provided then the default assay will be used. @@ -230,6 +348,21 @@ def to_anndata( matrix: Whether ``X`` contains raw counts or normalized values. feature_indexes: Global feature rows to export, in the requested order. feature_names: Feature names to export, in the requested order. + cell_selection: Cell-selection artifact whose cells are exported, in + store order; it excludes ``cell_key``. The cells of an + artifact's selection are those that its values cover. + obsm: Artifacts to export as ``obsm`` arrays, by name. + obs: Cell-aligned artifacts to export as ``obs`` columns, by name. + A pair ``(ref, name)`` names another per-cell array of the + artifact, such as ``(cell_cycle, "s_score")``. + graph: Connectivity map or integrated graph to export as + ``obsp["connectivities"]`` (and, for a connectivity map, + ``obsp["distances"]``) with ``uns["neighbors"]``. It must be + built over exactly the exported cells. + dtype: Dtype of the exported raw counts, in ``X`` and in + ``layers``. None keeps the stored dtype, except that + ``uint8`` and ``uint16`` counts become ``float32``, on which + arithmetic cannot wrap. Returns: An AnnData object. @@ -237,6 +370,9 @@ def to_anndata( Raises: ImportError: If ``anndata`` is not installed. UnmeasuredCellsError: If normed or other-assay data has an unmeasured cell. + ValueError: If an artifact does not cover every exported cell, or + a graph does not cover exactly the exported cells. + OverflowError: If ``dtype`` cannot hold an exported count. """ try: # noinspection PyPackageRequirements @@ -246,11 +382,18 @@ def to_anndata( "DataStore.to_anndata requires anndata. " "Install it with: pip install 'cytearc[extra]'" ) from exc + from ...writers.export import ( + export_count_dtype, + with_artifacts, + with_count_dtype, + ) if matrix not in ("raw", "normed"): raise ValueError("matrix must be either 'raw' or 'normed'") if feature_indexes is not None and feature_names is not None: raise ValueError("feature_indexes and feature_names are mutually exclusive") + if dtype is not None: + export_count_dtype(None, dtype) if run is not None: from ..pipeline_run import PipelineRun @@ -259,7 +402,11 @@ def to_anndata( raise TypeError("run must be a PipelineRun") if run._owner is not self: raise ValueError("run must be opened from this datastore") - if from_assay is not None or cell_key is not None: + if ( + from_assay is not None + or cell_key is not None + or cell_selection is not None + ): raise ValueError( "Run-aware export uses the frozen run selection and assay" ) @@ -267,13 +414,23 @@ def to_anndata( raise ValueError( "Run-aware export uses the frozen run feature selection" ) + plan = self._h5ad_run_plan(run, matrix=matrix, layers=layers) + if obsm is not None or obs is not None or graph is not None: + plan = with_artifacts( + plan, + self.zw, + cell_rows=np.flatnonzero(run.cells.fetch_all("I")), + obsm=obsm, + obs=obs, + graph=graph, + max_bytes=self.memoryBytes, + ) return _anndata_from_plan( - AnnData, - self._h5ad_run_plan(run, matrix=matrix, layers=layers), + AnnData, with_count_dtype(plan, dtype, raw_x=matrix == "raw") ) - if cell_key is None: - cell_key = "I" + if cell_key is not None and cell_selection is not None: + raise ValueError("cell_key and cell_selection are mutually exclusive") assay = self._get_assay(from_assay) if feature_indexes is not None: @@ -324,10 +481,20 @@ def to_anndata( else: feat_idx = np.arange(assay.feats.N, dtype=np.int64) - cell_idx = self.cells.active_index(cell_key) + cells: str | ArtifactRef + remedy: Literal["cell_key", "cell_selection"] + if cell_selection is not None: + from ...writers.export import cell_selection_rows + + cell_idx = cell_selection_rows(self.zw, cell_selection) + cells, remedy = cell_selection, "cell_selection" + else: + cells = "I" if cell_key is None else cell_key + cell_idx = self.cells.active_index(cells) + remedy = "cell_key" if matrix == "normed": self._require_measured_cells( - assay.name, cell_key, operation="to_anndata", remedy="cell_key" + assay.name, cells, operation="to_anndata", remedy=remedy ) layer_features: dict[str, tuple[Any, np.ndarray]] = {} if layers is not None: @@ -341,25 +508,10 @@ def to_anndata( # Only the exported assay declares its membership. self._require_measured_cells( assay_name, - cell_key, + cells, operation="to_anndata", - remedy="cell_key", + remedy=remedy, ) - declared, omitted = exported_membership(self.cells, assay.name) - obs = ( - self.cells.to_pandas_dataframe( - [column for column in self.cells.columns if column not in omitted], - key=cell_key, - ) - .reset_index(drop=True) - .set_index("ids") - ) - var = ( - assay.feats.to_pandas_dataframe(assay.feats.columns) - .iloc[feat_idx] - .rename(columns={"ids": "gene_ids"}) - .set_index("gene_ids") - ) if matrix == "raw": x = raw_csr(assay, cell_idx, feat_idx) @@ -371,10 +523,104 @@ def to_anndata( if blocks else csr_matrix((len(cell_idx), len(feat_idx))) ) - adata = AnnData(x, obs=obs, var=var, uns=membership_declaration(declared)) - for layer, (layer_assay, layer_feat_idx) in layer_features.items(): - adata.layers[layer] = raw_csr(layer_assay, cell_idx, layer_feat_idx) - return adata + plan = self._live_anndata_plan(assay, x, cell_idx, feat_idx, layer_features) + if obsm is not None or obs is not None or graph is not None: + plan = with_artifacts( + plan, + self.zw, + cell_rows=cell_idx, + obsm=obsm, + obs=obs, + graph=graph, + max_bytes=self.memoryBytes, + ) + return _anndata_from_plan( + AnnData, with_count_dtype(plan, dtype, raw_x=matrix == "raw") + ) + + def _live_anndata_plan( + self, + assay: Any, + x: csr_matrix, + cell_idx: np.ndarray, + feat_idx: np.ndarray, + layer_features: Mapping[str, tuple[Any, np.ndarray]], + ) -> "H5adExportPlan": + """Plan the in-memory export of live cells, features, and matrices. + + ``obs`` is indexed by ``ids`` and ``var`` by ``gene_ids``. Column + names that AnnData reserves or that the index takes are renamed, and + columns that keep a source categorical's categories are categoricals. + """ + from ...writers.export import ( + H5adColumn, + H5adExportPlan, + H5adMatrix, + _live_column, + live_categorical, + unique_column_names, + ) + + declared, omitted = exported_membership(self.cells, assay.name) + + def table( + metadata: Any, + index: str, + keys: list[str], + rows: np.ndarray, + label: str, + ) -> tuple[H5adColumn, tuple[H5adColumn, ...]]: + names = unique_column_names(index, keys, table=label) + return H5adColumn( + index, partial(_live_column, metadata, "ids", rows) + ), tuple( + H5adColumn( + name, + partial(_live_column, metadata, key, rows), + categorical=live_categorical(metadata, key), + ) + for key, name in zip(keys, names, strict=True) + ) + + obs_index, obs_columns = table( + self.cells, + "ids", + [ + column + for column in self.cells.columns + if column != "ids" and column not in omitted + ], + cell_idx, + "obs", + ) + var_index, var_columns = table( + assay.feats, + "gene_ids", + [column for column in assay.feats.columns if column != "ids"], + feat_idx, + "var", + ) + shape = (len(cell_idx), len(feat_idx)) + return H5adExportPlan( + x=H5adMatrix( + shape=shape, dtype=np.dtype(x.dtype), blocks=partial(_materialized, x) + ), + obs_index=obs_index, + obs=obs_columns, + var_index=var_index, + var=var_columns, + layers={ + layer: H5adMatrix( + shape=shape, + dtype=np.dtype(layer_assay.rawData.dtype), + blocks=partial( + _materialized, raw_csr(layer_assay, cell_idx, layer_feat_idx) + ), + ) + for layer, (layer_assay, layer_feat_idx) in layer_features.items() + }, + membership=declared, + ) def _layer_features( self, @@ -433,11 +679,21 @@ def _h5ad_run_plan( run's frozen fields, with masked rows missing, encoded as ``"categorical"`` columns: the plan decides which text fields are categoricals and in what order their categories are, for the file - and for the object alike. Consecutive ``umap_`` fields become - ``obsm["X_umap"]``. ``layers`` maps layer names to assays whose raw + and for the object alike. A field with a categorical display, such + as ``clusters``, ``leiden_*``, or ``paris``, is a categorical of its + labels as text, in natural order, with the display's colors. + Consecutive ``umap_`` fields become ``obsm["X_umap"]``, and the + run's ``pca`` and ``harmony`` outputs ``obsm["X_pca"]`` and + ``obsm["X_pca_harmony"]``, with the PCA loadings in + ``varm["PCs"]``. ``layers`` maps layer names to assays whose raw counts are aligned to the exported features by ID. """ - from ...writers.export import H5adColumn, H5adExportPlan, H5adMatrix + from ...writers.export import ( + H5adColumn, + H5adExportPlan, + H5adMatrix, + with_artifacts, + ) from ..pipeline_run import PipelineRun if matrix not in ("raw", "normed"): @@ -480,6 +736,7 @@ def frozen( column if name is None else name, partial(_frozen_field, view, column, rows), "categorical", + None if view is features else _run_label_categorical(view, column), ) cell_fields = [column for column in cells.columns if column != "ids"] @@ -502,9 +759,11 @@ def frozen( plan_layers[layer] = _raw_count_matrix( layer_assay, cell_idx, layer_feat_idx ) - return H5adExportPlan( + plan = H5adExportPlan( x=x, - obs_index=frozen(cells, "ids"), + obs_index=H5adColumn( + "ids", partial(_frozen_field, cells, "ids", None), "categorical" + ), obs=tuple( frozen(cells, column) for column in cell_fields if column not in umap ), @@ -521,6 +780,20 @@ def frozen( ), layers=plan_layers, ) + coordinates = { + name: run[output] + for output, name in _RUN_OBSM_OUTPUTS.items() + if output in run + } + if not coordinates: + return plan + return with_artifacts( + plan, + self.zw, + cell_rows=cell_idx, + obsm=coordinates, + max_bytes=self.memoryBytes, + ) def _run_normalized_values( self, @@ -580,6 +853,103 @@ def _run_normalized_values( ) return values, np.asarray(selections.featureMask, dtype=bool) + @validation_scoped + def _reduction_import_coordinates( + self, + values: Any, + *, + cell_selection: ArtifactRef, + name: str, + from_assay: str | None = None, + source: Mapping[str, Any] | None = None, + invalidate_cache: bool = False, + ) -> ArtifactRef: + """Import an external latent space as graph coordinates. + + The coordinates, such as an scVI latent space (``X_scVI``) or a + Harmony correction computed elsewhere (``X_pca_harmony``), become an + ``imported_coordinates`` artifact that ``graph.ann_index``, + ``embeddings.initialization``, and mapping accept like a PCA. Its + identity records ``name``, the ``source`` record, a fingerprint of + the values, which also serves as their source digest, and a + fingerprint of the ordered cell IDs, so importing the same values + again reuses the artifact, and lineage reports show where they came + from. + + Args: + values: A two-dimensional array with one row per cell of + ``cell_selection``, in store order, or a pandas DataFrame + indexed by cell ID, whose rows are aligned to the selected + cells by ID; rows of other cells are left out. Integer and + boolean values are stored as float64. + cell_selection: Cell-selection artifact of the cells that the + coordinates describe. + name: Name of the coordinates, recorded as their source key, such + as ``"X_scVI"``. + from_assay: Assay that owns the artifact; the default assay when + None. + source: Record of the tool that computed the coordinates, such as + ``{"tool": "scvi-tools", "version": "1.2.0", "parameters": + {"n_latent": 30}}``, holding text, numbers, booleans, None, + and lists or mappings of them. + invalidate_cache: Write a new artifact even when one matches. + + Returns: + Reference to the ``imported_coordinates`` artifact. + + Raises: + TypeError: If an argument has the wrong type, or the values are + not numbers. + ValueError: If the values do not have one row per selected cell, + a DataFrame lacks a selected cell or repeats a cell ID, or a + value is not finite. + PermissionError: If no matching result exists and the store is + not opened with ``zarr_mode='r+'``. + """ + from ...embeddings.imported import ( + imported_source_record, + write_imported_coordinates, + ) + from ...metadata.rows import read_metadata_rows_chunkwise + from ...storage.artifacts import fingerprint_array + from ...writers._store import bounded_block_rows, floating_payload_dtype + from ...writers.export import cell_selection_rows + + if not isinstance(name, str) or not name: + raise ValueError("name must be a non-empty string") + if source is not None: + imported_source_record(source) + assay = self._get_assay(from_assay) + rows = cell_selection_rows(self.zw, cell_selection) + cell_ids = np.asarray(read_metadata_rows_chunkwise(self.cells, "ids", rows)) + coordinates = _aligned_coordinates(values, cell_ids.astype(str)) + dtype = floating_payload_dtype(coordinates.dtype, "Imported coordinates") + coordinates = np.ascontiguousarray(coordinates, dtype=dtype) + if coordinates.shape[1] < 1: + raise ValueError("Imported coordinates need at least one dimension") + if not bool(np.isfinite(coordinates).all()): + raise ValueError("Imported coordinates must hold only finite values") + fingerprint = fingerprint_array(coordinates) + return write_imported_coordinates( + self.zw, + assay=assay.name, + dimreduc_key=name, + role="coordinates", + coordinates=coordinates, + source_digest=bytes.fromhex(fingerprint), + payload_fingerprints={"data": fingerprint}, + source_cell_ids=cell_ids, + cell_selection=cell_selection, + block_rows=bounded_block_rows( + None, + row_bytes=coordinates.shape[1] * dtype.itemsize, + memory_bytes=self.memoryBytes, + ), + invalidate_cache=invalidate_cache, + source=source, + require_writable=lambda: self._require_writable("import_coordinates"), + ) + def show_zarr_tree(self, start: str = "/", depth: int = 2) -> None: """Prints the Zarr hierarchy of the DataStore. diff --git a/cytearc/datastore/namespaces.py b/cytearc/datastore/namespaces.py index daf7426..c10afb3 100644 --- a/cytearc/datastore/namespaces.py +++ b/cytearc/datastore/namespaces.py @@ -180,6 +180,9 @@ class ReductionAccessor(_Accessor): lsi = _bind(_GraphOperationsMixin._reduction_lsi) custom = _bind(_GraphOperationsMixin._reduction_custom) harmony = _bind(_GraphOperationsMixin._reduction_harmony) + import_coordinates = _bind( + _PresentationOperationsMixin._reduction_import_coordinates + ) class RegulonsAccessor(_Accessor): diff --git a/cytearc/embeddings/imported.py b/cytearc/embeddings/imported.py index 1fa17cc..57b2f97 100644 --- a/cytearc/embeddings/imported.py +++ b/cytearc/embeddings/imported.py @@ -1,3 +1,4 @@ +import math from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass from typing import Any, cast @@ -121,6 +122,47 @@ def _validate_source_digest(value: bytes) -> bytes: return value +def _source_value(value: Any, path: str) -> Any: + if value is None or isinstance(value, bool | str): + return value + if isinstance(value, int | float): + if isinstance(value, float) and not math.isfinite(value): + raise ValueError(f"{path} must be a finite number") + return value + if isinstance(value, Mapping): + if any(not isinstance(key, str) for key in value): + raise TypeError(f"{path} must have text keys") + return { + key: _source_value(item, f"{path}[{key!r}]") for key, item in value.items() + } + if isinstance(value, list | tuple): + return [ + _source_value(item, f"{path}[{index}]") for index, item in enumerate(value) + ] + raise TypeError( + f"{path} holds a {type(value).__name__}; source records hold text, " + "numbers, booleans, None, and lists or mappings of them" + ) + + +def imported_source_record(source: Mapping[str, Any]) -> dict[str, Any]: + """Return a validated record of the tool that computed imported coordinates. + + The record, such as ``{"tool": "scvi-tools", "version": "1.2.0", + "parameters": {"n_latent": 30}}``, is stored in the artifact's + provenance, so it is part of its identity and of its lineage. + + Raises: + TypeError: If ``source`` is not a mapping with text keys, or holds a + value that is not text, a number, a boolean, None, or a list or + mapping of them. + ValueError: If it holds a non-finite number. + """ + if not isinstance(source, Mapping): + raise TypeError("source must be a mapping") + return cast(dict[str, Any], _source_value(source, "source")) + + def _string_source_length(values: ImportedStringSource) -> int: shape = getattr(values, "shape", None) if shape is not None: @@ -435,13 +477,26 @@ def write_imported_coordinates( stdev_dtype: Any | None = None, block_rows: int = 100_000, invalidate_cache: bool = False, + source: Mapping[str, Any] | None = None, + require_writable: Callable[[], None] | None = None, ) -> ArtifactRef: + """Store, or reuse, imported graph coordinates over an exact cell selection. + + The artifact's identity holds the source key, role, dimensions, which + optional payloads are stored, the source digest, the fingerprint of each + payload, the fingerprint of the ordered source cell IDs, and the cell + selection. ``source``, a record of the tool that computed the + coordinates (:func:`imported_source_record`), is stored in the + parameters when given. ``require_writable`` is called before anything is + written, when no stored artifact matches. + """ storage = ImportedArtifactStorage(root) source_digest = _validate_source_digest(source_digest) if not isinstance(dimreduc_key, str) or not dimreduc_key: raise ValueError("dimreduc_key must be a non-empty string") if not isinstance(role, str) or not role or role.lower() in {"umap", "tsne"}: raise ValueError("Imported graph coordinates require a non-layout role") + source_record = None if source is None else imported_source_record(source) block_rows = integer_argument(block_rows, "block_rows", minimum=1) data_source = _resolve_source( coordinates, @@ -529,17 +584,20 @@ def write_imported_coordinates( ) for name, shape in shapes.items() ) + parameters: dict[str, Any] = { + "dimreduc_key": dimreduc_key, + "role": role.lower(), + "dims": dims, + "loadings_stored": loading_source is not None, + "feature_ids_stored": feature_source is not None, + "stdev_stored": stdev_source is not None, + } + if source_record is not None: + parameters["source"] = source_record planned = storage.plan( assay=assay, kind="imported_coordinates", - parameters={ - "dimreduc_key": dimreduc_key, - "role": role.lower(), - "dims": dims, - "loadings_stored": loading_source is not None, - "feature_ids_stored": feature_source is not None, - "stdev_stored": stdev_source is not None, - }, + parameters=parameters, inputs={ "source_digest": source_digest, "payload_fingerprints": fingerprints, @@ -557,6 +615,8 @@ def write_imported_coordinates( ), ) if not planned.reused: + if require_writable is not None: + require_writable() with storage.transaction(planned) as group: _write_numeric_payload( storage, diff --git a/cytearc/readers/_h5ad_columns.py b/cytearc/readers/_h5ad_columns.py index 1b3adb0..f85f2c0 100644 --- a/cytearc/readers/_h5ad_columns.py +++ b/cytearc/readers/_h5ad_columns.py @@ -300,6 +300,122 @@ def read_table_column( return values, missing +@dataclass(frozen=True, slots=True) +class H5adCategories: + """The declared categories of an AnnData categorical column. + + Attributes: + values: Every category in its stored order, unused ones included, + decoded as the column's values decode: byte strings as text and + NumPy scalars as Python scalars. + ordered: Whether the categories are ordered. + """ + + values: tuple[Any, ...] + ordered: bool + + +def _category_value(value: Any) -> Any: + if isinstance(value, bytes | np.bytes_): + return as_text(value) + return value.item() if isinstance(value, np.generic) else value + + +def column_categories( + h5: h5py.File, + table: H5adNode, + key: str, + category_groups: tuple[str, ...], +) -> H5adCategories | None: + """Return the categories of a categorical column, or None for another column. + + A group-encoded categorical keeps its categories beside its codes and + records ``ordered`` as an attribute. AnnData 0.7 and 0.6 kept the + categories of integer codes apart from them, as + :func:`legacy_categories` finds them, and such categories are unordered + unless their dataset records ``ordered``. + """ + if isinstance(table, h5py.Group): + node = table[key] + if is_categorical(node): + return H5adCategories( + tuple(_category_value(value) for value in node["categories"][:]), + bool(node.attrs.get("ordered", False)), + ) + if not isinstance(node, h5py.Dataset): + return None + stored = node.dtype + else: + stored = np.dtype(table.dtype.fields[key][0]) + if stored.kind not in "iu": + return None + categories = legacy_categories(h5, table, key, category_groups) + if categories is None: + return None + ordered = False + if isinstance(table, h5py.Group): + for name in category_groups: + group = table.get(name) + if isinstance(group, h5py.Group) and key in group: + ordered = bool(group[key].attrs.get("ordered", False)) + break + return H5adCategories( + tuple(_category_value(value) for value in categories), ordered + ) + + +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") + + +def hex_color(value: str) -> str | None: + """Return a color as ``#rrggbb``, or None when it is not a color. + + ``#rgb``, ``#rrggbb``, and ``#rrggbbaa`` are read directly, dropping any + alpha. Other names, such as ``red`` or ``tab:blue``, are resolved with + Matplotlib when it is installed. + """ + text = value.strip() + if text.startswith("#") and set(text[1:]) <= _HEX_DIGITS: + digits = text[1:] + if len(digits) in {3, 4}: + digits = "".join(digit * 2 for digit in digits[:3]) + if len(digits) in {6, 8}: + return f"#{digits[:6].lower()}" + return None + try: + from matplotlib.colors import to_hex + except ImportError: + return None + try: + return str(to_hex(text, keep_alpha=False)) + except ValueError: + return None + + +def category_colors(h5: h5py.File, key: str, count: int) -> tuple[str, ...] | None: + """Return the ``uns["_colors"]`` of a categorical with ``count`` categories. + + AnnData and Scanpy keep one color per category, in category order. Like + Scanpy, a list with more colors than categories gives each category the + color at its position. A list with fewer colors, or with a value that is + not a color, is not used, and None is returned. + """ + node = h5.get(f"uns/{key}_colors") + if not isinstance(node, h5py.Dataset) or node.ndim != 1 or count < 1: + return None + if int(node.shape[0]) < count: + return None + colors = [] + for value in node[:count]: + if not isinstance(value, str | bytes | np.str_ | np.bytes_): + return None + color = hex_color(as_text(value)) + if color is None: + return None + colors.append(color) + return tuple(colors) + + def table_column_dtype( h5: h5py.File, table: H5adNode, diff --git a/cytearc/readers/_h5ad_inspect.py b/cytearc/readers/_h5ad_inspect.py index 9911e41..df2f5f3 100644 --- a/cytearc/readers/_h5ad_inspect.py +++ b/cytearc/readers/_h5ad_inspect.py @@ -105,12 +105,65 @@ class _MatrixCandidate: encoding: str shape: tuple[int, int] integerLike: bool + # Total of the matrix, estimated from the sampled values. + estimatedTotal: float = 0.0 @property def isSparse(self) -> bool: return self.encoding in {"csr", "csc"} +# The sample that decides whether a matrix holds counts: up to this many +# windows, spread evenly over its stored values, of up to this many values +# each. Reading a window decompresses at most the chunks that it overlaps, so +# inspection reads a bounded part of any file. +_SAMPLE_WINDOWS = 32 +_SAMPLE_WINDOW_VALUES = 1024 +# Matrices whose key names counts rank by these tiers, lowest first: raw +# counts, the canonical matrix, spliced counts, other matrices, and the +# unspliced and ambiguous counts of velocity data. +_COUNT_LAYER_NAMES = frozenset({"counts", "raw_counts"}) +_SECONDARY_LAYER_NAMES = frozenset({"unspliced", "ambiguous"}) + + +def _name_tier(key: str) -> int: + """Return how conventional a name for a count matrix ``key`` is.""" + if key == "raw/X": + return 0 + layer = key.removeprefix("layers/").lower() if key.startswith("layers/") else None + if layer in _COUNT_LAYER_NAMES: + return 0 + if key == "X": + return 1 + if layer == "spliced": + return 2 + if layer in _SECONDARY_LAYER_NAMES: + return 4 + return 3 + + +def _sample_windows(size: int, width: int) -> list[slice]: + """Return up to ``_SAMPLE_WINDOWS`` windows spread evenly over ``size`` items.""" + if size <= 0: + return [] + width = max(1, min(width, size)) + starts = np.linspace(0, size - width, num=min(_SAMPLE_WINDOWS, size)).astype(int) + return [slice(start, start + width) for start in np.unique(starts).tolist()] + + +def _sampled_counts(sample: np.ndarray) -> bool: + """Return whether sampled values are finite, non-negative, and whole.""" + if sample.size == 0 or not np.issubdtype(sample.dtype, np.number): + return False + if np.issubdtype(sample.dtype, np.integer): + return bool(np.all(sample >= 0)) + return bool( + np.all(np.isfinite(sample)) + and np.all(sample >= 0) + and np.allclose(sample, np.round(sample), rtol=0, atol=1e-8) + ) + + def _node_length(node: h5py.Group | h5py.Dataset | None) -> int | None: if node is None: return None @@ -129,30 +182,33 @@ def _node_length(node: h5py.Group | h5py.Dataset | None) -> int | None: return None -def _is_integer_like(dataset: h5py.Dataset) -> bool: - if np.issubdtype(dataset.dtype, np.integer): - return True - sample = np.asarray(dataset[: min(101, dataset.shape[0])]) - if sample.size == 0 or not np.issubdtype(sample.dtype, np.number): - return False - return bool( - np.all(np.isfinite(sample)) - and np.allclose(sample, np.round(sample), rtol=0, atol=1e-8) - ) - +def _sparse_counts(dataset: h5py.Dataset) -> tuple[bool, float]: + """Sample the stored values of a sparse matrix. -def _dense_is_integer_like(dataset: h5py.Dataset) -> bool: - if np.issubdtype(dataset.dtype, np.integer): - return True - rows = min(10, dataset.shape[0]) - columns = min(100, dataset.shape[1]) - sample = np.asarray(dataset[:rows, :columns]) - if sample.size == 0 or not np.issubdtype(sample.dtype, np.number): - return False - return bool( - np.all(np.isfinite(sample)) - and np.allclose(sample, np.round(sample), rtol=0, atol=1e-8) - ) + Returns whether every sampled value is a count, and the matrix total + that the sampled mean estimates. + """ + size = int(dataset.shape[0]) + windows = _sample_windows(size, _SAMPLE_WINDOW_VALUES) + if not windows: + return False, 0.0 + sample = np.concatenate([np.asarray(dataset[window]) for window in windows]) + if not _sampled_counts(sample): + return False, 0.0 + return True, float(np.mean(sample, dtype=np.float64)) * size + + +def _dense_counts(dataset: h5py.Dataset) -> tuple[bool, float]: + """Sample whole rows of a dense matrix, as :func:`_sparse_counts` samples.""" + n_rows, n_columns = (int(extent) for extent in dataset.shape) + rows = max(1, _SAMPLE_WINDOW_VALUES // max(1, n_columns)) + windows = _sample_windows(n_rows, rows) + if not windows or n_columns == 0: + return False, 0.0 + sample = np.concatenate([np.asarray(dataset[window]) for window in windows]) + if not _sampled_counts(sample): + return False, 0.0 + return True, float(np.mean(sample, dtype=np.float64)) * n_rows * n_columns def _is_matrix_path(key: str) -> bool: @@ -178,40 +234,80 @@ def visit(key: str, node: h5py.Group | h5py.Dataset) -> None: f"Ignoring sparse matrix candidate without a shape attribute: {key}" ) return + integer_like, total = _sparse_counts(node["data"]) candidates.append( _MatrixCandidate( key=key, encoding=encoding, shape=shape, - integerLike=_is_integer_like(node["data"]), + integerLike=integer_like, + estimatedTotal=total, ) ) - elif isinstance(node, h5py.Dataset) and len(node.shape) == 2: + elif ( + isinstance(node, h5py.Dataset) + # A null dataspace, as anndata before 0.13 writes ``X=None``, has + # no shape. + and node.shape is not None + and len(node.shape) == 2 + ): if not np.issubdtype(node.dtype, np.number): return + integer_like, total = _dense_counts(node) candidates.append( _MatrixCandidate( key=key, encoding="dense", shape=(int(node.shape[0]), int(node.shape[1])), - integerLike=_dense_is_integer_like(node), + integerLike=integer_like, + estimatedTotal=total, ) ) h5.visititems(visit) - def rank(candidate: _MatrixCandidate) -> tuple[int, int, int, int, str]: - # Integer-like values signal raw counts and take priority over storage - # layout, so a dense count matrix outranks a sparse transformed layer. + def rank(candidate: _MatrixCandidate) -> tuple[int, int, float, int, str]: + # Counts take priority over storage layout, so a dense count matrix + # outranks a sparse transformed layer. Among counts, a conventional + # name, such as layers/counts or raw/X, outranks X, which outranks + # spliced, other, and then unspliced or ambiguous counts; matrices of + # one tier rank by their estimated total, so the matrix that holds + # the most reads wins, and then by layout and key. integer = 0 if candidate.integerLike else 1 - raw = 0 if candidate.key.startswith("raw/") else 1 - canonical = 0 if candidate.key == "X" else 1 + total = -candidate.estimatedTotal if candidate.integerLike else 0.0 sparse = 0 if candidate.isSparse else 1 - return integer, raw, canonical, sparse, candidate.key + return integer, _name_tier(candidate.key), total, sparse, candidate.key return sorted(candidates, key=rank) +def _warn_competing_counts( + chosen: _MatrixCandidate, + candidates: list[_MatrixCandidate], + obs_length: int | None, +) -> None: + """Warn when other matrices of the same cells also hold counts.""" + if not chosen.integerLike: + return + others = [ + candidate + for candidate in candidates + if candidate is not chosen + and candidate.integerLike + and (obs_length is None or candidate.shape[0] == obs_length) + ] + if not others: + return + described = ", ".join( + f"{candidate.key} (about {candidate.estimatedTotal:.3g} counts)" + for candidate in (chosen, *others) + ) + logger.warning( + f"Several matrices hold integer counts: {described}. inspect_h5ad chose " + f"{chosen.key}; review the choice and pass matrix_key to choose another" + ) + + def _read_column( node: h5py.Group | h5py.Dataset, key: str, @@ -267,6 +363,17 @@ def _table_index(node: h5py.Group | h5py.Dataset, names: list[str]) -> str | Non return None +def _is_identifier_column(values: np.ndarray | None, n_cells: int) -> bool: + """Return whether a column holds unique text or integers, one per cell. + + Floating-point values, such as scores, are never identifiers, even when + every value differs. + """ + if values is None or not _is_unique(values, n_cells): + return False + return values.dtype.kind in "iu" or _is_string_column(values) + + def _find_cell_ids( node: h5py.Group | h5py.Dataset | None, n_cells: int, @@ -274,31 +381,42 @@ def _find_cell_ids( if node is None: return "_index" names = table_column_names(node) - index_key = _index_key(node) - if index_key is not None and index_key in names: - values = _read_column(node, index_key) - if values is not None and _is_unique(values, n_cells): - return index_key - preferred = _matching_key(names, _CELL_ID_KEYS) - if preferred is not None: - values = _read_column(node, preferred) - if values is not None and _is_unique(values, n_cells): - return preferred - - candidates: list[tuple[str, bool]] = [] - for name in names: + index = _table_index(node, names) + if index is not None and _is_identifier_column(_read_column(node, index), n_cells): + return index + normalized = {name.lower(): name for name in names} + for preferred in _CELL_ID_KEYS: + name = normalized.get(preferred) + if name is not None and _is_identifier_column( + _read_column(node, name), n_cells + ): + return name + + # Other columns hold IDs only as text: a column of distinct numbers, such + # as a count per cell, is not one. + for name in sorted(names): values = _read_column(node, name) - if values is None or not _is_unique(values, n_cells): - continue - candidates.append((name, _is_string_column(values))) - if candidates: - candidates.sort(key=lambda item: (not item[1], item[0])) - return candidates[0][0] + if _is_unique_text(values, n_cells): + return name - logger.warning("No unique cell ID column found; generated IDs will be used") + if index is not None: + logger.warning( + f"The cell IDs in the obs index {index!r} repeat values, and no " + "other column holds unique text IDs. Pass " + "duplicate_cell_ids='make_unique' to H5adReader, also through " + "H5adReader.from_inspect, to rename repeated IDs" + ) + return index + logger.warning("No cell ID column found; generated IDs will be used") return "_index" +def _is_unique_text(values: np.ndarray | None, n_cells: int) -> bool: + return ( + values is not None and _is_string_column(values) and _is_unique(values, n_cells) + ) + + def _mean_text_length(values: np.ndarray) -> float: sample = [ _as_text(value) @@ -499,6 +617,18 @@ def inspect_h5ad( ) -> H5adInspectResult: """Report the matrix and metadata layout of an H5AD file. + Candidate matrices are ranked so that the counts come first. A matrix + holds counts when a sample of its stored values, up to 32 windows of 1,024 + values spread over the whole matrix, is finite, non-negative, and whole. + Among counts, a conventional name wins: ``raw/X``, ``layers/counts``, and + ``layers/raw_counts`` first, then ``X``, ``layers/spliced``, other + matrices, and the ``unspliced`` and ``ambiguous`` layers of velocity data + last. Matrices of one tier rank by their total, estimated from the + sample, so the one that holds the most reads wins. When other matrices of + the cells also hold counts, a warning lists them with their estimated + totals; review ``matrixKey`` and ``matrixCandidates`` and pass + ``matrix_key`` to choose another. + Args: h5ad_fn: Path to the H5AD file. matrix_key: Optional matrix path to force (for example ``X`` or @@ -526,6 +656,7 @@ def inspect_h5ad( matrix, feature_attrs_key = _select_matrix(h5, forced) else: matrix, feature_attrs_key = _select_matrix(h5, candidates) + _warn_competing_counts(matrix, candidates, _node_length(h5.get("obs"))) n_cells, n_features = matrix.shape cell_node = h5.get("obs") feature_node = h5.get(feature_attrs_key) diff --git a/cytearc/readers/_text.py b/cytearc/readers/_text.py index e807072..c5b4e27 100644 --- a/cytearc/readers/_text.py +++ b/cytearc/readers/_text.py @@ -6,6 +6,7 @@ from ..utils.logging import logger type DuplicateFeatureIds = Literal["error", "make_unique"] +type DuplicateCellIds = Literal["error", "make_unique"] _DUPLICATE_FEATURE_ID_OPTIONS = ("error", "make_unique") @@ -52,11 +53,22 @@ def duplicate_feature_ids_option(value: Any) -> DuplicateFeatureIds: Raises: ValueError: If ``value`` is neither ``"error"`` nor ``"make_unique"``. """ + return _duplicate_ids_option(value, "duplicate_feature_ids") + + +def duplicate_cell_ids_option(value: Any) -> DuplicateCellIds: + """Return a validated ``duplicate_cell_ids`` reader argument. + + Raises: + ValueError: If ``value`` is neither ``"error"`` nor ``"make_unique"``. + """ + return _duplicate_ids_option(value, "duplicate_cell_ids") + + +def _duplicate_ids_option(value: Any, option: str) -> Literal["error", "make_unique"]: if isinstance(value, str) and value in _DUPLICATE_FEATURE_ID_OPTIONS: return "error" if value == "error" else "make_unique" - raise ValueError( - f"duplicate_feature_ids must be 'error' or 'make_unique', not {value!r}" - ) + raise ValueError(f"{option} must be 'error' or 'make_unique', not {value!r}") def _later_occurrences(texts: np.ndarray) -> np.ndarray: @@ -101,13 +113,52 @@ def unique_feature_identifiers( ValueError: If a value is missing or blank, or, with ``duplicates="error"``, if a value repeats. """ + return _unique_identifiers( + values, + label, + duplicates, + option="duplicate_feature_ids", + note=" Feature names are unchanged.", + ) + + +def unique_cell_identifiers( + values: np.ndarray, + label: str, + duplicates: DuplicateCellIds, +) -> np.ndarray: + """Return cell identifiers that are present and unique. + + Repeated identifiers are renamed as :func:`unique_feature_identifiers` + renames them, with ``-1``, ``-2``, and so on, as AnnData's + ``obs_names_make_unique`` names them, when ``duplicates`` is + ``"make_unique"``. + + Raises: + ValueError: If a value is missing or blank, or, with + ``duplicates="error"``, if a value repeats. The message names the + ``duplicate_cell_ids`` option. + """ + return _unique_identifiers( + values, label, duplicates, option="duplicate_cell_ids", note="" + ) + + +def _unique_identifiers( + values: np.ndarray, + label: str, + duplicates: Literal["error", "make_unique"], + *, + option: str, + note: str, +) -> np.ndarray: texts = _identifier_texts(values, label) if not has_duplicates(texts): return values if duplicates == "error": raise ValueError( f"{label} must contain unique values; pass " - "duplicate_feature_ids='make_unique' to rename repeated IDs" + f"{option}='make_unique' to rename repeated IDs" ) resolved = texts.astype(object) taken = set(texts.tolist()) @@ -130,7 +181,6 @@ def unique_feature_identifiers( ) logger.warning( f"{label} repeat {len(suffixes)} values: {later.size} later occurrences " - f"were renamed with a numbered suffix, for example {examples}. Feature " - "names are unchanged." + f"were renamed with a numbered suffix, for example {examples}.{note}" ) return resolved diff --git a/cytearc/readers/csv.py b/cytearc/readers/csv.py index 2143985..fff89cc 100644 --- a/cytearc/readers/csv.py +++ b/cytearc/readers/csv.py @@ -80,7 +80,10 @@ class CSVReader: Args: csv_fn: Path to the CSV file has_header: Does the CSV file has a header. (Default value: True) - id_column: The column number which contains row name. (Default value: None) + id_column: The column number which contains row name. Its values are + read as text, as the file holds them, so ``001`` stays + ``"001"``; a missing or empty ID raises ValueError. + (Default value: None) rows_are_cells: If True then each row represents a cell and hence each column is a feature. If False then each row is feature and each column in a cell sep: The column separator in the CSV file, one character. (Default value: ',') @@ -149,6 +152,12 @@ def __init__( self.pandas_kwargs["skiprows"] = skip_rows self.pandas_kwargs["chunksize"] = batch_size self.pandas_kwargs["index_col"] = id_column + if id_column is not None: + # Cell IDs are text as the file holds them: type inference would + # read "001" as the number 1. + converters = dict(self.pandas_kwargs.get("converters") or {}) + converters[id_column] = str + self.pandas_kwargs["converters"] = converters if skip_cols is None: self.skipCols = [] @@ -303,7 +312,12 @@ def _consistency_check( raise ValueError("CSV file contains no data rows") cell_ids: np.ndarray | None = None if collected_cell_ids is not None: - cell_ids = np.asarray(collected_cell_ids) + # An ID that pandas reads as missing, such as an empty field, is + # rejected as blank. + cell_ids = np.asarray( + ["" if pd.isna(value) else value for value in collected_cell_ids], + dtype=str, + ) require_unique_identifiers(cell_ids, "CSV cell IDs") if feature_ids is not None and keep_cols is not None: feature_ids = feature_ids[keep_cols] diff --git a/cytearc/readers/h5ad.py b/cytearc/readers/h5ad.py index 4c67aa5..3a21e3f 100644 --- a/cytearc/readers/h5ad.py +++ b/cytearc/readers/h5ad.py @@ -18,6 +18,9 @@ from ._assay_names import auto_name_feat_table, make_feat_table_from_types from ._h5ad_columns import ( SPARSE_KEYS, + H5adCategories, + category_colors, + column_categories, column_encoding, column_length, column_order, @@ -35,14 +38,36 @@ from ._h5ad_inspect import H5adInspectResult, inspect_h5ad as inspect_h5ad from ._sparse import SparseRowStore from ._text import ( + DuplicateCellIds, DuplicateFeatureIds, as_text, + duplicate_cell_ids_option, duplicate_feature_ids_option, - require_unique_identifiers, + unique_cell_identifiers, unique_feature_identifiers, ) -type H5adEmbeddingRole = Literal["umap", "tsne"] +type H5adEmbeddingRole = Literal["umap", "tsne", "coordinates"] + +_EMBEDDING_ROLES = frozenset({"umap", "tsne", "coordinates"}) +# AnnData stores an unnamed dataframe index under this name, which no +# dataframe column may take. +_UNNAMED_INDEX = "_index" +# The name under which a table's unnamed index imports when it is neither the +# ID nor the name column. +_SOURCE_INDEX = "source_index" + + +def _texts(values: np.ndarray) -> list[str | None]: + return [None if value is None else as_text(value) for value in values] + + +def _is_missing_name(value: Any) -> bool: + if value is None: + return True + if isinstance(value, float | np.floating): + return not bool(np.isfinite(value)) + return not as_text(value).strip() @dataclass(frozen=True) @@ -72,8 +97,23 @@ class H5adReader: and across assays, by appending ``-1``, ``-2``, and so on (skipping names that are already IDs), as AnnData's ``var_names_make_unique`` does, and logs a warning. Feature names are never changed: without a - feature name column they are the IDs as the file holds them. Repeated - cell IDs always raise. + feature name column they are the IDs as the file holds them. + duplicate_cell_ids: ``"error"`` rejects repeated cell IDs. ``"make_unique"`` renames each + later occurrence of a repeated cell ID as ``duplicate_feature_ids`` + renames feature IDs, as AnnData's ``obs_names_make_unique`` does, + which suits files that ``anndata.concat`` wrote without + ``index_unique``. + + A table's dataframe index that is neither the ID nor the name column + imports as a metadata column only when it holds other values: an index + that repeats the IDs or the names is left out, and AnnData's unnamed + index, which no dataframe column may be named, imports as + ``source_index`` (``source_index_2`` and so on when that name is taken). + Categorical columns keep their category order, unused categories, and + ``ordered`` flag, which ``H5adToZarr`` stores with the column; ``obs`` + categories also keep the colors that ``uns["_colors"]`` holds. + A missing or blank feature name takes the feature's ID as the file holds + it. Attributes: h5: A File object from the h5py package. @@ -91,11 +131,16 @@ class H5adReader: sourceMatrixDtype: dtype in which the source holds the matrix values, in native byte order. float16 is not a count storage dtype, so float16 source values are read as float32. - embedding_roles: Exact ``obsm`` keys to import as immutable UMAP or - t-SNE artifacts. + embedding_roles: Exact ``obsm`` keys to import as immutable artifacts: + ``"umap"`` and ``"tsne"`` layouts become ``embedding`` + artifacts, and ``"coordinates"``, such as ``X_pca``, + ``X_scVI``, or ``X_pca_harmony``, become + ``imported_coordinates`` artifacts that graph + construction, embeddings, and mapping accept. cluster_keys: Exact ``obs`` keys to import as immutable cluster-label artifacts instead of raw cell metadata. duplicateFeatureIds: How repeated feature IDs are treated. + duplicateCellIds: How repeated cell IDs are treated. """ def __init__( @@ -114,13 +159,17 @@ def __init__( *, temp_dir: str | Path | None = None, duplicate_feature_ids: DuplicateFeatureIds = "error", + duplicate_cell_ids: DuplicateCellIds = "error", ) -> None: self.h5adFn = h5ad_fn self._tempDir = temp_dir self.duplicateFeatureIds = duplicate_feature_ids_option(duplicate_feature_ids) - # The feature IDs as the file holds them and as an import stores - # them, read on first use. + self.duplicateCellIds = duplicate_cell_ids_option(duplicate_cell_ids) + # The feature and cell IDs as the file holds them and as an import + # stores them, read on first use. self._featureIdentifiers: tuple[np.ndarray, np.ndarray] | None = None + self._cellIdentifiers: tuple[np.ndarray, np.ndarray] | None = None + self._featureNames: np.ndarray | None = None self.h5: h5py.File = h5py.File(h5ad_fn, mode="r") try: self.matrixKey = matrix_key @@ -180,6 +229,7 @@ def _clone_kwargs(self) -> dict[str, Any]: "cluster_keys": self.clusterKeys, "temp_dir": self._tempDir, "duplicate_feature_ids": self.duplicateFeatureIds, + "duplicate_cell_ids": self.duplicateCellIds, } def close(self) -> None: @@ -200,8 +250,8 @@ def from_inspect( Args: inspection: Result of :func:`~cytearc.inspect_h5ad`. **overrides: Constructor arguments that replace or add to the - inspected ones, such as ``embedding_roles``, ``temp_dir``, or - ``duplicate_feature_ids``. + inspected ones, such as ``embedding_roles``, ``temp_dir``, + ``duplicate_feature_ids``, or ``duplicate_cell_ids``. """ reader_kwargs = inspection.to_reader_kwargs() reader_kwargs.update(overrides) @@ -378,8 +428,10 @@ def _validate_embedding_roles( for raw_key, raw_role in roles.items(): if not isinstance(raw_key, str) or not raw_key: raise ValueError("embedding_roles keys must be non-empty strings") - if raw_role not in {"umap", "tsne"}: - raise ValueError("embedding_roles values must be 'umap' or 'tsne'") + if raw_role not in _EMBEDDING_ROLES: + raise ValueError( + "embedding_roles values must be 'umap', 'tsne', or 'coordinates'" + ) if not self._check_exists(self.obsmAttrsKey, raw_key): raise KeyError( f"Embedding key {raw_key!r} was not found in {self.obsmAttrsKey}" @@ -486,11 +538,27 @@ def _identifiers(self, group: str, key: str, generated: str) -> np.ndarray: values = np.array([f"{generated}_{x}" for x in range(n_rows)]) return values + def _cell_identifiers(self) -> tuple[np.ndarray, np.ndarray]: + """Return the cell IDs as the file holds them and as they import. + + They differ only when ``duplicate_cell_ids="make_unique"`` renamed + repeated IDs, which happens once, on first use. + """ + if self._cellIdentifiers is None: + source = self._identifiers(self.cellAttrsKey, self.cellIdsKey, "cell") + resolved = unique_cell_identifiers( + source, "H5AD cell IDs", self.duplicateCellIds + ) + self._cellIdentifiers = (source, resolved) + return self._cellIdentifiers + def cell_ids(self) -> np.ndarray: - """Returns a list of cell IDs.""" - values = self._identifiers(self.cellAttrsKey, self.cellIdsKey, "cell") - require_unique_identifiers(values, "H5AD cell IDs") - return values + """Returns a list of cell IDs. + + Repeated IDs raise ValueError, or are renamed with + ``duplicate_cell_ids="make_unique"``. + """ + return self._cell_identifiers()[1].copy() def _feature_identifiers(self) -> tuple[np.ndarray, np.ndarray]: """Return the feature IDs as the file holds them and as they import. @@ -520,26 +588,84 @@ def feat_names(self) -> np.ndarray: """Returns a list of feature names. Without the feature name key, the names are the feature IDs as the - file holds them, before any repeated ID is renamed. + file holds them, before any repeated ID is renamed. A missing or + blank name, such as a missing category, takes the ID of its feature + as the file holds it. """ - if self._check_exists(self.featureAttrsKey, self.featNamesKey): - return present_column( - *self._read_column(self.featureAttrsKey, self.featNamesKey) - ).astype(object) - logger.warning( - f"Feature name key {self.featNamesKey!r} was not found in " - f"{self.featureAttrsKey}; feature IDs will be used" - ) - return self._feature_identifiers()[0].copy() + if self._featureNames is None: + self._featureNames = self._read_feature_names() + return self._featureNames.copy() + + def _read_feature_names(self) -> np.ndarray: + if not self._check_exists(self.featureAttrsKey, self.featNamesKey): + logger.warning( + f"Feature name key {self.featNamesKey!r} was not found in " + f"{self.featureAttrsKey}; feature IDs will be used" + ) + return self._feature_identifiers()[0].copy() + names = present_column( + *self._read_column(self.featureAttrsKey, self.featNamesKey) + ).astype(object) + missing = np.asarray([_is_missing_name(name) for name in names], dtype=bool) + if missing.any(): + names[missing] = self._feature_identifiers()[0][missing] + logger.warning( + f"{int(missing.sum())} feature names in {self.featNamesKey!r} are " + "missing or blank; those features take their IDs as names" + ) + return names + + def _index_member(self, group: str) -> str | None: + """Return the source name of a dataframe group's index, if it has one.""" + if self.groupCodes.get(group) != 2: + return None + table = self.h5[group] + recorded = index_key(table) + for key in (recorded, _UNNAMED_INDEX): + if key is not None and key in table: + return key + return None + + def _import_name(self, group: str, name: str) -> str: + """Return the name under which a source column imports. + + Only AnnData's unnamed index changes its name, to the first of + ``source_index``, ``source_index_2``, and so on that the table does + not use. + """ + if name != _UNNAMED_INDEX: + return name + taken = set(table_column_names(self.h5[group])) + candidate, suffix = _SOURCE_INDEX, 2 + while candidate in taken: + candidate, suffix = f"{_SOURCE_INDEX}_{suffix}", suffix + 1 + return candidate + + def _repeats_identifiers( + self, + group: str, + values: np.ndarray, + missing: np.ndarray, + ) -> bool: + """Return whether an index holds the IDs or the names of its table.""" + texts = _texts(present_column(values, missing)) + known: tuple[np.ndarray, ...] + if group == self.cellAttrsKey: + known = (self._cell_identifiers()[0],) + else: + known = (self._feature_identifiers()[0], self.feat_names()) + return any(texts == _texts(column) for column in known) def _table_columns( self, group: str, ignore_keys: Sequence[str] - ) -> Iterator[tuple[str, np.ndarray, np.ndarray]]: - """Yield each decodable column with its stored values and missing mask. + ) -> Iterator[tuple[str, np.ndarray, np.ndarray, H5adCategories | None]]: + """Yield each decodable column with its values, missing mask, and categories. - Columns come by source name. A dataframe group lists them in + Columns come by the name they import under, which is their source + name except for an unnamed index. A dataframe group lists them in ``column-order``, which resolves a name that old AnnData versions - nested into groups because it contains ``/``. + nested into groups because it contains ``/``. An index that repeats + the table's IDs or names is left out. """ code = self.groupCodes[group] if code not in {1, 2}: @@ -557,6 +683,7 @@ def _table_columns( ) nodes = dict(members.members) names = tuple(nodes) + index = self._index_member(group) for name in iter_progress(names, desc=f"Reading attributes from group {group}"): if name in ignore_keys: continue @@ -578,10 +705,17 @@ def _table_columns( f"{values.ndim}-dimensional values" ) continue - yield name, values, missing + if name == index and self._repeats_identifiers(group, values, missing): + logger.info( + f"Leaving out the {group} index {name!r} because it holds the " + "IDs or the names that the import stores" + ) + continue + categories = column_categories(self.h5, table, name, self._categoryGroups) + yield self._import_name(group, name), values, missing, categories def _source_column_names(self, group: str) -> list[str]: - """Return the source names of the decodable columns of a table. + """Return the import names of the decodable columns of a table. The names include the ID, name, and cluster columns that metadata import leaves out, so a caller can plan storage keys over the whole @@ -589,7 +723,24 @@ def _source_column_names(self, group: str) -> list[str]: """ if self.groupCodes.get(group) not in {1, 2}: return [] - return table_column_names(self.h5[group]) + return [ + self._import_name(group, name) + for name in table_column_names(self.h5[group]) + ] + + def _category_colors( + self, + name: str, + categories: H5adCategories, + ) -> tuple[str, ...] | None: + """Return the ``uns`` colors of an ``obs`` categorical, in category order.""" + return category_colors(self.h5, name, len(categories.values)) + + def _cell_column_categories(self, key: str) -> H5adCategories | None: + """Return the categories of an ``obs`` column, such as a cluster key.""" + return column_categories( + self.h5, self.h5[self.cellAttrsKey], key, self._categoryGroups + ) def _cell_column_value_dtype(self, key: str) -> np.dtype[Any]: return table_column_dtype( @@ -622,7 +773,13 @@ def _cell_ids_block(self, start: int, stop: int) -> np.ndarray: """ if not self._check_exists(self.cellAttrsKey, self.cellIdsKey): return np.asarray([f"cell_{index}" for index in range(start, stop)]) - values, _ = self._read_column(self.cellAttrsKey, self.cellIdsKey, start, stop) + if self.duplicateCellIds == "make_unique": + # Renamed IDs exist only once every ID was read. + values = self._cell_identifiers()[1][start:stop] + else: + values, _ = self._read_column( + self.cellAttrsKey, self.cellIdsKey, start, stop + ) return np.asarray( [as_text(value) for value in values.astype(object)], dtype=str ) @@ -642,13 +799,17 @@ def _iter_obsm_blocks( stop = min(start + block_rows, self.nCells) yield np.asarray(node[start:stop], dtype=dtype) - def _cell_columns(self) -> Iterator[tuple[str, np.ndarray, np.ndarray]]: + def _cell_columns( + self, + ) -> Iterator[tuple[str, np.ndarray, np.ndarray, H5adCategories | None]]: return self._table_columns( self.cellAttrsKey, [self.cellIdsKey, self.catNamesKey, *self.clusterKeys], ) - def _feature_columns(self) -> Iterator[tuple[str, np.ndarray, np.ndarray]]: + def _feature_columns( + self, + ) -> Iterator[tuple[str, np.ndarray, np.ndarray, H5adCategories | None]]: return self._table_columns( self.featureAttrsKey, [self.featIdsKey, self.featNamesKey, self.catNamesKey], @@ -660,12 +821,12 @@ def get_cell_columns(self) -> Generator[tuple[str, np.ndarray], None, None]: Missing categorical and text values are ``None``; missing numeric values are NaN. """ - for name, values, missing in self._cell_columns(): + for name, values, missing, _categories in self._cell_columns(): yield name, present_column(values, missing) def get_feat_columns(self) -> Generator[tuple[str, np.ndarray], None, None]: """Creates a Generator that yields the feature columns.""" - for name, values, missing in self._feature_columns(): + for name, values, missing, _categories in self._feature_columns(): yield name, present_column(values, missing) def assay_membership(self) -> dict[str, str]: diff --git a/cytearc/writers/export.py b/cytearc/writers/export.py index 19830d8..2f945e0 100644 --- a/cytearc/writers/export.py +++ b/cytearc/writers/export.py @@ -1,24 +1,27 @@ """Export assays and completed pipeline runs to H5AD and Matrix Market files. -Every H5AD export is an :class:`H5adExportPlan`: the matrices, columns, and -embeddings that the file holds, each read only while it is written. -:func:`live_h5ad_plan` plans the export of a complete assay with its live -metadata, and a ``DataStore`` plans the export of a completed pipeline run. -:func:`write_h5ad_plan` writes either one with h5py, row block by row block, -so it never holds a complete matrix and needs no AnnData. -:func:`materialize_h5ad_matrix` and :func:`h5ad_frame` build the in-memory +Every H5AD export is an :class:`H5adExportPlan`: the matrices, columns, +embeddings, graphs, and unstructured values that the file holds, each read +only while it is written. :func:`live_h5ad_plan` plans the export of an assay +with its live metadata, :func:`with_artifacts` adds the ``obsm``, ``obs``, +``obsp``, ``varm``, and ``uns`` entries of artifact references, and a +``DataStore`` plans the export of a completed pipeline run. +:func:`write_h5ad_plan` writes any of them with h5py, row block by row block, +so it never holds a complete count matrix and needs no AnnData. +:func:`materialize_h5ad_matrix` and :func:`h5ad_table` build the in-memory form of the same plan, which ``DataStore.to_anndata`` returns. """ +import gzip import os import re import secrets import stat import unicodedata from collections import defaultdict -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from contextlib import suppress -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import partial from typing import TYPE_CHECKING, Any, Literal, cast @@ -26,28 +29,62 @@ from scipy.sparse import csr_matrix, issparse, vstack from ..metadata.rows import apply_missing_mask, metadata_missing_mask +from ..storage.refs import ArtifactRef from ..utils.compute import compute_with_progress from ..utils.logging import logger if TYPE_CHECKING: import pandas as pd + import zarr type H5adText = Literal["string", "categorical"] +type H5adSource = ArtifactRef | tuple[ArtifactRef, str] +"""An artifact to export, or an artifact with the name of one of its per-cell arrays.""" # Element storage of a streamed CSR matrix. _CSR_CHUNK = 65_536 # Value kinds that hold text: strings, bytes, objects, and NumPy strings. _TEXT_KINDS = frozenset("USOT") +# AnnData stores an unnamed dataframe index under this name, which no +# dataframe column may take; such a column exports under the next name. +_RESERVED_COLUMN = "_index" +_SOURCE_INDEX = "source_index" @dataclass(frozen=True, slots=True) class H5adMatrix: - """A cells-by-features matrix that an H5AD export reads in row blocks.""" + """A cells-by-features matrix that an H5AD export reads in row blocks. + + ``export_dtype``, when set, is the dtype that the export stores instead + of ``dtype``: each converted piece is cast to it, and a value that it + cannot hold raises ``OverflowError``. + """ shape: tuple[int, int] dtype: np.dtype[Any] blocks: Callable[[], Iterator[csr_matrix | np.ndarray]] reserve: Callable[[], int] | None = None + export_dtype: np.dtype[Any] | None = None + + +@dataclass(frozen=True, slots=True) +class H5adCategorical: + """How an exported column becomes a pandas categorical. + + Attributes: + levels: The categories in their order, unused ones included. A value + that they do not list is appended, in natural order, with a + warning. None takes the distinct present values, as text, in + natural order. + ordered: Whether the categories are ordered. + colors: The color of each category. When every category has one, + the export writes them as ``uns["_colors"]`` in category + order, as Scanpy reads them. + """ + + levels: tuple[Any, ...] | None = None + ordered: bool = False + colors: Mapping[Any, str] | None = None @dataclass(frozen=True, slots=True) @@ -57,11 +94,17 @@ class H5adColumn: name: str read: Callable[[], tuple[np.ndarray, np.ndarray | None]] text: H5adText = "string" + categorical: H5adCategorical | None = None @dataclass(frozen=True, slots=True) class H5adExportPlan: - """Everything that one H5AD file holds, read while it is written.""" + """Everything that one H5AD file holds, read while it is written. + + ``obsp`` holds cells-by-cells sparse matrices, ``varm`` features-by-k + arrays, and ``uns`` unstructured values: mappings with text keys, text, + numbers, booleans, and arrays or lists of them. + """ x: H5adMatrix obs_index: H5adColumn @@ -71,6 +114,9 @@ class H5adExportPlan: obsm: Mapping[str, Callable[[], np.ndarray]] = field(default_factory=dict) layers: Mapping[str, H5adMatrix] = field(default_factory=dict) membership: Mapping[str, str] = field(default_factory=dict) + obsp: Mapping[str, Callable[[], csr_matrix]] = field(default_factory=dict) + varm: Mapping[str, Callable[[], np.ndarray]] = field(default_factory=dict) + uns: Mapping[str, Any] = field(default_factory=dict) # Dense values that one conversion step turns into CSR. A dense row block is @@ -78,6 +124,9 @@ class H5adExportPlan: # next is converted, so the conversion holds a bounded number of bytes # whatever the size of the block. _CSR_STEP_VALUES = 1 << 20 +# Bytes of one value of the widest export dtype, which a converted piece may +# be cast to. +_CAST_ITEMSIZE = 8 def _csr_step_rows(n_cols: int) -> int: @@ -98,7 +147,11 @@ def _csr_index_dtype(n_cols: int, step_values: int) -> np.dtype[Any]: def h5ad_conversion_bytes(dtype: Any, n_cols: int, block_rows: int) -> int: - """Return the bytes held while converting dense row blocks to CSR.""" + """Return the bytes held while converting dense row blocks to CSR. + + They include a copy of a step's values in the export dtype, at most + eight bytes each, which a cast to another dtype holds. + """ rows = min(_csr_step_rows(n_cols), int(block_rows)) if rows <= 0: return 0 @@ -106,7 +159,7 @@ def h5ad_conversion_bytes(dtype: Any, n_cols: int, block_rows: int) -> int: itemsize = int(np.dtype(dtype).itemsize) index_bytes = _csr_index_dtype(n_cols, values).itemsize int64 = np.dtype(np.int64).itemsize - per_value = 2 * itemsize + int64 + index_bytes + per_value = 2 * itemsize + int64 + index_bytes + _CAST_ITEMSIZE per_row = 2 * int64 + index_bytes return values * per_value + (rows + 1) * per_row @@ -152,11 +205,61 @@ def _dense_csr_pieces(block: np.ndarray) -> Iterator[csr_matrix]: del data, indices, indptr +def export_count_dtype(stored: Any, requested: Any | None = None) -> np.dtype[Any]: + """Return the dtype in which an export stores raw counts. + + An explicit ``requested`` dtype, which must be boolean, integer, or + floating-point, is used as given. Otherwise counts keep their stored + dtype, except that ``uint8`` and ``uint16`` counts become ``float32``: + arithmetic on them wraps silently or narrows, as ``X * 300`` does in + ``uint16`` and ``numpy.log1p`` does to ``float16`` on ``uint8``. Every + value of either type is a whole number that ``float32`` holds exactly, + and ``float32`` is the dtype in which Scanpy's readers return counts. + """ + if requested is not None: + resolved: np.dtype[Any] = np.dtype(requested) + if resolved.kind not in "biuf": + raise TypeError( + f"dtype must be a boolean, integer, or floating-point dtype, " + f"not {resolved}" + ) + return resolved + source: np.dtype[Any] = np.dtype(stored) + if source in (np.dtype(np.uint8), np.dtype(np.uint16)): + return np.dtype(np.float32) + return source + + +def _cast_values(values: np.ndarray, dtype: np.dtype[Any]) -> np.ndarray: + """Cast count values, raising ``OverflowError`` for a value ``dtype`` cannot hold.""" + from ..utils.arrays import checked_sparse_cast + + # A count beyond a float dtype becomes infinite, which is refused below. + with np.errstate(over="ignore"): + cast_values = checked_sparse_cast(np.asarray(values), dtype) + if dtype.kind == "f" and not bool(np.isfinite(cast_values).all()): + raise OverflowError(f"Counts exceed the range of the export dtype {dtype}") + return cast_values + + +def cast_counts(matrix: csr_matrix, dtype: np.dtype[Any]) -> csr_matrix: + """Return a CSR count matrix in ``dtype``, checked like an export's pieces.""" + if matrix.dtype == dtype: + return matrix + return csr_matrix( + (_cast_values(matrix.data, dtype), matrix.indices, matrix.indptr), + shape=matrix.shape, + ) + + def iter_h5ad_blocks( matrix: H5adMatrix, label: str = "The X matrix", ) -> Iterator[csr_matrix]: - """Yield the rows of ``matrix`` as CSR pieces, checked against it.""" + """Yield the rows of ``matrix`` as CSR pieces, checked against it. + + Pieces are cast to ``matrix.export_dtype`` when it is set. + """ n_rows, n_cols = matrix.shape rows = 0 for values in matrix.blocks(): @@ -183,16 +286,24 @@ def iter_h5ad_blocks( raise ValueError( f"{label} has more than the {n_rows} rows that the export declares" ) - if issparse(block): - yield csr_matrix(block) - else: - yield from _dense_csr_pieces(block) + pieces = ( + iter((csr_matrix(block),)) if issparse(block) else _dense_csr_pieces(block) + ) + for piece in pieces: + if matrix.export_dtype is not None: + piece = cast_counts(piece, matrix.export_dtype) + yield piece # The stream reads the next block while this one is held no longer. del values, block if rows != n_rows: raise ValueError(f"{label} has {rows} rows; the export declares {n_rows}") +def _stored_dtype(matrix: H5adMatrix) -> np.dtype[Any]: + """Return the dtype in which an export stores ``matrix``.""" + return matrix.dtype if matrix.export_dtype is None else matrix.export_dtype + + def materialize_h5ad_matrix( matrix: H5adMatrix, label: str = "The X matrix", @@ -200,7 +311,7 @@ def materialize_h5ad_matrix( """Return ``matrix`` as one CSR matrix, built from its checked row blocks.""" blocks = list(iter_h5ad_blocks(matrix, label)) if not blocks: - return csr_matrix(matrix.shape, dtype=matrix.dtype) + return csr_matrix(matrix.shape, dtype=_stored_dtype(matrix)) return cast(csr_matrix, vstack(blocks, format="csr")) @@ -246,6 +357,7 @@ def _categorical_column( and none at all for text that is missing in every row. A column that is neither numeric nor boolean and has missing rows is such text too. Numbers, booleans, and other text are not categoricals, and return None. + Columns with an :class:`H5adCategorical` follow it instead. """ import pandas as pd @@ -264,6 +376,86 @@ def _categorical_column( return categorical.reorder_categories(natural) +def _explicit_categorical( + name: str, + values: np.ndarray, + missing: np.ndarray | None, + spec: H5adCategorical, +) -> tuple["pd.Categorical", tuple[str, ...] | None]: + """Return a column as the categorical that ``spec`` describes, with its colors. + + Missing rows have no category. The colors are returned in category + order when ``spec`` gives every category one. + """ + import pandas as pd + + present = ( + np.ones(len(values), dtype=bool) if missing is None else ~np.asarray(missing) + ) + if spec.levels is None: + objects = values.astype(str).astype(object) + objects[~present] = None + levels = sorted({str(value) for value in objects[present]}, key=_natural_key) + else: + objects = values.astype(object) + objects[~present] = None + levels = list(spec.levels) + positions = pd.Index(levels, dtype=object).get_indexer(objects) + unlisted = (positions < 0) & present + if unlisted.any(): + extra = sorted( + set(objects[unlisted].tolist()), + key=lambda value: _natural_key(str(value)), + ) + logger.warning( + f"Column {name!r} holds {len(extra)} values that are not among its " + f"stored categories, such as {extra[0]!r}; the export appends them " + "to its categories" + ) + levels = [*levels, *extra] + categorical = pd.Categorical(objects, categories=levels, ordered=spec.ordered) + colors: tuple[str, ...] | None = None + if spec.colors is not None and levels: + palette = [spec.colors.get(level) for level in categorical.categories.tolist()] + if all(isinstance(color, str) for color in palette): + colors = tuple(cast(list[str], palette)) + return categorical, colors + + +def unique_column_names( + index: str, + names: Sequence[str], + *, + table: str, +) -> list[str]: + """Return the export name of each column, so that no two names clash. + + AnnData reserves ``_index``, so a column of that name exports as + ``source_index``. A column whose name an earlier column or the index + already takes exports under the first free name among ``_2``, + ``_3``, and so on. Each rename is logged as a warning. + """ + taken = {index} + resolved: list[str] = [] + for name in names: + base = _SOURCE_INDEX if name == _RESERVED_COLUMN else name + candidate, suffix = base, 2 + while candidate in taken: + candidate, suffix = f"{base}_{suffix}", suffix + 1 + if candidate != name: + logger.warning( + f"Exporting {table} column {name!r} as {candidate!r} because " + + ( + "AnnData reserves its name" + if name == _RESERVED_COLUMN + else "the export already uses its name" + ) + ) + taken.add(candidate) + resolved.append(candidate) + return resolved + + def _encoded(node: Any, encoding: str, version: str) -> Any: """Record the AnnData element encoding on an HDF5 dataset or group.""" node.attrs["encoding-type"] = encoding @@ -291,11 +483,12 @@ def _write_categorical( name: str, categories: np.ndarray, codes: np.ndarray, + ordered: bool = False, ) -> None: - """Write one unordered categorical whose code -1 marks a missing row.""" + """Write one categorical whose code -1 marks a missing row.""" column = group.create_group(name) _encoded(column, "categorical", "0.2.0") - column.attrs["ordered"] = False + column.attrs["ordered"] = bool(ordered) _write_array(column, "categories", categories) _write_array(column, "codes", codes) @@ -365,9 +558,29 @@ def _read_column( return values, missing -def _write_column(group: Any, column: H5adColumn, n_rows: int) -> bool: - """Write one column; skip one of unsupported dtype with a warning.""" +def _write_column( + group: Any, + column: H5adColumn, + n_rows: int, +) -> tuple[bool, tuple[str, ...] | None]: + """Write one column; skip one of unsupported dtype with a warning. + + Returns whether the column was written and the colors of its categories, + if it has them. + """ values, missing = _read_column(column, n_rows) + if column.categorical is not None: + explicit, colors = _explicit_categorical( + column.name, values, missing, column.categorical + ) + _write_categorical( + group, + column.name, + np.asarray(explicit.categories), + np.asarray(explicit.codes), + explicit.ordered, + ) + return True, colors if column.text == "categorical": categorical = _categorical_column(values, missing) if categorical is not None: @@ -377,10 +590,10 @@ def _write_column(group: Any, column: H5adColumn, n_rows: int) -> bool: np.asarray(categorical.categories, dtype=object), np.asarray(categorical.codes), ) - return True + return True, None if missing is not None and missing.any(): _write_masked_column(group, column.name, values, missing, column.text) - return True + return True, None try: _write_array(group, column.name, values) except TypeError: @@ -388,16 +601,20 @@ def _write_column(group: Any, column: H5adColumn, n_rows: int) -> bool: f"Skipping metadata column {column.name!r} with unsupported dtype " f"{values.dtype}" ) - return False - return True + return False, None + return True, None -def h5ad_frame( +def h5ad_table( index: H5adColumn, columns: tuple[H5adColumn, ...], n_rows: int, -) -> "pd.DataFrame": - """Return an ``obs`` or ``var`` table as an in-memory export holds it.""" +) -> tuple["pd.DataFrame", dict[str, tuple[str, ...]]]: + """Return an ``obs`` or ``var`` table as an in-memory export holds it. + + Also returns the colors of the categories of each column that has them, + which the export writes as ``uns["_colors"]``. + """ import pandas as pd from ..metadata.queries import missing_frame_values @@ -406,8 +623,16 @@ def h5ad_frame( if index_missing is not None and index_missing.any(): raise ValueError(f"Index {index.name!r} holds missing values") data: dict[str, Any] = {} + colors: dict[str, tuple[str, ...]] = {} for column in columns: values, missing = _read_column(column, n_rows) + if column.categorical is not None: + data[column.name], palette = _explicit_categorical( + column.name, values, missing, column.categorical + ) + if palette is not None: + colors[column.name] = palette + continue categorical = ( _categorical_column(values, missing) if column.text == "categorical" @@ -420,7 +645,17 @@ def h5ad_frame( data[column.name] = values.astype(str) else: data[column.name] = missing_frame_values(values, missing) - return pd.DataFrame(data, index=pd.Index(index_values, name=index.name)) + frame = pd.DataFrame(data, index=pd.Index(index_values, name=index.name)) + return frame, colors + + +def h5ad_frame( + index: H5adColumn, + columns: tuple[H5adColumn, ...], + n_rows: int, +) -> "pd.DataFrame": + """Return an ``obs`` or ``var`` table as an in-memory export holds it.""" + return h5ad_table(index, columns, n_rows)[0] def _write_frame( @@ -428,22 +663,31 @@ def _write_frame( index: H5adColumn, columns: tuple[H5adColumn, ...], n_rows: int, -) -> set[str]: - """Write an ``obs`` or ``var`` dataframe; return the columns it holds.""" +) -> tuple[set[str], dict[str, tuple[str, ...]]]: + """Write an ``obs`` or ``var`` dataframe. + + Returns the columns that it holds and the category colors of each that + has them. + """ values, missing = _read_column(index, n_rows) if missing is not None and missing.any(): raise ValueError(f"Index {index.name!r} holds missing values") _write_array(group, index.name, values) # column-order names only written columns: AnnData reads every column it # names, so a skipped one would make the file unreadable. - written = [ - column.name for column in columns if _write_column(group, column, n_rows) - ] + written: list[str] = [] + colors: dict[str, tuple[str, ...]] = {} + for column in columns: + stored, palette = _write_column(group, column, n_rows) + if stored: + written.append(column.name) + if palette is not None: + colors[column.name] = palette group.attrs["_index"] = index.name # HDF5 cannot store an empty object array; AnnData writes an empty list. group.attrs["column-order"] = np.array(written, dtype=object) if written else [] _encoded(group, "dataframe", "0.2.0") - return set(written) + return set(written), colors def _write_csr(group: Any, matrix: H5adMatrix, label: str) -> None: @@ -464,7 +708,7 @@ def _write_csr(group: Any, matrix: H5adMatrix, label: str) -> None: maxshape=(None,), chunks=(_CSR_CHUNK,), compression="gzip", - dtype=matrix.dtype, + dtype=_stored_dtype(matrix), ) indices = group.create_dataset( "indices", @@ -495,6 +739,77 @@ def _write_csr(group: Any, matrix: H5adMatrix, label: str) -> None: group.attrs["shape"] = np.array([n_rows, n_cols]) +def _write_sparse(group: Any, name: str, matrix: csr_matrix) -> None: + """Write one in-memory CSR matrix as an AnnData element.""" + node = group.create_group(name) + _encoded(node, "csr_matrix", "0.1.0") + node.attrs["shape"] = np.array(matrix.shape) + node.create_dataset("data", data=matrix.data) + node.create_dataset("indices", data=matrix.indices) + node.create_dataset("indptr", data=matrix.indptr) + + +def _write_uns_value(group: Any, name: str, value: Any) -> None: + """Write one ``uns`` value with the encoding AnnData reads it with.""" + import h5py + + if isinstance(value, Mapping): + child = _encoded(group.create_group(name), "dict", "0.1.0") + for key, item in value.items(): + _write_uns_value(child, str(key), item) + elif isinstance(value, str): + _encoded( + group.create_dataset(name, data=value, dtype=h5py.string_dtype()), + "string", + "0.2.0", + ) + elif isinstance(value, bool | int | float | np.generic): + _encoded( + group.create_dataset(name, data=np.asarray(value)), + "numeric-scalar", + "0.2.0", + ) + else: + _write_array(group, name, np.asarray(value)) + + +def _merged(base: Mapping[str, Any], extra: Mapping[str, Any]) -> dict[str, Any]: + """Return ``base`` with ``extra`` merged in, mapping by mapping.""" + merged = dict(base) + for key, value in extra.items(): + current = merged.get(key) + if isinstance(current, Mapping) and isinstance(value, Mapping): + merged[key] = _merged(current, value) + else: + merged[key] = value + return merged + + +def plan_uns( + plan: H5adExportPlan, + obs_columns: Iterable[str], + colors: Mapping[str, tuple[str, ...]], +) -> dict[str, Any]: + """Return the ``uns`` of an export: the plan's values, colors, and membership. + + Each written ``obs`` column whose categories have colors adds + ``_colors``, and the membership columns that the export holds are + declared under ``uns["cytearc"]["assayMembership"]``. + """ + from ..metadata.membership import membership_declaration + + written = set(obs_columns) + uns = dict(plan.uns) + for name, palette in colors.items(): + uns[f"{name}_colors"] = np.asarray(palette, dtype=object) + declared = { + assay_name: column + for assay_name, column in plan.membership.items() + if column in written + } + return _merged(uns, membership_declaration(declared)) + + def _check_plan(plan: H5adExportPlan) -> None: """Reject a plan that no file can hold, before anything is written.""" for table, index, columns in ( @@ -512,54 +827,74 @@ def _check_plan(plan: H5adExportPlan) -> None: ) -def _write_plan(h5: Any, plan: H5adExportPlan) -> None: - import h5py +def read_obsp(plan: H5adExportPlan, name: str) -> csr_matrix: + """Read one ``obsp`` matrix and check that it pairs the exported cells.""" + n_obs = plan.x.shape[0] + matrix = csr_matrix(plan.obsp[name]()) + if matrix.shape != (n_obs, n_obs): + raise ValueError( + f"obsp {name!r} has shape {matrix.shape}; the export declares " + f"{(n_obs, n_obs)}" + ) + return matrix + + +def read_varm(plan: H5adExportPlan, name: str) -> np.ndarray: + """Read one ``varm`` array and check that it has a row per exported feature.""" + n_vars = plan.x.shape[1] + values = np.asarray(plan.varm[name]()) + if values.ndim != 2 or values.shape[0] != n_vars: + raise ValueError( + f"varm {name!r} has shape {values.shape}; the export declares " + f"{n_vars} features" + ) + return values - from ..metadata.membership import MEMBERSHIP_UNS_KEY, CYTEARC_UNS_KEY +def read_obsm(plan: H5adExportPlan, name: str) -> np.ndarray: + """Read one ``obsm`` array and check that it has a row per exported cell.""" + n_obs = plan.x.shape[0] + values = np.asarray(plan.obsm[name]()) + if values.ndim != 2: + raise ValueError( + f"obsm {name!r} has {values.ndim} dimensions; " + "obsm arrays are two-dimensional" + ) + if values.shape[0] != n_obs: + raise ValueError( + f"obsm {name!r} has {values.shape[0]} rows; the export declares {n_obs}" + ) + return values + + +def _write_plan(h5: Any, plan: H5adExportPlan) -> None: n_obs, n_vars = plan.x.shape _encoded(h5, "anndata", "0.1.0") for name in ("X", "obs", "var"): h5.create_group(name) obsm = _encoded(h5.create_group("obsm"), "dict", "0.1.0") _write_csr(h5["X"], plan.x, "The X matrix") - obs_columns = _write_frame(h5["obs"], plan.obs_index, plan.obs, n_obs) + obs_columns, colors = _write_frame(h5["obs"], plan.obs_index, plan.obs, n_obs) _write_frame(h5["var"], plan.var_index, plan.var, n_vars) - for name, read in plan.obsm.items(): - values = np.asarray(read()) - if values.ndim != 2: - raise ValueError( - f"obsm {name!r} has {values.ndim} dimensions; " - "obsm arrays are two-dimensional" - ) - if values.shape[0] != n_obs: - raise ValueError( - f"obsm {name!r} has {values.shape[0]} rows; the export declares {n_obs}" - ) - _write_array(obsm, name, values) + for name in plan.obsm: + _write_array(obsm, name, read_obsm(plan, name)) if plan.layers: layers = _encoded(h5.create_group("layers"), "dict", "0.1.0") for name, layer in plan.layers.items(): _write_csr(layers.create_group(name), layer, f"Layer {name!r}") - declared = { - assay_name: column - for assay_name, column in plan.membership.items() - if column in obs_columns - } - if declared: - uns = _encoded(h5.create_group("uns"), "dict", "0.1.0") - cytearc_uns = _encoded(uns.create_group(CYTEARC_UNS_KEY), "dict", "0.1.0") - entries = _encoded( - cytearc_uns.create_group(MEMBERSHIP_UNS_KEY), "dict", "0.1.0" - ) - for assay_name, column in declared.items(): - _encoded( - entries.create_dataset( - assay_name, data=column, dtype=h5py.string_dtype() - ), - "string", - "0.2.0", - ) + if plan.obsp: + obsp = _encoded(h5.create_group("obsp"), "dict", "0.1.0") + for name in plan.obsp: + _write_sparse(obsp, name, read_obsp(plan, name)) + if plan.varm: + varm = _encoded(h5.create_group("varm"), "dict", "0.1.0") + for name in plan.varm: + _write_array(varm, name, read_varm(plan, name)) + uns = plan_uns(plan, obs_columns, colors) + if uns: + group = _encoded(h5.create_group("uns"), "dict", "0.1.0") + for name, value in uns.items(): + _write_uns_value(group, name, value) def write_h5ad_plan(plan: H5adExportPlan, path: str | os.PathLike[str]) -> None: @@ -588,6 +923,685 @@ def write_h5ad_plan(plan: H5adExportPlan, path: str | os.PathLike[str]) -> None: raise +def with_count_dtype( + plan: H5adExportPlan, + dtype: Any | None, + *, + raw_x: bool, +) -> H5adExportPlan: + """Return ``plan`` storing its raw counts in :func:`export_count_dtype`. + + The raw counts are ``X`` when ``raw_x`` is True, and every layer. + """ + + def converted(matrix: H5adMatrix) -> H5adMatrix: + target = export_count_dtype(matrix.dtype, dtype) + if target == matrix.dtype: + return matrix + return replace(matrix, export_dtype=target) + + return replace( + plan, + x=converted(plan.x) if raw_x else plan.x, + layers={name: converted(layer) for name, layer in plan.layers.items()}, + ) + + +# --------------------------------------------------------------------------- +# Artifacts + + +# Kinds whose rows follow the cell selection of their lineage. +_LINEAGE_KINDS = frozenset({"reduction", "batch_correction"}) +_GRAPH_KINDS = frozenset({"connectivity_map", "integrated_graph"}) +_SCANPY_METRICS = {"l2": "euclidean", "cosine": "cosine"} +# Bytes that exporting a graph holds per stored edge: its edges and weights, +# the directed and transposed CSR matrices, their product and fuzzy union, +# and the distances. +_GRAPH_EDGE_BYTES = 96 + + +@dataclass(frozen=True, slots=True) +class H5adArtifactEntries: + """The ``obsm``, ``obs``, ``obsp``, ``varm``, and ``uns`` entries of artifacts.""" + + obsm: Mapping[str, Callable[[], np.ndarray]] + obs: tuple[H5adColumn, ...] + obsp: Mapping[str, Callable[[], csr_matrix]] + varm: Mapping[str, Callable[[], np.ndarray]] + uns: Mapping[str, Any] + + +def _source(value: Any, label: str) -> tuple[ArtifactRef, str | None]: + """Return an export source as its artifact and the per-cell array it names.""" + if isinstance(value, ArtifactRef): + return value, None + if ( + isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], ArtifactRef) + and isinstance(value[1], str) + and value[1] + ): + return value[0], value[1] + raise TypeError( + f"{label} must be an ArtifactRef or an (ArtifactRef, array name) pair" + ) + + +def _sources( + values: Mapping[str, H5adSource] | None, + label: str, +) -> dict[str, tuple[ArtifactRef, str | None]]: + if values is None: + return {} + if not isinstance(values, Mapping): + raise TypeError(f"{label} must map names to artifact references") + resolved: dict[str, tuple[ArtifactRef, str | None]] = {} + for name, value in values.items(): + if not isinstance(name, str) or not name: + raise ValueError(f"{label} names must be non-empty strings") + if "/" in name: + raise ValueError( + f"{label} name {name!r} contains '/', which AnnData does not allow " + "in keys" + ) + resolved[name] = _source(value, f"{label}[{name!r}]") + return resolved + + +class _ArtifactRows: + """Resolve and read the cell-aligned rows of artifacts, once per selection.""" + + def __init__( + self, root: "zarr.Group", cell_rows: np.ndarray, max_bytes: int | None + ): + self._root = root + self._cellRows = np.asarray(cell_rows, dtype=np.int64) + self._maxBytes = max_bytes + self._selections: dict[ArtifactRef, np.ndarray] = {} + + def selection_rows(self, selection: ArtifactRef) -> np.ndarray: + from ..storage.selections import read_stored_selection_indices + + if selection not in self._selections: + self._selections[selection] = read_stored_selection_indices( + self._root, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + return self._selections[selection] + + def positions( + self, label: str, ref: ArtifactRef, selection: ArtifactRef + ) -> np.ndarray: + """Return where each exported cell lies among the artifact's rows.""" + rows = self.selection_rows(selection) + positions = np.searchsorted(rows, self._cellRows) + covered = positions < rows.size + covered[covered] = rows[positions[covered]] == self._cellRows[covered] + if not covered.all(): + raise ValueError( + f"{label}: the {ref.kind} artifact holds values for " + f"{int(covered.sum())} of the {self._cellRows.size} exported cells. " + "Export the cells of its cell selection, with " + "cell_selection=" + ) + return positions + + def same_cells(self, label: str, ref: ArtifactRef, selection: ArtifactRef) -> None: + """Require that an artifact holds exactly the exported cells.""" + rows = self.selection_rows(selection) + if not np.array_equal(rows, self._cellRows): + raise ValueError( + f"{label}: the {ref.kind} artifact covers {rows.size} cells, and a " + f"graph is exported only over exactly its cells; the export holds " + f"{self._cellRows.size} other or more cells. Export the cells of " + "its cell selection, with cell_selection=" + ) + + +def _lineage_selection(root: "zarr.Group", ref: ArtifactRef) -> ArtifactRef: + from ..graph.feature_projection import resolve_coordinate_inputs + + return resolve_coordinate_inputs(root, ref).cell_selection + + +def _recorded_selection(root: "zarr.Group", ref: ArtifactRef) -> ArtifactRef: + from ..storage.artifacts import inspect_artifact + + status = inspect_artifact(root, ref) + if not status.exists or not status.complete: + raise ValueError(f"The {ref.kind} artifact is unavailable or incomplete") + return status.input_ref("cell_selection") + + +def _cell_value_name( + ref: ArtifactRef, value: str | None, label: str +) -> tuple[str, bool]: + """Return the per-cell array of an artifact to export and whether it holds labels.""" + from ..metadata.selection import cell_value_array + + if ref.kind in _LINEAGE_KINDS: + if value not in (None, "data"): + raise ValueError( + f"{label}: {ref.kind} artifacts export only their 'data' coordinates" + ) + return "data", False + try: + return cell_value_array(ref.kind, value) + except ValueError as error: + raise ValueError(f"{label}: {error}") from None + + +def _array_ndim(root: "zarr.Group", ref: ArtifactRef, name: str, label: str) -> int: + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + group = artifact_group(root, ref) + if name not in group: + raise ValueError(f"{label}: the {ref.kind} artifact has no {name!r} array") + return int(as_zarr_array(group[name], name=name).ndim) + + +def _read_cell_array( + root: "zarr.Group", + ref: ArtifactRef, + value: str, + positions: np.ndarray, + max_bytes: int | None, +) -> np.ndarray: + """Read an artifact's per-cell array at the rows of the exported cells.""" + return _read_cell_values(root, ref, value, positions, max_bytes)[0] + + +def _read_cell_values( + root: "zarr.Group", + ref: ArtifactRef, + value: str, + positions: np.ndarray, + max_bytes: int | None, +) -> tuple[np.ndarray, np.ndarray | None]: + """Read an artifact's per-cell array at the rows of the exported cells.""" + from ..metadata.rows import read_array_rows_chunkwise + from ..metadata.selection import resolve_cell_aligned_artifact + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + if ref.kind in _LINEAGE_KINDS: + data = as_zarr_array(artifact_group(root, ref)["data"], name="data") + return np.asarray(read_array_rows_chunkwise(data, positions)), None + resolved = resolve_cell_aligned_artifact( + root, ref, value_name=value, ndim=None, max_bytes=max_bytes + ) + missing = ( + None if resolved.missing_mask is None else resolved.missing_mask[positions] + ) + return np.asarray(resolved.values)[positions], missing + + +def _stored_labels(root: "zarr.Group", ref: ArtifactRef, value: str) -> H5adCategorical: + """Return the categories that a label array records, or their natural order. + + Labels imported from a categorical keep its ``levels``, ``ordered``, and + the colors of its ``display`` with their array. + """ + from ..storage.artifacts import artifact_group + + attributes = artifact_group(root, ref)[value].attrs + return stored_categorical(attributes) or H5adCategorical() + + +def stored_categorical(attributes: Mapping[str, Any]) -> H5adCategorical | None: + """Return the categorical that a stored column's attributes record, or None. + + A column keeps the categories of a source categorical as ``levels`` and + ``ordered`` attributes, and a categorical ``display`` gives their colors. + """ + levels = attributes.get("levels") + if not isinstance(levels, list | tuple): + return None + colors: dict[Any, str] | None = None + display = attributes.get("display") + if isinstance(display, Mapping) and display.get("kind") == "categorical": + colors = { + category["value"]: category["color"] + for category in display.get("categories", ()) + if isinstance(category, Mapping) and "value" in category + } + return H5adCategorical( + levels=tuple(levels), + ordered=bool(attributes.get("ordered", False)), + colors=colors, + ) + + +def _require_graph_memory( + root: "zarr.Group", + graph: ArtifactRef, + max_bytes: int | None, +) -> None: + """Refuse a graph whose export does not fit the memory budget.""" + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + edges = as_zarr_array(artifact_group(root, graph)["edges"], name="edges") + n_edges = int(edges.shape[0]) + required = n_edges * _GRAPH_EDGE_BYTES + if max_bytes is not None and required > max_bytes: + raise MemoryError( + f"Exporting the graph's {n_edges} edges needs about {required} bytes, " + f"but the memory budget is {max_bytes} bytes. Raise mem_budget." + ) + + +def _graph_matrices( + root: "zarr.Group", + graph: ArtifactRef, +) -> tuple[csr_matrix, csr_matrix | None]: + """Return a graph's symmetric connectivities and its KNN distances. + + The connectivities are the fuzzy union ``W + W.T - W * W.T`` of the + directed edge weights, which CyteArc's UMAP and Scanpy read. Distances + exist only for a connectivity map, whose neighbors record them. + """ + from ..graph.feature_projection import resolve_native_graph_inputs + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + group = artifact_group(root, graph) + n_cells = int(cast(int, group.attrs["n_cells"])) + edges = np.asarray(as_zarr_array(group["edges"], name="edges")[:]).astype( + np.int64, copy=False + ) + weights = np.asarray(as_zarr_array(group["weights"], name="weights")[:]) + distances: csr_matrix | None = None + if graph.kind == "connectivity_map": + neighbors = resolve_native_graph_inputs(root, graph).neighbors + knn = artifact_group(root, neighbors) + indices = np.asarray(as_zarr_array(knn["indices"], name="indices")[:]) + values = np.asarray(as_zarr_array(knn["distances"], name="distances")[:]) + distances = csr_matrix( + ( + values.ravel(), + ( + np.repeat(np.arange(n_cells), indices.shape[1]), + indices.ravel().astype(np.int64), + ), + ), + shape=(n_cells, n_cells), + ) + directed = csr_matrix( + (weights, (edges[:, 0], edges[:, 1])), shape=(n_cells, n_cells) + ) + transposed = directed.T.tocsr() + union = csr_matrix(directed + transposed - directed.multiply(transposed)) + union.eliminate_zeros() + return union, distances + + +def _graph_parameters( + root: "zarr.Group", + graph: ArtifactRef, + use_rep: str | None, +) -> dict[str, Any]: + """Return ``uns["neighbors"]["params"]`` of a graph in Scanpy's keys. + + ``method`` is ``umap`` for a connectivity map, whose weights follow + umap-learn's kernel, and the integration method of an integrated graph. + """ + from ..graph.feature_projection import resolve_native_graph_inputs + from ..storage.artifacts import artifact_group, inspect_artifact + + k = int(cast(int, artifact_group(root, graph).attrs["n_neighbors"])) + # Scanpy counts each cell among its own neighbors. + params: dict[str, Any] = {"n_neighbors": k + 1} + if graph.kind == "connectivity_map": + neighbors = resolve_native_graph_inputs(root, graph).neighbors + metric = str( + (inspect_artifact(root, neighbors).parameters or {})["distance_metric"] + ) + params["method"] = "umap" + params["metric"] = _SCANPY_METRICS.get(metric, metric) + else: + params["method"] = str( + (inspect_artifact(root, graph).parameters or {})["method"] + ) + if use_rep is not None: + params["use_rep"] = use_rep + return params + + +def _pca_loadings( + root: "zarr.Group", + ref: ArtifactRef, + feature_ids: np.ndarray, +) -> tuple[np.ndarray, np.ndarray] | None: + """Return the loading rows of a PCA and the feature ID of each, if it has them.""" + from ..assay.normalization import load_normalized_inputs + from ..graph.feature_projection import resolve_coordinate_inputs + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + group = artifact_group(root, ref) + if "loadings" not in group: + return None + loadings = np.asarray(as_zarr_array(group["loadings"], name="loadings")[:]) + if ref.kind == "imported_coordinates": + ids = np.asarray(as_zarr_array(group["feature_ids"], name="feature_ids")[:]) + return loadings, ids.astype(str) + normalized = resolve_coordinate_inputs(root, ref).normalized + assert normalized is not None + _group, selections = load_normalized_inputs(root, normalized) + assay = cast(str, ref.assay) + all_ids = np.asarray( + as_zarr_array(root[f"{assay}/featureData/ids"], name="ids")[:] + ).astype(str) + return loadings, all_ids[np.asarray(selections.featureMask, dtype=bool)] + + +def _varm_pcs( + loadings: np.ndarray, ids: np.ndarray, feature_ids: np.ndarray +) -> np.ndarray: + """Place loading rows at the exported features that they belong to. + + Exported features without a loading hold zeros, as Scanpy stores the + features that a PCA did not use. + """ + pcs = np.zeros((feature_ids.size, loadings.shape[1]), dtype=loadings.dtype) + position = {feature: index for index, feature in enumerate(ids.tolist())} + rows = np.asarray([position.get(feature, -1) for feature in feature_ids.tolist()]) + found = rows >= 0 + pcs[found] = loadings[rows[found]] + return pcs + + +def artifact_entries( + root: "zarr.Group", + *, + cell_rows: np.ndarray, + feature_ids: np.ndarray, + obsm: Mapping[str, H5adSource] | None = None, + obs: Mapping[str, H5adSource] | None = None, + graph: ArtifactRef | None = None, + max_bytes: int | None = None, + exported: Mapping[str, ArtifactRef] | None = None, +) -> H5adArtifactEntries: + """Plan the export of artifacts beside the cells at store rows ``cell_rows``. + + ``obsm`` names two-dimensional per-cell arrays, such as a ``reduction``, + ``batch_correction``, ``imported_coordinates``, or ``embedding``. The + entry named ``X_pca`` also writes its loadings, when the artifact has + them, as ``varm["PCs"]``, aligned to ``feature_ids`` with zeros for the + features it did not use. A PCA ``reduction`` that stores its variance and + variance ratio writes them as ``uns["pca"]["variance"]`` and + ``uns["pca"]["variance_ratio"]``, and an ``imported_coordinates`` + artifact with ``stdev`` writes their squares as + ``uns["pca"]["variance"]``. ``obs`` + names one-dimensional per-cell arrays: labels become categoricals, with + the categories, order, and colors that imported labels record, and + numbers stay numbers. A pair ``(ref, name)`` names another per-cell + array of the artifact. Each artifact must hold every exported cell. + + ``graph``, a ``connectivity_map`` or ``integrated_graph`` over exactly + the exported cells, becomes ``obsp["connectivities"]``, the fuzzy union + of its directed weights, and, for a connectivity map, + ``obsp["distances"]``, its KNN distances, with ``uns["neighbors"]`` in + Scanpy's layout, whose ``use_rep`` names the ``obsm`` entry, among + ``obsm`` and the ``exported`` arrays of the export, that holds the + graph's coordinates. ``uns["cytearc"]["artifacts"]`` records the + reference of every exported artifact by element. + + Every reference is resolved and aligned here, and a graph's memory need + is checked, so a mismatch raises before anything is written; values are + read while the export writes them. + """ + sources = _sources(obsm, "obsm") + labels = _sources(obs, "obs") + if graph is not None and not isinstance(graph, ArtifactRef): + raise TypeError("graph must be an ArtifactRef") + if graph is not None and graph.kind not in _GRAPH_KINDS: + raise ValueError( + f"graph must be a connectivity_map or integrated_graph artifact, not " + f"{graph.kind!r}" + ) + rows = _ArtifactRows(root, cell_rows, max_bytes) + lineage: dict[str, Any] = {} + obsm_entries: dict[str, Callable[[], np.ndarray]] = {} + for name, (ref, value) in sources.items(): + label = f"obsm {name!r}" + array, _labels = _cell_value_name(ref, value, label) + selection = ( + _lineage_selection(root, ref) + if ref.kind in _LINEAGE_KINDS + else _recorded_selection(root, ref) + ) + if _array_ndim(root, ref, array, label) != 2: + raise ValueError( + f"{label}: the {ref.kind} array {array!r} is not two-dimensional; " + "export it in obs" + ) + positions = rows.positions(label, ref, selection) + obsm_entries[name] = partial( + _read_cell_array, root, ref, array, positions, max_bytes + ) + lineage.setdefault("obsm", {})[name] = _lineage_record(ref, value) + obs_columns: list[H5adColumn] = [] + for name, (ref, value) in labels.items(): + label = f"obs {name!r}" + array, categorical = _cell_value_name(ref, value, label) + if ref.kind in _LINEAGE_KINDS: + raise ValueError(f"{label}: {ref.kind} coordinates belong in obsm") + selection = _recorded_selection(root, ref) + if _array_ndim(root, ref, array, label) != 1: + raise ValueError( + f"{label}: the {ref.kind} array {array!r} is not one-dimensional; " + "export it in obsm" + ) + positions = rows.positions(label, ref, selection) + obs_columns.append( + H5adColumn( + name, + partial(_read_cell_values, root, ref, array, positions, max_bytes), + categorical=(_stored_labels(root, ref, array) if categorical else None), + ) + ) + lineage.setdefault("obs", {})[name] = _lineage_record(ref, value) + varm: dict[str, Callable[[], np.ndarray]] = {} + uns: dict[str, Any] = {} + if "X_pca" in sources: + ref, _value = sources["X_pca"] + if ref.kind in {"reduction", "imported_coordinates"}: + loadings = _pca_loadings(root, ref, feature_ids) + if loadings is not None: + varm["PCs"] = partial(_varm_pcs, *loadings, np.asarray(feature_ids)) + if ref.kind == "reduction": + variance = _reduction_variance(root, ref) + if variance is not None: + uns["pca"] = variance + elif ref.kind == "imported_coordinates": + stdev = _imported_stdev(root, ref) + if stdev is not None: + uns["pca"] = {"variance": stdev**2} + obsp: dict[str, Callable[[], csr_matrix]] = {} + if graph is not None: + from ..graph.feature_projection import graph_cell_selection + + rows.same_cells("graph", graph, graph_cell_selection(root, graph)) + _require_graph_memory(root, graph, max_bytes) + matrices = _LazyGraph(root, graph) + obsp["connectivities"] = matrices.connectivities + neighbors: dict[str, Any] = {"connectivities_key": "connectivities"} + if graph.kind == "connectivity_map": + obsp["distances"] = matrices.distances + neighbors["distances_key"] = "distances" + coordinates = _graph_coordinates(root, graph) + represented = { + **(exported or {}), + **{name: ref for name, (ref, value) in sources.items() if value is None}, + } + use_rep = next( + (name for name, ref in represented.items() if ref == coordinates), None + ) + neighbors["params"] = _graph_parameters(root, graph, use_rep) + uns["neighbors"] = neighbors + lineage["obsp"] = {"connectivities": _lineage_record(graph, None)} + if lineage: + uns["cytearc"] = {"artifacts": lineage} + return H5adArtifactEntries( + obsm=obsm_entries, + obs=tuple(obs_columns), + obsp=obsp, + varm=varm, + uns=uns, + ) + + +def _lineage_record(ref: ArtifactRef, value: str | None) -> dict[str, str]: + record = ref.to_dict() + if value is not None: + record["value"] = value + return record + + +def _reduction_variance( + root: "zarr.Group", ref: ArtifactRef +) -> dict[str, np.ndarray] | None: + """Return a PCA's stored variance and variance ratio, or None without them. + + PCA artifacts store both from release 1.3.0; earlier ones store neither. + """ + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + group = artifact_group(root, ref) + if "variance" not in group or "variance_ratio" not in group: + return None + return { + name: np.asarray(as_zarr_array(group[name], name=name)[:]) + for name in ("variance", "variance_ratio") + } + + +def _imported_stdev(root: "zarr.Group", ref: ArtifactRef) -> np.ndarray | None: + from ..storage.artifacts import artifact_group + from ..storage.types import as_zarr_array + + group = artifact_group(root, ref) + if "stdev" not in group: + return None + return np.asarray(as_zarr_array(group["stdev"], name="stdev")[:]) + + +def _graph_coordinates(root: "zarr.Group", graph: ArtifactRef) -> ArtifactRef | None: + """Return the coordinates that a connectivity map was built on.""" + from ..graph.feature_projection import resolve_native_graph_inputs + + if graph.kind != "connectivity_map": + return None + return resolve_native_graph_inputs(root, graph).coordinates + + +class _LazyGraph: + """Read a graph's matrices once, when the export first writes one.""" + + def __init__(self, root: "zarr.Group", graph: ArtifactRef): + self._args = (root, graph) + self._matrices: tuple[csr_matrix, csr_matrix | None] | None = None + + def _read(self) -> tuple[csr_matrix, csr_matrix | None]: + if self._matrices is None: + self._matrices = _graph_matrices(*self._args) + return self._matrices + + def connectivities(self) -> csr_matrix: + return self._read()[0] + + def distances(self) -> csr_matrix: + distances = self._read()[1] + assert distances is not None + return distances + + +def plain_uns(value: Any) -> Any: + """Return ``uns`` values with every mapping as a plain ``dict``.""" + if isinstance(value, Mapping): + return {str(key): plain_uns(item) for key, item in value.items()} + return value + + +def _exported_obsm(plan: H5adExportPlan) -> dict[str, ArtifactRef]: + """Return the artifact of each ``obsm`` array that a plan already exports. + + Earlier :func:`with_artifacts` calls record them in + ``uns["cytearc"]["artifacts"]["obsm"]``. + """ + records = plan.uns.get("cytearc", {}).get("artifacts", {}).get("obsm", {}) + return { + name: ArtifactRef.from_dict(record) + for name, record in records.items() + if "value" not in record + } + + +def with_artifacts( + plan: H5adExportPlan, + root: "zarr.Group", + *, + cell_rows: np.ndarray, + obsm: Mapping[str, H5adSource] | None = None, + obs: Mapping[str, H5adSource] | None = None, + graph: ArtifactRef | None = None, + max_bytes: int | None = None, +) -> H5adExportPlan: + """Return ``plan`` with the entries of :func:`artifact_entries` added. + + ``cell_rows`` are the store rows of the plan's cells, in their order. A + name that the plan already exports raises ``ValueError``. + """ + feature_ids = np.asarray(_read_column(plan.var_index, plan.x.shape[1])[0]).astype( + str + ) + entries = artifact_entries( + root, + cell_rows=cell_rows, + feature_ids=feature_ids, + obsm=obsm, + obs=obs, + graph=graph, + max_bytes=max_bytes, + exported=_exported_obsm(plan), + ) + planned = {plan.obs_index.name, *(column.name for column in plan.obs)} + for column in entries.obs: + if column.name in planned: + raise ValueError( + f"obs {column.name!r} names a column that the export already holds; " + "choose another name" + ) + for name in entries.obsm: + if name in plan.obsm: + raise ValueError( + f"obsm {name!r} names an array that the export already holds; " + "choose another name" + ) + return replace( + plan, + obs=(*plan.obs, *entries.obs), + obsm={**plan.obsm, **entries.obsm}, + obsp={**plan.obsp, **entries.obsp}, + varm={**plan.varm, **entries.varm}, + uns=_merged(plan.uns, entries.uns), + ) + + +# --------------------------------------------------------------------------- +# Live exports + + def _embedding_groups( columns: list[str], assay_name: str, @@ -608,24 +1622,43 @@ def _embedding_groups( } -def _live_column(table: Any, column: str) -> tuple[np.ndarray, np.ndarray | None]: +def _live_column( + table: Any, + column: str, + rows: np.ndarray | None, +) -> tuple[np.ndarray, np.ndarray | None]: """Read one live metadata column and its linked missing mask.""" mask = metadata_missing_mask(table, column) missing = None if mask is None else np.asarray(mask[:], dtype=bool) - return np.asarray(table.fetch_all(column)), missing + values = np.asarray(table.fetch_all(column)) + if rows is None: + return values, missing + return values[rows], None if missing is None else missing[rows] + + +def live_categorical(table: Any, column: str) -> H5adCategorical | None: + """Return the categorical that a live column's stored attributes record.""" + return stored_categorical(table._get_array(column).attrs) -def _live_embedding(cells: Any, columns: list[str]) -> np.ndarray: +def _live_embedding( + cells: Any, columns: list[str], rows: np.ndarray | None +) -> np.ndarray: """Stack live embedding columns into a cells-by-components array.""" - return np.array([cells.fetch_all(column) for column in columns]).T + stacked = np.array([cells.fetch_all(column) for column in columns]).T + return stacked if rows is None else stacked[rows] -def _live_count_blocks(assay: Any, nthreads: int) -> Iterator[np.ndarray]: - """Stream every row of an assay's raw counts. +def _live_count_blocks( + assay: Any, + nthreads: int, + rows: np.ndarray | None, +) -> Iterator[np.ndarray]: + """Stream the raw counts of an assay's cells, every cell by default. The writer's conversion of each block to CSR is charged as resident. """ - counts = assay.rawData + counts = assay.rawData if rows is None else assay.rawData[rows, :] blocks: Iterator[np.ndarray] = counts._stream_blocks( nthreads=nthreads, msg="Writing raw counts", @@ -654,8 +1687,17 @@ def live_h5ad_plan( embeddings_cols: list[str] | None = None, skip_recalc_nfeats: bool = True, nthreads: int = 4, + *, + cell_rows: np.ndarray | None = None, ) -> H5adExportPlan: - """Plan the H5AD export of a complete assay with its live metadata.""" + """Plan the H5AD export of an assay with its live metadata. + + Every cell is exported, or the cells at store rows ``cell_rows``. A + column that holds a source categorical's ``levels`` exports as that + categorical, with the colors of its ``display``. Column names that + AnnData reserves or that the export already uses are renamed + (:func:`unique_column_names`). + """ from ..metadata.membership import exported_membership membership, omitted = exported_membership(assay.cells, assay.name) @@ -673,42 +1715,81 @@ def live_h5ad_plan( raise ValueError( f"Embedding columns of two prefixes both export as obsm[{name!r}]" ) - obsm[name] = partial(_live_embedding, cells, columns) - - def column(table: Any, key: str, name: str | None = None) -> H5adColumn: - return H5adColumn( - key if name is None else name, partial(_live_column, table, key) + obsm[name] = partial(_live_embedding, cells, columns, cell_rows) + + def planned( + table: Any, + keys: list[str], + names: list[str], + rows: np.ndarray | None, + ) -> tuple[H5adColumn, ...]: + return tuple( + H5adColumn( + name, + partial(_live_column, table, key, rows), + categorical=live_categorical(table, key), + ) + for key, name in zip(keys, names, strict=True) ) + obs_keys = [ + key + for key in cells.columns + if key != "ids" and key not in omitted and key not in embedding_columns + ] + var_keys = [key for key in feats.columns if key != "ids"] + var_names = unique_column_names( + "_index", + ["gene_short_name" if key == "names" else key for key in var_keys], + table="var", + ) + n_cells = int(cells.N) if cell_rows is None else int(np.asarray(cell_rows).size) return H5adExportPlan( x=H5adMatrix( - shape=(int(cells.N), int(feats.N)), + shape=(n_cells, int(feats.N)), dtype=np.dtype(assay.rawData.dtype), - blocks=partial(_live_count_blocks, assay, nthreads), + blocks=partial(_live_count_blocks, assay, nthreads, cell_rows), # A preliminary pass counts the stored values to allocate. reserve=( None - if skip_recalc_nfeats + if skip_recalc_nfeats or cell_rows is not None else partial(_live_count_nonzero, assay, nthreads) ), ), - obs_index=column(cells, "ids", "_index"), - obs=tuple( - column(cells, key) - for key in cells.columns - if key != "ids" and key not in omitted and key not in embedding_columns - ), - var_index=column(feats, "ids", "_index"), - var=tuple( - column(feats, key, "gene_short_name" if key == "names" else None) - for key in feats.columns - if key != "ids" + obs_index=H5adColumn("_index", partial(_live_column, cells, "ids", cell_rows)), + obs=planned( + cells, + obs_keys, + unique_column_names("_index", obs_keys, table="obs"), + cell_rows, ), + var_index=H5adColumn("_index", partial(_live_column, feats, "ids", None)), + var=planned(feats, var_keys, var_names, None), obsm=obsm, membership=membership, ) +def cell_selection_rows(root: Any, selection: ArtifactRef) -> np.ndarray: + """Return the store rows of the cells that a cell selection selects.""" + from ..storage.selections import read_stored_selection_indices + + if not isinstance(selection, ArtifactRef): + raise TypeError("cell_selection must be an ArtifactRef") + if selection.kind != "cell_selection": + raise ValueError( + f"cell_selection must be a cell_selection artifact, not {selection.kind!r}" + ) + return read_stored_selection_indices( + root, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + + def to_h5ad( assay: Any, h5ad_filename: str, @@ -718,13 +1799,25 @@ def to_h5ad( *, run: object | None = None, matrix: Literal["raw", "normed"] = "raw", + cell_selection: ArtifactRef | None = None, + obsm: Mapping[str, H5adSource] | None = None, + obs: Mapping[str, H5adSource] | None = None, + graph: ArtifactRef | None = None, + dtype: Any | None = None, ) -> None: """Save an assay or a completed pipeline run as an H5AD file. + The file is streamed block by block with h5py and needs no AnnData. Rows that a nullable metadata column's linked missing mask flags are written as missing values: NaN in a float64 column for numeric columns, a nullable boolean for boolean columns, and a missing category for other - columns. + columns. A column that keeps a source categorical's categories, as H5AD + and Seurat imports store them, is written as that categorical, with its + category order, unused categories, ``ordered`` flag, and colors in + ``uns["_colors"]``. A column that AnnData reserves, ``_index``, + exports as ``source_index``, and a column whose name the export already + uses, such as a ``gene_short_name`` beside the feature names, takes the + next free ``_2`` name, with a warning. Args: assay: Assay to save in H5ad format @@ -737,29 +1830,75 @@ def to_h5ad( nthreads: Number of processing threads to use (Default value: 4) run: Completed pipeline run opened from the datastore that owns ``assay``. The frozen run selections and fields are exported. - Consecutive frozen UMAP fields are already in ``obsm['X_umap']`` - as in ``to_anndata(run=...)``; cluster labels remain in - ``obs['clusters']``. Live embedding columns and feature-count + Consecutive frozen UMAP fields are in ``obsm['X_umap']`` and + the run's PCA in ``obsm['X_pca']``, as in + ``to_anndata(run=...)``; cluster labels (``clusters``, + ``leiden_*``, ``paris``) are categoricals in ``obs``. Live + embedding columns, a cell selection, and feature-count recalculation options do not apply to run export. - matrix: ``"raw"`` counts, or ``"normed"`` values, which require ``run``. + matrix: ``"raw"`` counts, or ``"normed"`` values, which require + ``run``: the run's stored normalized values over its highly + variable features. + cell_selection: Cell-selection artifact whose cells are exported, in + store order. None exports every cell of the assay. + obsm: Artifacts to export as ``obsm`` arrays, such as + ``{"X_pca": pca, "X_umap": umap}``. + obs: Cell-aligned artifacts to export as ``obs`` columns, such as + ``{"leiden": leiden}``; labels become categoricals. + graph: Connectivity map or integrated graph to export as + ``obsp["connectivities"]`` and ``obsp["distances"]`` with + ``uns["neighbors"]``, over exactly the exported cells. + dtype: Dtype of the exported raw counts, in ``X`` and every layer. + None keeps the stored dtype, except that ``uint8`` and + ``uint16`` counts become ``float32``. A count that the dtype + cannot hold raises ``OverflowError``. Returns: None + + Every artifact is resolved and aligned to the exported cells before + anything is written, so a misaligned artifact raises ``ValueError`` and + leaves no file. """ if matrix not in ("raw", "normed"): raise ValueError("matrix must be either 'raw' or 'normed'") + if dtype is not None: + export_count_dtype(None, dtype) if run is None: if matrix != "raw": raise ValueError( "matrix='normed' requires run: only a pipeline run freezes " "normalized values" ) - write_h5ad_plan( - live_h5ad_plan(assay, embeddings_cols, skip_recalc_nfeats, nthreads), - h5ad_filename, + cell_rows = ( + None + if cell_selection is None + else cell_selection_rows(assay._artifact_root, cell_selection) + ) + plan = live_h5ad_plan( + assay, + embeddings_cols, + skip_recalc_nfeats, + nthreads, + cell_rows=cell_rows, ) + if obsm is not None or obs is not None or graph is not None: + plan = with_artifacts( + plan, + assay._artifact_root, + cell_rows=( + np.arange(int(assay.cells.N), dtype=np.int64) + if cell_rows is None + else cell_rows + ), + obsm=obsm, + obs=obs, + graph=graph, + max_bytes=int(assay.resources.memoryBytes), + ) + write_h5ad_plan(with_count_dtype(plan, dtype, raw_x=True), h5ad_filename) logger.info( - f"Exported {assay.cells.N} cells and {assay.feats.N} features " + f"Exported {plan.x.shape[0]} cells and {assay.feats.N} features " f"to {h5ad_filename}" ) return None @@ -793,9 +1932,24 @@ def to_h5ad( "Run-aware export uses the datastore execution settings; " "nthreads cannot be overridden" ) + if cell_selection is not None: + raise ValueError( + "Run-aware export uses the frozen run cells; cell_selection cannot " + "be provided" + ) plan = run_plan(pipeline_run, matrix=matrix) - write_h5ad_plan(plan, h5ad_filename) + if obsm is not None or obs is not None or graph is not None: + plan = with_artifacts( + plan, + owner.zw, + cell_rows=np.flatnonzero(pipeline_run.cells.fetch_all("I")), + obsm=obsm, + obs=obs, + graph=graph, + max_bytes=int(assay.resources.memoryBytes), + ) + write_h5ad_plan(with_count_dtype(plan, dtype, raw_x=matrix == "raw"), h5ad_filename) n_cells, n_features = plan.x.shape logger.info( f"Exported pipeline run {pipeline_run.run_id} with {n_cells} cells and " @@ -804,14 +1958,120 @@ def to_h5ad( return None +# --------------------------------------------------------------------------- +# Matrix Market + + +def _digit_counts(values: np.ndarray) -> np.ndarray: + """Return the number of decimal digits of each non-negative integer.""" + width = np.ones(values.shape, dtype=np.int64) + alive = np.flatnonzero(values >= 10) + rest: Any = values[alive] // 10 + while alive.size: + width[alive] += 1 + rest //= 10 + keep = rest > 0 + alive = alive[keep] + rest = rest[keep] + return width + + +def _integer_lines(columns: Sequence[np.ndarray]) -> bytes: + """Return rows of non-negative integer columns as space-separated text lines. + + The digits are computed with NumPy, so the text equals what Python's + ``str`` writes for each value, and each line ends with a newline. + """ + n_rows = int(columns[0].size) + if n_rows == 0: + return b"" + values = [ + np.asarray(column).astype( + np.uint32 if int(np.max(column)) <= np.iinfo(np.uint32).max else np.uint64, + copy=False, + ) + for column in columns + ] + widths = [_digit_counts(column) for column in values] + lengths = np.add.reduce(widths) + len(columns) + ends = np.cumsum(lengths) + text = np.empty(int(ends[-1]), dtype=np.uint8) + position = ends - lengths + for index, (column, width) in enumerate(zip(values, widths, strict=True)): + # Digits are written from the last to the first, while any remain. + last = position + width - 1 + alive = np.arange(n_rows) + rest: Any = column.copy() + while alive.size: + text[last[alive]] = (rest % 10).astype(np.uint8) + ord("0") + last[alive] -= 1 + rest //= 10 + keep = rest > 0 + alive = alive[keep] + rest = rest[keep] + position = position + width + text[position] = ord(" ") if index < len(columns) - 1 else ord("\n") + position += 1 + return text.tobytes() + + +def _matrix_market_lines( + features: np.ndarray, + cells: np.ndarray, + values: np.ndarray, +) -> bytes: + """Return Matrix Market coordinate lines, ``feature cell value``, one-based. + + Integer values are formatted with NumPy; other values as ``str`` of the + value in its dtype, as pandas writes them. + """ + if values.dtype.kind in "biu": + return _integer_lines([features, cells, values.astype(np.uint64)]) + text = values.astype(str).tolist() + return "".join( + f"{feature} {cell} {value}\n" + for feature, cell, value in zip( + features.tolist(), cells.tolist(), text, strict=True + ) + ).encode("utf-8") + + +def _assay_feature_type(assay: Any, default: str) -> str: + """Return the feature type of an exported assay. + + It is the value of the assay's ``feature_type`` column when every + feature holds it, and ``default`` otherwise. + """ + if "feature_type" not in assay.feats.columns: + return default + stored = np.unique(np.asarray(assay.feats.fetch_all("feature_type")).astype(str)) + if stored.size == 1: + return str(stored[0]) + logger.warning( + f"Assay {assay.name!r} holds {stored.size} feature types; the Matrix Market " + f"export writes {default!r} for every feature" + ) + return default + + def to_mtx(assay: Any, mtx_directory: str, compress: bool = False) -> None: """Save an assay as a Matrix Market directory. + The features file holds a feature ID, name, and type column, as Cell + Ranger 3 writes them, whether or not it is compressed, so an import + keeps the assay's type: ``features.tsv.gz`` with ``compress=True``, and + ``genes.tsv``, the Cell Ranger 2 name, without compression. Every + feature takes one type: the assay's ``feature_type`` column when every + feature holds the same value, such as ``Antibody Capture``, else + ``Gene Expression`` for an RNA assay and the assay name for another, so + gene biotypes that CELLxGENE keeps in ``feature_type`` do not split the + assay on import. Compressed files use gzip level 6. + Args: assay: CyteArc assay. For example: `ds.RNA` mtx_directory: Out directory where MTX file will be saved along with barcodes and features file compress: If True, then the files are compressed and saved with .gz extension, using Cell Ranger 3 - names; ``features.tsv.gz`` then also holds a feature-type column. (Default value: False). + names. (Default value: False). Returns: None @@ -819,11 +2079,6 @@ def to_mtx(assay: Any, mtx_directory: str, compress: bool = False) -> None: Raises: UnmeasuredCellsError: If the assay did not measure a cell. """ - import gzip - - import pandas as pd - from scipy.sparse import coo_matrix - from ..assay.classification import is_rna_assay_type from ..metadata.membership import require_measured_cells @@ -855,47 +2110,41 @@ def to_mtx(assay: Any, mtx_directory: str, compress: bool = False) -> None: numeric_type = ( "integer" if np.issubdtype(assay.rawData.dtype, np.integer) else "real" ) - with gzip.open(matrix_path, "wt") if compress else open(matrix_path, "w") as handle: + with ( + gzip.open(matrix_path, "wb", compresslevel=6) + if compress + else open(matrix_path, "wb") + ) as handle: handle.write( f"%%MatrixMarket matrix coordinate {numeric_type} general\n" "% Generated by CyteArc\n" + f"{assay.feats.N} {assay.cells.N} {tot_counts}\n".encode() ) - handle.write(f"{assay.feats.N} {assay.cells.N} {tot_counts}\n") - s = 0 + start = 0 for values in assay.rawData.stream_blocks( nthreads=assay.nthreads, msg="Writing Matrix Market counts", ): - block = coo_matrix(values) - df = pd.DataFrame( - { - "col": block.col + 1, - "row": block.row + s + 1, - "d": block.data, - } - ) - df.to_csv( - handle, - sep=" ", - header=False, - index=False, - mode="a", - lineterminator="\n", + block = np.asarray(values) + rows, columns = np.nonzero(block) + handle.write( + _matrix_market_lines( + columns.astype(np.int64) + 1, + rows.astype(np.int64) + start + 1, + block[rows, columns], + ) ) - s += block.shape[0] + start += block.shape[0] assay.cells.to_pandas_dataframe(["ids"]).to_csv( os.path.join(mtx_directory, barcodes_fn), sep="\t", header=False, index=False ) features = assay.feats.to_pandas_dataframe(["ids", "names"]) - if compress: - # Cell Ranger 3 feature files carry a third feature-type column. - if "feature_type" in assay.feats.columns: - features["feature_type"] = assay.feats.fetch_all("feature_type") - else: - features["feature_type"] = ( - "Gene Expression" if is_rna_assay_type(assay) else assay.name - ) + # Cell Ranger 3 feature files carry a third feature-type column, which + # holds the one type of the exported assay. + features["feature_type"] = _assay_feature_type( + assay, "Gene Expression" if is_rna_assay_type(assay) else assay.name + ) features.to_csv( os.path.join(mtx_directory, features_fn), sep="\t", header=False, index=False ) diff --git a/cytearc/writers/h5ad.py b/cytearc/writers/h5ad.py index 42cb81b..6938461 100644 --- a/cytearc/writers/h5ad.py +++ b/cytearc/writers/h5ad.py @@ -9,7 +9,7 @@ import numpy as np from scipy.sparse import coo_matrix -from ..storage.types import as_zarr_group +from ..storage.types import as_zarr_array, as_zarr_group from ..readers import H5adReader from ..readers.h5ad import _H5adAssayFeatures from ..storage import ArtifactRef @@ -27,6 +27,106 @@ _SOURCE_DIGEST_BLOCK_BYTES = 1024 * 1024 +# Categories longer than this list are not stored with their column: AnnData +# makes a categorical of any text that repeats a value, so a column of nearly +# unique text can hold about one category per row. +_MAX_STORED_CATEGORIES = 10_000 +_MISSING_DISPLAY = {"missing_label": "NA", "missing_color": "#bdbdbd"} + + +def _stored_levels(dtype: np.dtype[Any], categories: Sequence[Any]) -> list[Any] | None: + """Return categories as the stored column holds its values, or None. + + A text column holds text, and a boolean, integer, or float column holds + categories of that type. Categories that the column's type cannot hold + exactly, such as text categories of a numeric column, return None. + """ + if dtype.kind == "U": + return [ + category.decode("utf-8") if isinstance(category, bytes) else str(category) + for category in categories + ] + checks = { + "b": lambda value: isinstance(value, bool), + "i": lambda value: isinstance(value, int) and not isinstance(value, bool), + "f": lambda value: ( + isinstance(value, int | float) + and not isinstance(value, bool) + and math.isfinite(value) + ), + } + check = checks.get("i" if dtype.kind == "u" else dtype.kind) + if check is None or not all(check(category) for category in categories): + return None + if dtype.kind == "f": + return [float(category) for category in categories] + return list(categories) + + +def _categorical_display( + levels: list[Any], + colors: tuple[str, ...] | None, + has_missing: bool, +) -> dict[str, Any] | None: + """Return the categorical display contract of levels in their order. + + The categories keep the stored order, so plots show them in it. Colors + come from the file, or else from the palette of + :func:`~cytearc.metadata.artifacts.categorical_display`. + """ + from ..metadata.artifacts import categorical_display, validate_display_metadata + + if colors is None: + generated = { + category["value"]: category["color"] + for category in categorical_display(np.asarray(levels, dtype=object))[ + "categories" + ] + } + colors = tuple(generated[level] for level in levels) + display: dict[str, Any] = { + "kind": "categorical", + "categories": [ + {"value": level, "label": str(level), "color": color} + for level, color in zip(levels, colors, strict=True) + ], + } + if has_missing: + display.update(_MISSING_DISPLAY) + try: + return validate_display_metadata(display) + except (TypeError, ValueError): + return None + + +def _categorical_attributes( + dtype: np.dtype[Any], + categories: Any, + *, + colors: tuple[str, ...] | None, + has_missing: bool, + display: bool, +) -> dict[str, Any]: + """Return the attributes that keep a source categorical with its column. + + ``levels`` holds every category in its source order, unused ones + included, ``ordered`` whether they are ordered, and for cell columns + ``display`` the categorical display contract that plots honor, with the + source colors when the file holds them. A categorical whose categories + the column cannot hold, or that has more than 10,000 categories, gets no + attributes. + """ + if len(categories.values) > _MAX_STORED_CATEGORIES: + return {} + levels = _stored_levels(dtype, categories.values) + if levels is None: + return {} + attributes: dict[str, Any] = {"levels": levels, "ordered": categories.ordered} + if display: + contract = _categorical_display(levels, colors, has_missing) + if contract is not None: + attributes["display"] = contract + return attributes def _count_matrix_bands( @@ -351,7 +451,10 @@ class H5adImportResult: assayNames: Assay groups written to the destination store. analysisAssay: Assay owning explicitly selected analytical outputs. cellSelection: Artifact covering the imported cell axis. - embeddingArtifacts: Embedding artifacts keyed by exact ``obsm`` key. + embeddingArtifacts: Artifacts of the selected ``obsm`` arrays keyed by + exact ``obsm`` key: ``embedding`` artifacts for the ``"umap"`` and + ``"tsne"`` roles, and ``imported_coordinates`` artifacts, which + ``graph.ann_index`` accepts, for the ``"coordinates"`` role. clusterArtifacts: Cluster artifacts keyed by exact ``obs`` key. """ @@ -644,10 +747,10 @@ def _ini_cell_data(self) -> None: self.h5ad._source_column_names(self.h5ad.cellAttrsKey), taken=[*g.keys(), *reserved], ) - for key, (values, missing) in keyed_metadata_columns( + for key, (name, values, missing, categories) in keyed_metadata_columns( ( - (name, (values, missing)) - for name, values, missing in self.h5ad._cell_columns() + (name, (name, values, missing, categories)) + for name, values, missing, categories in self.h5ad._cell_columns() if name not in restored ), keys, @@ -655,9 +758,37 @@ def _ini_cell_data(self) -> None: membership=reserved, ): write_metadata_column(g, key, values, missing, profile=self.profile) + if categories is not None: + self._keep_categories( + as_zarr_array(g[key], name=key), + categories, + colors=self.h5ad._category_colors(name, categories), + missing=missing, + display=True, + ) for assay, members in self._membership.items(): write_membership_column(g, assay, members, profile=self.profile) + @staticmethod + def _keep_categories( + array: Any, + categories: Any, + *, + colors: tuple[str, ...] | None, + missing: np.ndarray, + display: bool, + ) -> None: + """Record a source categorical's categories on its stored column.""" + array.attrs.update( + _categorical_attributes( + np.dtype(array.dtype), + categories, + colors=colors, + has_missing=bool(np.asarray(missing, dtype=bool).any()), + display=display, + ) + ) + def _ini_feature_data(self) -> None: from ..storage.metadata_keys import metadata_column_keys from ._store import keyed_metadata_columns, write_metadata_column @@ -682,10 +813,10 @@ def _ini_feature_data(self) -> None: ) # Stream one column at a time so a single decoded var column is held in # memory rather than every column for the full feature axis at once. - for column_name, (values, missing) in keyed_metadata_columns( + for column_name, (values, missing, categories) in keyed_metadata_columns( ( - (name, (values, missing)) - for name, values, missing in self.h5ad._feature_columns() + (name, (values, missing, categories)) + for name, values, missing, categories in self.h5ad._feature_columns() ), keys, "feature", @@ -703,6 +834,14 @@ def _ini_feature_data(self) -> None: selected_missing, profile=self.profile, ) + if categories is not None: + self._keep_categories( + as_zarr_array(feat_group[column_name], name=column_name), + categories, + colors=None, + missing=selected_missing, + display=False, + ) def dump(self, batch_size: int | None = None) -> H5adImportResult: """Write h5ad matrix data into Zarr ``counts`` and RNA ``countsT``. @@ -777,7 +916,10 @@ def _write_embedding_artifacts( cell_selection: ArtifactRef, requested_rows: int | None, ) -> dict[str, ArtifactRef]: - from ..embeddings.imported import write_imported_embedding + from ..embeddings.imported import ( + write_imported_coordinates, + write_imported_embedding, + ) if not self.h5ad.embeddingRoles: return {} @@ -814,6 +956,24 @@ def blocks( resolved_dtype, ) + if role == "coordinates": + # Graph coordinates, such as a scVI latent space, which graph + # construction, embeddings, and mapping accept. + artifacts[key] = write_imported_coordinates( + self.root, + assay=self.analysisAssay, + dimreduc_key=key, + role=role, + coordinates=blocks, + coordinate_shape=source_shape, + coordinate_dtype=dtype, + source_digest=self._sourceDigest, + payload_fingerprints={"data": fingerprint}, + source_cell_ids=cell_ids, + cell_selection=cell_selection, + block_rows=block_rows, + ) + continue artifacts[key] = write_imported_embedding( self.root, assay=self.analysisAssay, @@ -985,7 +1145,7 @@ def blocks() -> Iterator[MetadataBlock]: missing if has_missing else None, ) - create_streamed_metadata_column( + values = create_streamed_metadata_column( group, "values", shape=self.h5ad.nCells, @@ -998,6 +1158,19 @@ def blocks() -> Iterator[MetadataBlock]: hasMissing=has_missing, profile=self.profile, ) + categories = self.h5ad._cell_column_categories(key) + if categories is not None: + # The labels keep their source categories, order, and + # colors, which exports and plots read. + values.attrs.update( + _categorical_attributes( + dtype, + categories, + colors=self.h5ad._category_colors(key, categories), + has_missing=has_missing, + display=True, + ) + ) artifacts[key] = planned.ref return artifacts diff --git a/cytearc/writers/sparse.py b/cytearc/writers/sparse.py index d9c4bc8..df14c92 100644 --- a/cytearc/writers/sparse.py +++ b/cytearc/writers/sparse.py @@ -2,7 +2,7 @@ from typing import Any import numpy as np -from scipy.sparse import coo_matrix, csr_matrix +from scipy.sparse import coo_matrix, csr_matrix, issparse from ..storage.count_dtype import count_storage_dtype from ..storage.count_matrix import DEFAULT_COUNT_MATRIX_POLICY, CountMatrixPolicy @@ -14,19 +14,48 @@ resolve_storage_profile, ) from ..storage.sharding import accumulate_sparse_to_shards -from ..utils.arrays import max_window_nnz, sparse_matrix_bytes +from ..utils.arrays import has_duplicates, max_window_nnz, sparse_matrix_bytes from ..utils.count_values import compressed_count_ranges from ..utils.logging import logger +from ._store import decode_text + + +def _identifiers(values: Any, label: str) -> np.ndarray: + """Return identifiers as text after rejecting missing, blank, and repeated ones. + + Byte strings are decoded as UTF-8, as every reader decodes them. + """ + array = np.asarray(values, dtype=object) + if array.ndim != 1: + raise ValueError(f"{label} must be one-dimensional") + texts = np.asarray( + [ + "" + if value is None or (isinstance(value, float) and value != value) + else (decode_text(value) if isinstance(value, str | bytes) else str(value)) + for value in array + ], + dtype=str, + ) + if texts.size and bool(np.any(np.char.strip(texts) == "")): + raise ValueError(f"{label} must contain non-empty values") + if has_duplicates(texts): + raise ValueError(f"{label} must contain unique values") + return texts class SparseToZarr: """A class for converting data in a sparse matrix to a Zarr hierarchy. Args: - csr_mat: A CSR format sparse matrix + csr_mat: A cells-by-features matrix: a SciPy sparse matrix or array of + any format, or a dense two-dimensional array. Other formats + are converted to CSR; a CSR matrix is used as given. zarr_loc: Output Zarr filename with path - cell_ids: Cell IDs for the cells in the dataset. - feature_ids: Feature IDs for the features in the dataset. + cell_ids: Cell IDs for the cells in the dataset. They must be present + and unique, as every reader requires. + feature_ids: Feature IDs for the features in the dataset. They must be + present and unique. assay_name: Name for the output assay. If not provided then automatically set to RNA. workspace: Workspace name in the destination store. None uses the legacy layout without a workspace group. @@ -53,7 +82,8 @@ class SparseToZarr: Raises: ValueError: Raised if number of input cell or feature IDs does not match - the matrix, or if a count is NaN or infinite. + the matrix, if an ID is missing, blank, or repeated, if the matrix + is not two-dimensional, or if a count is NaN or infinite. FileExistsError: If ``zarr_loc`` is not empty and may not be replaced. Attributes: @@ -93,7 +123,11 @@ def __init__( validate_assay_type( assay_type, assay="RNA" if assay_name is None else assay_name ) - self.mat = csr_mat + if not issparse(csr_mat) and np.ndim(csr_mat) != 2: + raise ValueError("The count matrix must be two-dimensional") + # COO, CSC, and dense inputs become CSR, which the scan and the row + # batches read; a CSR matrix is used as given, without a copy. + self.mat = csr_mat if isinstance(csr_mat, csr_matrix) else csr_matrix(csr_mat) self.assayType = assay_type self.resources = resolve_budget(mem_budget, nthreads) self.profile = resolve_storage_profile(zarr_loc, profile) @@ -116,6 +150,8 @@ def __init__( raise ValueError( "ERROR: Number of feature ids are not same as number of features in the matrix" ) + _identifiers(cell_ids, "Cell IDs") + _identifiers(feature_ids, "Feature IDs") # The scan reads windows beside the matrix it holds. held = sparse_matrix_bytes(self.mat) if held >= self.resources.memoryBytes: diff --git a/docs/source/developers/architecture.md b/docs/source/developers/architecture.md index b7bb0dc..ad50320 100644 --- a/docs/source/developers/architecture.md +++ b/docs/source/developers/architecture.md @@ -1247,6 +1247,36 @@ CyteArc validates persisted data and public arguments against the following cont - `qc.feature_percentage` reads only the selected features and divides by the prepared `_nCounts` instead of summing every count of each cell (see {ref}`stable_identity_result_changes`). +- H5AD categoricals keep their categories: `H5adToZarr` stores `levels`, `ordered`, and, for cell + columns, a categorical `display` with the colors of `uns["_colors"]` on each imported + categorical column of at most 10,000 categories and on imported cluster labels. Exports write + such a column as that categorical, with its colors in `uns`. Earlier imports stored plain values + and are not migrated; they export as before. +- A dataframe index that is neither the ID nor the name column no longer imports as a column named + `_index`: it is left out when it repeats the IDs or names, and imports as `source_index` + otherwise. Exports rename a store's `_index` column to `source_index` and a column whose name the + export already uses to `_2`, with a warning, instead of raising; stores are not changed. +- `inspect_h5ad` ranks count matrices by conventional name (`raw/X`, `layers/counts`, + `layers/raw_counts`, then `X`, `layers/spliced`, others, and `unspliced` and `ambiguous` last), + then by estimated total, samples up to 32 windows of 1,024 values across a matrix to decide + whether it holds counts, which must be finite, non-negative, and whole, and warns when several + matrices hold counts. It never takes a floating-point column as cell IDs, and when the obs + index repeats IDs it says so and names `duplicate_cell_ids="make_unique"`, a new keyword of + `H5adReader`. A missing feature name takes the feature ID. +- `embedding_roles` accepts `"coordinates"`, which imports an `obsm` array as + `imported_coordinates`, and `DataStore.reduction.import_coordinates` imports an array or a + DataFrame aligned by cell ID, recording an optional `source` record in its parameters. + `write_imported_coordinates` records `source` only when given, so Seurat and earlier + imported coordinates keep their identities. +- `to_anndata` and `to_h5ad` take `cell_selection`, `obsm`, `obs`, `graph`, and `dtype`. Run + exports write label fields with a categorical display (`clusters`, `leiden_*`, `paris`, + `cell_cycle_phase`) as categoricals of string labels in natural order, with the display colors, + where they wrote numbers before, and add the run's PCA as `obsm["X_pca"]` with `varm["PCs"]`. + Raw `uint8` and `uint16` counts export as `float32` unless `dtype` names another dtype. +- `to_mtx` writes a feature-type column without compression too, one type per assay, formats + integer counts with NumPy, and compresses with gzip level 6; the matrix text is unchanged. +- `CSVReader` reads its ID column as text and rejects a missing ID. `SparseToZarr` rejects missing + or repeated cell and feature IDs and converts COO, CSC, and dense input to CSR. Compatibility exists only where a current public facade or an explicit file-schema test says it does. There are no silent migrations, implicit compatibility branches, or forwarding shims for diff --git a/docs/source/reference/api/graph_construction.md b/docs/source/reference/api/graph_construction.md index f3fce8c..713a626 100644 --- a/docs/source/reference/api/graph_construction.md +++ b/docs/source/reference/api/graph_construction.md @@ -35,7 +35,9 @@ The full stage order is: 1. {py:meth}`~cytearc.DataStore.snapshot_cell_selection` and an exact feature-selection ref 2. {py:meth}`~cytearc.datastore.namespaces.FeaturesAccessor.normalize` 3. {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.pca`, {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.lsi`, or - {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.custom`, or none + {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.custom`, or none; or coordinates + computed elsewhere through + {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.import_coordinates` 4. Optional {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.harmony` 5. {py:meth}`~cytearc.datastore.namespaces.GraphAccessor.ann_index` 6. {py:meth}`~cytearc.datastore.namespaces.GraphAccessor.neighbors` @@ -71,6 +73,32 @@ supplied, but is not part of connectivity construction. `graph.neighbors` reads the coordinate ref named by the ANN artifact. Its optional `coordinates=` argument is only an equality check; it cannot redirect an index to different coordinates. +## External coordinates + +A latent space computed by another tool, such as scVI's `X_scVI` or a Harmony correction saved +as `X_pca_harmony`, becomes graph coordinates through +{py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.import_coordinates`. Pass an array with +one row per cell of the selection, in store order, or a pandas DataFrame indexed by cell ID, which +is aligned to the selected cells by ID: + +```python +cells = ds.snapshot_cell_selection("I") +latent = ds.reduction.import_coordinates( + adata.obsm["X_scVI"], + cell_selection=cells, + name="X_scVI", + source={"tool": "scvi-tools", "version": "1.2.0", "parameters": {"n_latent": 30}}, +) +ann = ds.graph.ann_index(latent) +graph = ds.graph.connectivity(ds.graph.neighbors(ann, k=11)) +``` + +The result is an `imported_coordinates` artifact. Its identity records the name, the `source` +record, a fingerprint of the values, and a fingerprint of the ordered cell IDs, so importing the +same values again reuses it and `ds.artifacts.lineage` shows where the coordinates came from. +`H5adReader` imports such an `obsm` array in the same form when its role is `"coordinates"`, as in +`embedding_roles={"X_scVI": "coordinates"}`. + ## Downstream consumers UMAP, t-SNE, clustering, diffusion, trajectories, graph metrics, and graph-backed plots @@ -108,6 +136,7 @@ no such selection; integrated graphs preserve the ordered projections of their e cytearc.datastore.namespaces.ReductionAccessor.lsi cytearc.datastore.namespaces.ReductionAccessor.custom cytearc.datastore.namespaces.ReductionAccessor.harmony + cytearc.datastore.namespaces.ReductionAccessor.import_coordinates cytearc.datastore.namespaces.GraphAccessor.ann_index cytearc.datastore.namespaces.GraphAccessor.neighbors cytearc.datastore.namespaces.GraphAccessor.connectivity @@ -122,6 +151,7 @@ no such selection; integrated graphs preserve the ordered projections of their e .. automethod:: cytearc.datastore.namespaces.ReductionAccessor.lsi .. automethod:: cytearc.datastore.namespaces.ReductionAccessor.custom .. automethod:: cytearc.datastore.namespaces.ReductionAccessor.harmony +.. automethod:: cytearc.datastore.namespaces.ReductionAccessor.import_coordinates .. automethod:: cytearc.datastore.namespaces.GraphAccessor.ann_index .. automethod:: cytearc.datastore.namespaces.GraphAccessor.neighbors .. automethod:: cytearc.datastore.namespaces.GraphAccessor.connectivity diff --git a/docs/source/reference/api/import_export.md b/docs/source/reference/api/import_export.md index ec66bf7..b29fd8c 100644 --- a/docs/source/reference/api/import_export.md +++ b/docs/source/reference/api/import_export.md @@ -43,6 +43,27 @@ myst: ## H5AD inspection Use this before `H5adReader` when you do not know which matrix and metadata keys an H5AD file uses. +Review the result before importing: `matrixKey` names the matrix that will become the counts, and +`matrixCandidates` lists every candidate in rank order. + +`inspect_h5ad` ranks the candidate matrices so that counts come first. A matrix holds counts when a +sample of its stored values, up to 32 windows of 1,024 values spread over the whole matrix, is +finite, non-negative, and whole. Among counts, a conventional name wins: `raw/X`, `layers/counts`, +and `layers/raw_counts`, then `X`, then `layers/spliced`, then other matrices, and the `unspliced` +and `ambiguous` layers of velocity data last. Matrices of one tier rank by their total, estimated +from the same sample, so the one that holds the most reads wins. When other matrices of the same +cells also hold counts, a warning lists them with their estimated totals; pass `matrix_key` to +choose another. A velocity file whose `X`, `spliced`, and `unspliced` are normalized imports its +`matrix` layer, the total counts, rather than its `ambiguous` reads. + +The cell IDs are the `obs` index when it is unique, else a preferred ID column such as `cell_id` or +`barcode` that holds unique text or integers, else the first unique text column. A column of +distinct floating-point values, such as a score, is never chosen. When no column holds unique IDs, +`cellIdsKey` names the index anyway and a warning says that its IDs repeat: such a file, as +`anndata.concat` writes without `index_unique`, imports with +`H5adReader.from_inspect(inspection, duplicate_cell_ids="make_unique")`, which renames each later +occurrence of a repeated ID by appending `-1`, `-2`, and so on, as AnnData's +`obs_names_make_unique` does. Only a file without an index gets generated IDs. `H5adReader` checks the matrix shape against the lengths of `obs` and `var` when it is constructed, and a mismatch raises `ValueError`. Overriding `matrix_key`, for example with @@ -58,9 +79,29 @@ Unsupported group encodings and multi-dimensional columns are skipped with a war `H5adToZarr` keeps missing rows under a linked missing mask and stores nullable booleans as booleans. +Categorical columns keep their categories. `H5adToZarr` stores, with each imported categorical +column of `obs` or `var` that has at most 10,000 categories, a `levels` attribute that lists every +category in its order, unused ones included, and an `ordered` attribute, as `SeuratToZarr` stores +factor levels. A cell column also gets a categorical `display` attribute in that order, with the +colors of `uns["_colors"]` when the file holds a color for each category, or else +CyteArc's palette; plots of the column show its categories in that order and colors. Exports write +such a column as the same categorical. Imported cluster labels keep the same attributes on their +`values` array. + +A dataframe index that is neither the ID nor the name column imports as metadata only when it holds +other values: an index that repeats the IDs or the names is left out, and AnnData's unnamed index, +which no AnnData column may be named, imports as `source_index`, or `source_index_2` and so on when +that name is taken. A missing or blank feature name, such as a missing category, takes the +feature's ID as the file holds it, with a warning. + Pass `embedding_roles` and `cluster_keys` to select analytical H5AD values for artifact import. Selected `obsm` arrays and cluster labels are excluded from live metadata and returned as exact -refs by `H5adToZarr.dump()`. Other supported `obs` columns remain literal metadata. +refs by `H5adToZarr.dump()`. Other supported `obs` columns remain literal metadata. The role +`"umap"` or `"tsne"` makes an `embedding` artifact, and `"coordinates"` an `imported_coordinates` +artifact, such as of `X_pca`, `X_scVI`, or `X_pca_harmony`, which `graph.ann_index`, embedding +initialization, and mapping accept as they accept a PCA. Coordinates computed after an import enter +a store through {py:meth}`~cytearc.datastore.namespaces.ReductionAccessor.import_coordinates` +({doc}`graph_construction`). CSC input is converted to temporary row storage on local disk. Pass `temp_dir` to `H5adReader` or `H5adReader.from_inspect` to choose its parent directory; the default uses @@ -212,7 +253,11 @@ whose name contains either one under the name with `_` in their place and log th source name that is already valid keeps its name; a renamed column whose name is taken gets the first free `_2`, `_3`, and so on. `SeuratToZarr` raises for reserved names instead of skipping them. -The H5AD, CSV, Matrix Market, and Cell Ranger readers reject missing or repeated cell IDs. All +The H5AD, CSV, Matrix Market, and Cell Ranger readers reject missing or repeated cell IDs, and so +does `SparseToZarr`, which also rejects missing or repeated feature IDs and converts a COO, CSC, or +dense matrix to CSR before it scans it. `CSVReader` reads its ID column as text, as the file holds +it, so `001` stays `"001"`. `H5adReader` renames repeated cell IDs on request +(`duplicate_cell_ids="make_unique"`). All but the CSV reader also reject missing or repeated feature IDs: `CSVReader` takes its feature IDs from the header as pandas reads it, which names a column with a blank header `Unnamed: ` and renames each later occurrence of a repeated name by appending `.1`, @@ -356,9 +401,13 @@ It keeps the membership column `_I` of each assay it writes, for the sele does not copy the membership column of an assay that `assays=` leaves out ([Assay membership](../developers/zarr-internals#assay-membership)). -Without a `run`, `to_h5ad` and `to_mtx` export a complete assay. +Without a `run`, `to_h5ad` and `to_mtx` export a complete assay; `to_h5ad(..., cell_selection=ref)` +exports the cells of a cell-selection artifact instead. For feature-selective disk export outside a pipeline run, call `to_anndata` and use AnnData's writer. +`to_mtx` writes a feature ID, name, and type column in the features file whether or not it +compresses, `features.tsv.gz` with `compress=True` and `genes.tsv` without, so `CrDirReader` +reads an ADT export back as ADT, and it compresses with gzip level 6. A Matrix Market directory holds no cell metadata, so `to_mtx` cannot say which cells an assay measured, and a cell that it did not measure would be written as a zero row that `MtxToZarr` reads back as a measured cell. `to_mtx` therefore raises @@ -372,13 +421,67 @@ complete matrix, and it converts a dense block to CSR a bounded step of rows at block's stream charges against the memory budget. It writes a temporary file in the same directory and moves it onto the target once complete, so a failed export leaves no partial file and keeps an earlier file of the same name; a replaced file keeps its permissions. Without a `run`, text without missing values is a string array and -both indexes are stored as `_index`. +both indexes are stored as `_index`. A column named `_index`, which AnnData reserves, as stores +imported before this release may hold, exports as `source_index`, and a column whose name the +export already uses, such as a `gene_short_name` beside the feature names that `to_h5ad` writes +under that name, takes the first free `_2`, with a warning; the store is not changed. + +A column that keeps a source categorical's `levels` and `ordered` attributes, as H5AD and Seurat +imports store them, exports as that categorical, with its category order, unused categories, and +`ordered` flag, and the colors of its `display` attribute as `uns["_colors"]` in category +order. A value that the levels do not list is appended to the categories with a warning. + +Exported raw counts take the dtype that `dtype` names. Without it they keep the stored dtype, +except that `uint8` and `uint16` counts become `float32`: arithmetic on narrow unsigned integers +wraps silently, as `X * 300` does in `uint16`, and `numpy.log1p` of `uint8` returns `float16`, +while `float32` holds every such count exactly and is the dtype that Scanpy's readers return. A +count that an explicit dtype cannot hold raises `OverflowError`. Nullable metadata columns keep a stored placeholder in each row that their linked missing mask flags. `to_anndata` and `to_h5ad` export those rows as missing values with or without `run`: numeric columns become float64 with `NaN`, boolean columns become nullable booleans, and other columns hold a missing value, which H5AD files store as a missing category. +### Artifacts in exports + +`to_anndata` and `to_h5ad`, with or without a `run`, export artifacts beside the cells: + +```python +adata = ds.to_anndata( + cell_selection=cells, + obsm={"X_pca": pca, "X_umap": umap}, + obs={"leiden": leiden, "s_score": (cell_cycle, "s_score")}, + graph=graph, +) +cytearc.to_h5ad(ds.RNA, "analysis.h5ad", cell_selection=cells, obsm={"X_pca": pca}) +``` + +`obsm` takes two-dimensional per-cell arrays, such as a `reduction`, a `batch_correction`, +`imported_coordinates`, or an `embedding`. The entry named `X_pca` also writes the loadings of a +PCA as `varm["PCs"]`, with a row per exported feature and zeros for features that the PCA did not +use, as Scanpy stores them; imported coordinates with a `stdev` add `uns["pca"]["variance"]`. +`obs` takes one-dimensional per-cell arrays: labels, such as a clustering, become categoricals of +text labels in natural order, or of the categories and colors that imported labels keep, and +scores stay numbers. A pair `(ref, name)` exports another per-cell array of an artifact. Every +artifact must hold every exported cell; `ValueError` names the remedy otherwise, which is to export +the cells of its cell selection with `cell_selection=`. + +`graph`, a `connectivity_map` or `integrated_graph` built over exactly the exported cells, becomes +`obsp["connectivities"]`, the fuzzy union `W + W.T - W * W.T` of its directed weights, which +CyteArc's UMAP reads and Scanpy expects to be symmetric, and, for a connectivity map, +`obsp["distances"]`, its KNN distances with `k` entries per cell. `uns["neighbors"]` follows +Scanpy's layout: `connectivities_key`, `distances_key`, and `params` with `n_neighbors` (`k + 1`, +because Scanpy counts each cell among its own neighbors), `method`, `metric`, and `use_rep` when +the graph's coordinates are also exported in `obsm`. Exporting a graph holds about 96 bytes per +stored edge, which is checked against the memory budget. `uns["cytearc"]["artifacts"]` records the +reference of every exported artifact by element, such as `uns["cytearc"]["artifacts"]["obsm"]["X_pca"]`. + +The two meanings of `matrix="normed"` differ. Without a `run`, `to_anndata(matrix="normed")` +exports the assay's `normed` values of the selected cells and features: for RNA, counts scaled by +each cell's library size over all of its features to 1,000, without a log transform. A run export +with `matrix="normed"` exports the run's stored `normalized` artifact: log1p of counts scaled over +the highly variable features only, which are then the only features, as described below. + Without `run`, `to_anndata` and `to_h5ad` export the membership column `_I` of the exported assay as an ordinary `obs` column and declare it in `uns["cytearc"]["assayMembership"]`, for example `{"RNA": "RNA_I"}`, so `H5adToZarr` restores it as the membership of an imported @@ -404,19 +507,24 @@ The assay must be the exact assay object owned by the datastore that opened the to a file holds exactly what {py:meth}`~cytearc.datastore.datastore.DataStore.to_anndata` returns for the same `run` and `matrix`, because one resolution of the run's rows, columns, and values serves both: the run's cells, its frozen cell fields in `obs` with the consecutive UMAP fields in -`obsm["X_umap"]` and `clusters` in `obs`, and its frozen feature fields in `var`. `to_anndata` -holds that object in memory; `to_h5ad` streams it into the file. The run's plan decides which text -fields are categoricals: text that repeats or has missing values is a categorical whose categories -are in natural order (`d2` before `d10`, with a superscript or circled digit kept as text), and text -missing in every row is a categorical without categories. `to_anndata` returns those fields as -pandas categoricals with exactly those categories, and the file stores the same categories and -codes, so the object, the file, and a file that `AnnData.write_h5ad` writes from the object agree -whatever the AnnData version. Other text is a string array, and the indexes are named `ids` and -`gene_ids`. The export stores `X` compressed, stores distinct text as a string array, -and leaves out the empty `layers`, -`obsp`, `uns`, `varm`, and `varp` groups. `embeddings_cols`, feature-count recalculation, and a -writer-specific thread override apply only to ordinary full-assay export and are rejected when -`run` is supplied. +`obsm["X_umap"]`, its PCA in `obsm["X_pca"]` with the loadings in `varm["PCs"]` and its Harmony +correction, if any, in `obsm["X_pca_harmony"]`, and its frozen feature fields in `var`. `to_anndata` +holds that object in memory; `to_h5ad` streams it into the file. The run's plan decides which +fields are categoricals. Label fields, those with a categorical display (`clusters`, `leiden_*`, +`paris`, and `cell_cycle_phase`), are categoricals whose categories are their labels as text in +natural order, with the run's display colors as `uns["_colors"]`, so that Scanpy's +`rank_genes_groups(adata, "clusters")` and categorical plots work directly. Other text that repeats +or has missing values is a categorical whose categories are in natural order (`d2` before `d10`, +with a superscript or circled digit kept as text), and text missing in every row is a categorical +without categories. `to_anndata` returns those fields as pandas categoricals with exactly those +categories, and the file stores the same categories and codes, so the object, the file, and a file +that `AnnData.write_h5ad` writes from the object agree whatever the AnnData version. Other text is a +string array, and the indexes are named `ids` and `gene_ids`. The export stores `X` compressed, +stores distinct text as a string array, and leaves out empty `layers`, `obsp`, `uns`, `varm`, and +`varp` groups. `embeddings_cols`, feature-count recalculation, a cell selection, and a +writer-specific thread override apply only to ordinary assay export and are rejected when `run` is +supplied; `obsm`, `obs`, and `graph` add artifacts over the run's cells, such as +`graph=run["connectivity_map"]`. With `matrix="raw"`, the default, `X` holds the raw counts of the run's cells over its feature universe. With `matrix="normed"`, `X` is the run's `normalized` artifact: the float32 values that diff --git a/docs/source/scanpy.md b/docs/source/scanpy.md index 44827f7..161f650 100644 --- a/docs/source/scanpy.md +++ b/docs/source/scanpy.md @@ -42,15 +42,29 @@ expression. See [](tutorials/graph_construction.ipynb) for the complete manual g ## Move data through H5AD CyteArc reads and writes H5AD, so CyteArc and Scanpy can be used at different stages of one project. -Import an H5AD file into a CyteArc store: +Inspect an H5AD file first, and review which matrix will become the counts: ```python import cytearc inspection = cytearc.inspect_h5ad("data.h5ad") +print(inspection.matrixKey, inspection.integerLike) +print(inspection.matrixCandidates) +``` + +`inspect_h5ad` ranks matrices that hold counts first, preferring `raw/X`, `layers/counts`, or +`layers/raw_counts`, then `X`, then `spliced` counts, and ranks matrices of one kind by their +estimated total. It warns when several matrices hold integer counts, such as the `spliced`, +`unspliced`, `ambiguous`, and `matrix` layers of velocity data. If `matrixKey` is not the raw RNA +counts, inspect again with the right one, for example +`cytearc.inspect_h5ad("data.h5ad", matrix_key="layers/counts")`. + +Then import the file into a CyteArc store: + +```python reader = cytearc.H5adReader.from_inspect( inspection, - embedding_roles={"X_umap": "umap"}, + embedding_roles={"X_umap": "umap", "X_scVI": "coordinates"}, cluster_keys=("clusters",), ) imported = cytearc.H5adToZarr( @@ -66,15 +80,35 @@ ds.plots.embedding( ``` Selected embeddings and clusterings become immutable artifacts rather than live metadata -columns. Use their returned refs directly or inspect a payload with `ds.artifacts.load(ref)`. +columns. Use their returned refs directly or inspect a payload with `ds.artifacts.load(ref)`. The +`"coordinates"` role imports a latent space, such as `X_pca`, `X_scVI`, or `X_pca_harmony`, as +coordinates that `ds.graph.ann_index` builds a graph on. Categorical `obs` columns keep their +category order, unused categories, `ordered` flag, and the colors of `uns["_colors"]`, +which CyteArc's plots then use. A file whose cell IDs repeat, as `anndata.concat` writes without +`index_unique`, imports with `duplicate_cell_ids="make_unique"` passed to `from_inspect`. -Export to an in-memory `AnnData` object or directly to H5AD: +Export to an in-memory `AnnData` object or directly to H5AD. Pass the artifacts that Scanpy should +see: coordinates and embeddings go to `obsm`, labels and scores to `obs`, and a connectivity graph +to `obsp` with `uns["neighbors"]` in Scanpy's layout: ```python -adata = ds.to_anndata() -cytearc.to_h5ad(ds.RNA, "analysis.h5ad") +adata = ds.to_anndata( + cell_selection=cells, + obsm={"X_pca": pca, "X_umap": umap}, + obs={"leiden": leiden}, + graph=graph, +) +cytearc.to_h5ad(ds.RNA, "analysis.h5ad", cell_selection=cells, obsm={"X_pca": pca}) ``` +Labels such as `leiden` arrive as categoricals of text labels, so `sc.tl.rank_genes_groups(adata, +"leiden")` and `sc.pl.umap(adata, color="leiden")` work directly, and the graph lets +`sc.tl.paga` and `sc.tl.leiden` run without `sc.pp.neighbors`. `obsm["X_pca"]` also writes the PCA +loadings to `varm["PCs"]`. Each artifact must hold every exported cell, so export the cells of the +selection that the analysis used with `cell_selection=`. Raw counts that CyteArc stores as `uint8` +or `uint16` are exported as `float32`, on which arithmetic cannot wrap; pass `dtype=` to choose +another dtype. + To export a completed pipeline's frozen cells, feature universe, and result fields, pass its run: ```python @@ -82,19 +116,45 @@ adata = ds.to_anndata(run=run) cytearc.to_h5ad(ds.RNA, "pipeline-analysis.h5ad", run=run) ``` -Run export writes frozen UMAP coordinates to `obsm["X_umap"]` and frozen cluster labels to -`obs["clusters"]`. A run with `umap=False` does not invent an embedding. Both calls export the same +Run export writes frozen UMAP coordinates to `obsm["X_umap"]`, the run's PCA to `obsm["X_pca"]`, +and its cluster labels (`clusters`, `leiden_*`, and `paris`) to `obs` as categoricals with string +categories in natural order and the run's colors. A run with `umap=False` does not invent an +embedding. Add the run's graph with `graph=run["connectivity_map"]`. Both calls export the same object: `ds.to_anndata` holds it in memory, while `cytearc.to_h5ad` streams it to disk and needs neither the `anndata` package nor memory for the complete matrix. `X` holds raw counts over the -run's feature universe. Pass `matrix="normed"` to either call for the values that the run's PCA -read: its stored normalized values, float32, over its highly variable genes only, which then -become `var`. +run's feature universe. + +`matrix="normed"` means different values with and without a run. With `run`, `X` holds the values +that the run's PCA read: its stored normalized values, the log1p of counts scaled over its highly +variable genes, float32, over those genes only, which then become `var`. Without `run`, +`ds.to_anndata(matrix="normed")` holds the assay's library-size normalized values over all +exported genes, counts scaled to 1,000 per cell without a log transform, and `cytearc.to_h5ad` +rejects it. `ds.to_anndata()` defaults to active cells and all features. Pass `feature_indexes` or -`feature_names` to subset features. Without `run`, `cytearc.to_h5ad` writes the full assay to disk, -including cells with `I=False`, without first constructing an in-memory `AnnData`. +`feature_names` to subset features. Without `run` or `cell_selection`, `cytearc.to_h5ad` writes +the full assay to disk, including cells with `I=False`, without first constructing an in-memory +`AnnData`. + +## Bring results back from Scanpy + +A latent space computed with another tool re-enters the store as graph coordinates. Pass an array +in the order of a cell selection, or a pandas DataFrame indexed by cell ID, with a record of the +tool that made it: + +```python +import pandas as pd + +latent = ds.reduction.import_coordinates( + pd.DataFrame(adata.obsm["X_scVI"], index=adata.obs_names), + cell_selection=cells, + name="X_scVI", + source={"tool": "scvi-tools", "version": "1.2.0"}, +) +graph = ds.graph.connectivity(ds.graph.neighbors(ds.graph.ann_index(latent), k=11)) +leiden = ds.clusters.leiden(graph) +``` -Counts and metadata transfer, but CyteArc's neighbourhood graphs, provenance records, and -multimodal relationships do not map directly to AnnData. The exported H5AD may therefore need a -new neighbour graph in Scanpy. See [](tutorials/import_and_export.ipynb) for format details and export +The record and fingerprints of the values and cell IDs become the artifact's identity and lineage. +See [](tutorials/import_and_export.ipynb) for a complete round trip, format details, and export options. diff --git a/docs/source/tutorials/import_and_export.ipynb b/docs/source/tutorials/import_and_export.ipynb index 65feb6b..747fe9f 100644 --- a/docs/source/tutorials/import_and_export.ipynb +++ b/docs/source/tutorials/import_and_export.ipynb @@ -38,17 +38,17 @@ "id": "e71991cb", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:04.806363Z", - "iopub.status.busy": "2026-10-21T12:14:04.806092Z", - "iopub.status.idle": "2026-10-21T12:14:06.301813Z", - "shell.execute_reply": "2026-10-21T12:14:06.300640Z" + "iopub.execute_input": "2026-10-22T16:51:53.572370Z", + "iopub.status.busy": "2026-10-22T16:51:53.572063Z", + "iopub.status.idle": "2026-10-22T16:51:54.621123Z", + "shell.execute_reply": "2026-10-22T16:51:54.620025Z" } }, "outputs": [ { "data": { "text/plain": [ - "PosixPath('/tmp/tmpzw1pzfem')" + "PosixPath('/tmp/tmp3u289617')" ] }, "execution_count": 1, @@ -58,10 +58,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:04.806092Z | Wall time: 1.496 s" + "Started: 2026-10-22T16:51:53.572063Z | Wall time: 1.049 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:04.806092Z | Wall time: 1.496 s" + "Started: 2026-10-22T16:51:53.572063Z | Wall time: 1.049 s" ] }, "metadata": { @@ -118,17 +118,17 @@ "id": "8a065b91", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:06.305137Z", - "iopub.status.busy": "2026-10-21T12:14:06.304745Z", - "iopub.status.idle": "2026-10-21T12:14:09.272720Z", - "shell.execute_reply": "2026-10-21T12:14:09.270566Z" + "iopub.execute_input": "2026-10-22T16:51:54.623851Z", + "iopub.status.busy": "2026-10-22T16:51:54.623462Z", + "iopub.status.idle": "2026-10-22T16:51:57.680833Z", + "shell.execute_reply": "2026-10-22T16:51:57.679982Z" } }, "outputs": [ { "data": { "text/plain": [ - "PosixPath('/tmp/tmpzw1pzfem/xin_1K_pancreas_rnaseq')" + "PosixPath('/tmp/tmp3u289617/xin_1K_pancreas_rnaseq')" ] }, "execution_count": 2, @@ -138,10 +138,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:06.304745Z | Wall time: 2.968 s" + "Started: 2026-10-22T16:51:54.623462Z | Wall time: 3.057 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:06.304745Z | Wall time: 2.968 s" + "Started: 2026-10-22T16:51:54.623462Z | Wall time: 3.057 s" ] }, "metadata": { @@ -186,10 +186,10 @@ "id": "12024eec", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:09.276312Z", - "iopub.status.busy": "2026-10-21T12:14:09.275900Z", - "iopub.status.idle": "2026-10-21T12:14:09.786277Z", - "shell.execute_reply": "2026-10-21T12:14:09.784583Z" + "iopub.execute_input": "2026-10-22T16:51:57.682727Z", + "iopub.status.busy": "2026-10-22T16:51:57.682528Z", + "iopub.status.idle": "2026-10-22T16:51:58.008829Z", + "shell.execute_reply": "2026-10-22T16:51:58.008026Z" } }, "outputs": [ @@ -268,10 +268,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:09.275900Z | Wall time: 0.510 s" + "Started: 2026-10-22T16:51:57.682528Z | Wall time: 0.326 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:09.275900Z | Wall time: 0.510 s" + "Started: 2026-10-22T16:51:57.682528Z | Wall time: 0.326 s" ] }, "metadata": { @@ -317,17 +317,17 @@ "id": "97768d1a", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:09.789830Z", - "iopub.status.busy": "2026-10-21T12:14:09.789430Z", - "iopub.status.idle": "2026-10-21T12:14:22.687820Z", - "shell.execute_reply": "2026-10-21T12:14:22.684707Z" + "iopub.execute_input": "2026-10-22T16:51:58.011002Z", + "iopub.status.busy": "2026-10-22T16:51:58.010803Z", + "iopub.status.idle": "2026-10-22T16:52:08.287604Z", + "shell.execute_reply": "2026-10-22T16:52:08.286320Z" } }, "outputs": [ { "data": { "text/plain": [ - "PosixPath('/tmp/tmpzw1pzfem/xin_1K.zarr')" + "PosixPath('/tmp/tmp3u289617/xin_1K.zarr')" ] }, "execution_count": 4, @@ -337,10 +337,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:09.789430Z | Wall time: 12.898 s" + "Started: 2026-10-22T16:51:58.010803Z | Wall time: 10.277 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:09.789430Z | Wall time: 12.898 s" + "Started: 2026-10-22T16:51:58.010803Z | Wall time: 10.277 s" ] }, "metadata": { @@ -377,10 +377,10 @@ "id": "48608ae5", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:22.692083Z", - "iopub.status.busy": "2026-10-21T12:14:22.691678Z", - "iopub.status.idle": "2026-10-21T12:14:24.624259Z", - "shell.execute_reply": "2026-10-21T12:14:24.622208Z" + "iopub.execute_input": "2026-10-22T16:52:08.289795Z", + "iopub.status.busy": "2026-10-22T16:52:08.289573Z", + "iopub.status.idle": "2026-10-22T16:52:09.526563Z", + "shell.execute_reply": "2026-10-22T16:52:09.525282Z" } }, "outputs": [ @@ -403,10 +403,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:22.691678Z | Wall time: 1.933 s" + "Started: 2026-10-22T16:52:08.289573Z | Wall time: 1.237 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:22.691678Z | Wall time: 1.933 s" + "Started: 2026-10-22T16:52:08.289573Z | Wall time: 1.237 s" ] }, "metadata": { @@ -460,10 +460,10 @@ "id": "bba1a376", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:24.627927Z", - "iopub.status.busy": "2026-10-21T12:14:24.627519Z", - "iopub.status.idle": "2026-10-21T12:14:27.334167Z", - "shell.execute_reply": "2026-10-21T12:14:27.332722Z" + "iopub.execute_input": "2026-10-22T16:52:09.528780Z", + "iopub.status.busy": "2026-10-22T16:52:09.528542Z", + "iopub.status.idle": "2026-10-22T16:52:12.288045Z", + "shell.execute_reply": "2026-10-22T16:52:12.286962Z" } }, "outputs": [ @@ -552,10 +552,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:24.627519Z | Wall time: 2.707 s" + "Started: 2026-10-22T16:52:09.528542Z | Wall time: 2.760 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:24.627519Z | Wall time: 2.707 s" + "Started: 2026-10-22T16:52:09.528542Z | Wall time: 2.760 s" ] }, "metadata": { @@ -599,12 +599,24 @@ "id": "27cbb9a3", "metadata": {}, "source": [ + "`inspect_h5ad` ranks the matrices that hold counts first and prefers conventional names:\n", + "`raw/X`, `layers/counts`, or `layers/raw_counts`, then `X`, then `spliced` counts, with\n", + "`unspliced` and `ambiguous` counts last. Matrices of one kind rank by their estimated total.\n", + "Review the selected matrix before importing. This file holds counts in `X`, `spliced`, and\n", + "`unspliced`, so inspection also logs a warning that several matrices hold counts. This page sets\n", + "`configure_output(level=\"ERROR\")`, which hides log warnings, so the warning does not appear above;\n", + "at the default log level it does. `X` is the one this tutorial imports. If another matrix holds\n", + "the raw counts that your analysis needs, inspect again with `matrix_key`, for example\n", + "`cytearc.inspect_h5ad(h5ad_path, matrix_key=\"layers/counts\")`.\n", + "\n", "`H5adReader.from_inspect` uses the discovered matrix and metadata keys.\n", - "Override the inspection only after confirming that another layer contains the raw count matrix required by the analysis.\n", "This file also contains a UMAP and cell-type labels; other H5AD files may lack these results.\n", "Here we ask the reader to keep them with\n", "`embedding_roles` and `cluster_keys`, so we can plot and export them later. Omit these two\n", - "arguments for a counts-and-metadata import." + "arguments for a counts-and-metadata import. The role `\"coordinates\"` imports a latent space,\n", + "such as `X_pca` or `X_scVI`, as coordinates for graph construction. Categorical columns keep\n", + "their category order, unused categories, `ordered` flag, and `uns[\"_colors\"]`, which\n", + "CyteArc's plots use." ] }, { @@ -613,18 +625,18 @@ "id": "625a14e7", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:27.337278Z", - "iopub.status.busy": "2026-10-21T12:14:27.336971Z", - "iopub.status.idle": "2026-10-21T12:14:30.336233Z", - "shell.execute_reply": "2026-10-21T12:14:30.334770Z" + "iopub.execute_input": "2026-10-22T16:52:12.290277Z", + "iopub.status.busy": "2026-10-22T16:52:12.290000Z", + "iopub.status.idle": "2026-10-22T16:52:14.287534Z", + "shell.execute_reply": "2026-10-22T16:52:14.286408Z" } }, "outputs": [ { "data": { "text/plain": [ - "{'embeddings': {'X_umap': ArtifactRef(assay='RNA', kind='embedding', artifact_id='62fb9025b3f1...')},\n", - " 'clusters': {'clusters': ArtifactRef(assay='RNA', kind='cluster_labels', artifact_id='cd15ac61d9f2...')}}" + "{'embeddings': {'X_umap': ArtifactRef(assay='RNA', kind='embedding', artifact_id='7a7f54a76f21...')},\n", + " 'clusters': {'clusters': ArtifactRef(assay='RNA', kind='cluster_labels', artifact_id='3f2c1d3d645e...')}}" ] }, "execution_count": 7, @@ -634,10 +646,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:27.336971Z | Wall time: 2.999 s" + "Started: 2026-10-22T16:52:12.290000Z | Wall time: 1.998 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:27.336971Z | Wall time: 2.999 s" + "Started: 2026-10-22T16:52:12.290000Z | Wall time: 1.998 s" ] }, "metadata": { @@ -703,17 +715,17 @@ "id": "818b9d72", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:30.340247Z", - "iopub.status.busy": "2026-10-21T12:14:30.339874Z", - "iopub.status.idle": "2026-10-21T12:14:42.879704Z", - "shell.execute_reply": "2026-10-21T12:14:42.878259Z" + "iopub.execute_input": "2026-10-22T16:52:14.290119Z", + "iopub.status.busy": "2026-10-22T16:52:14.289904Z", + "iopub.status.idle": "2026-10-22T16:52:24.978638Z", + "shell.execute_reply": "2026-10-22T16:52:24.977328Z" } }, "outputs": [ { "data": { "text/plain": [ - "PosixPath('/tmp/tmpzw1pzfem/pbmc_atac.zarr')" + "PosixPath('/tmp/tmp3u289617/pbmc_atac.zarr')" ] }, "execution_count": 8, @@ -723,10 +735,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:30.339874Z | Wall time: 12.540 s" + "Started: 2026-10-22T16:52:14.289904Z | Wall time: 10.689 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:30.339874Z | Wall time: 12.540 s" + "Started: 2026-10-22T16:52:14.289904Z | Wall time: 10.689 s" ] }, "metadata": { @@ -778,10 +790,10 @@ "id": "7e0b0ecc", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:42.882607Z", - "iopub.status.busy": "2026-10-21T12:14:42.882302Z", - "iopub.status.idle": "2026-10-21T12:14:43.174610Z", - "shell.execute_reply": "2026-10-21T12:14:43.172968Z" + "iopub.execute_input": "2026-10-22T16:52:24.981555Z", + "iopub.status.busy": "2026-10-22T16:52:24.981270Z", + "iopub.status.idle": "2026-10-22T16:52:25.167792Z", + "shell.execute_reply": "2026-10-22T16:52:25.166175Z" } }, "outputs": [ @@ -803,10 +815,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:42.882302Z | Wall time: 0.292 s" + "Started: 2026-10-22T16:52:24.981270Z | Wall time: 0.187 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:42.882302Z | Wall time: 0.292 s" + "Started: 2026-10-22T16:52:24.981270Z | Wall time: 0.187 s" ] }, "metadata": { @@ -926,10 +938,10 @@ "id": "22153d06", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:43.177865Z", - "iopub.status.busy": "2026-10-21T12:14:43.177511Z", - "iopub.status.idle": "2026-10-21T12:14:43.521683Z", - "shell.execute_reply": "2026-10-21T12:14:43.520136Z" + "iopub.execute_input": "2026-10-22T16:52:25.171156Z", + "iopub.status.busy": "2026-10-22T16:52:25.170802Z", + "iopub.status.idle": "2026-10-22T16:52:25.431421Z", + "shell.execute_reply": "2026-10-22T16:52:25.430084Z" } }, "outputs": [ @@ -946,10 +958,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:43.177511Z | Wall time: 0.344 s" + "Started: 2026-10-22T16:52:25.170802Z | Wall time: 0.261 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:43.177511Z | Wall time: 0.344 s" + "Started: 2026-10-22T16:52:25.170802Z | Wall time: 0.261 s" ] }, "metadata": { @@ -990,10 +1002,10 @@ "id": "f9e02f3f", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:43.524266Z", - "iopub.status.busy": "2026-10-21T12:14:43.523951Z", - "iopub.status.idle": "2026-10-21T12:14:44.857062Z", - "shell.execute_reply": "2026-10-21T12:14:44.855570Z" + "iopub.execute_input": "2026-10-22T16:52:25.433827Z", + "iopub.status.busy": "2026-10-22T16:52:25.433571Z", + "iopub.status.idle": "2026-10-22T16:52:26.282160Z", + "shell.execute_reply": "2026-10-22T16:52:26.280847Z" }, "figure_descriptions": [ "UMAP imported from H5AD, coloured by imported pancreatic cell labels including Alpha, Beta, Delta, Ductal, Epsilon, endocrine progenitors, and pre-endocrine cells." @@ -1002,7 +1014,7 @@ "outputs": [ { "data": { - "image/png": "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1lncfvvtezz3k5/8hG984xuDjxctWsTYsWPxer1kZGTw/e9/n1AoxF133cVLL73Eq6++SnFxMcXFxaxduxaA++67j9zcXFJSUsjKyuK2227bI+hefvnlXHTRRXz961/H4/Fw4oknfuFr/d3vfjd4XrfbzfTp01m5cuVex3V1dXHOOefg8/lwOp1cfPHFew1f/7f6/rvPapOhIgKrIAjCQeLXv/41d999N3/961+JRCIsXbr0U4PN56XrOueffz5XX3014XCYmpoaxowZw8aNG/nVr37FxRdfzPnnnz/Yc5wzZw733nsvf/3rX1m0aBGxWIxly5bx3HPP8bvf/W6Psl9//XVOPPFE+vv7WbFixReu2y233DJ4Xr/fz9e//nXOOeccAoHAXueZPXs2HR0dlJeXs3btWm655ZbB1z9vfT9PmwwVEVgFQRAOArquc99993HzzTdzyimnoKoqRUVFPPDAA1+6zHA4jN/vZ86cOciyjNfr5dprr+Xoo4/+1OMNw+A3v/kNt956KyUlJQQCAXJycrjmmmv2ug87e/Zsrr76aiwWy5eu3yei0SjXXnstsViM1atX7/FaaWkp//d//4fVamX06NHccccdPProo2ia9oXq+2Xb5MsQk5cEQRAOAk1NTfT29nLkkUcOWZkej4frrruOBQsWcPbZZ3PsscdyxhlnkJmZ+Zl16O7u5tprr+V73/veHq95vd49HpeWlv5Pddu2bRs33HADy5YtA8Bms+H3+2lqatrjuClTpuzxeOrUqUSjUerr67FarZ+7vp/4om3yZYgeqyAIwkFAlpMfx/9tgs7uJEna6znDMPZ4/NBDD7F27VpmzJjBc889x4gRI/jwww//Y7mvv/764DDtJ/8aGhr2OOZ/7amed955DB8+nKamJiKRCH19fWRkZOw1kenf75V+8rqq7uoXfp767u7LtMkXIQKrIAjCQaCgoIDc3Nwv9AGflpZGV1fXHs9t27Ztr+PGjBnDDTfcwLvvvssxxxzDP/7xDwCsVusegauwsJDc3FwWLlz45S7ic4pGo2zbto0rrriC9PR0AHbs2EFnZ+dex65Zs2aPOq5cuRKv10thYeH/VN/PapOhIAKrIAjCQUCSJG6//XbuvfdeHnvsMRoaGvj4448577zzPvM9xx13HC+//DJLliyhsbGRX//613z00UeDr1dVVXH66aez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SZMmEQ6H2bNnz5ABfidOnOD111+noqKC5ubmIX2oAOl0mr1792IYBjNmzPB+h/ztuQFefPFFLr/8ciorK0d8fSN9JgpW+dBJpVL0dHWRHhxk2zNPMfiXP5EIBbBdMKfM4OrFS5k2c+YHfZkicoFSsMqH3h9f3cXB//kDF9XXM2/hde85HUBE5HxRsIqIiPhIlZdERER8pGAVERHxkYJVRETERwpWERERHylYRUREfKRgFRER8ZGCVURExEcKVhERER8pWEVERHykYBUREfGRglVERMRHClYREREfKVhFRER8pGAVERHxkYJVRETERwpWERERHylYRUREfKRgFRER8ZGCVURExEcKVhERER8pWEVERHykYBUREfGRglVERMRHClYREREfKVhFRER8pGAVERHxkYJVRETERwpWERERHylYRUREfKRgFRER8ZGCVURExEcKVhERER8pWEVERHykYBUREfGRglVERMRHClYREREfKVhFRER8pGAVERHxkYJVRETERwpWERERHylYRUREfKRgFRER8ZGCVURExEcKVhERER8pWEVERHykYBUREfGRglVERMRHClYREREfKVhFRER8pGAVERHxkYJVRETERwpWERERHylYRUREfKRgFRER8ZGCVURExEcKVhERER8pWEVERHykYBUREfGRglVERMRHClYREREfKVhFRER8pGAVERHxkYJVRETERwpWERERHylYRUREfKRgFRER8ZGCVURExEcKVhERER8pWEVERHykYBUREfGRglVERMRHClYREREfGSP5R4VCAYCBgYFzejEiIudKMpkkEAh80JchF4ARBevg4CAADQ0N5/RiRETOlf7+fsrLyz/oy5ALQKBw6nX0DFzX5dixY/qLT0Q+tPT7S86XEQWriIiIjIwGL4mIiPhIwSoiIuIjBauIiIiPFKwiIiI+UrCKiIj4SMEqIiLiIwWriIiIj/4X3f8vuEnTWugAAAAASUVORK5CYII=", + "image/png": "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", "text/plain": [ "
" ] @@ -1013,10 +1025,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:43.523951Z | Wall time: 1.333 s" + "Started: 2026-10-22T16:52:25.433571Z | Wall time: 0.849 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:43.523951Z | Wall time: 1.333 s" + "Started: 2026-10-22T16:52:25.433571Z | Wall time: 0.849 s" ] }, "metadata": { @@ -1039,17 +1051,17 @@ "id": "82b4a168", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:44.860332Z", - "iopub.status.busy": "2026-10-21T12:14:44.859734Z", - "iopub.status.idle": "2026-10-21T12:14:52.633827Z", - "shell.execute_reply": "2026-10-21T12:14:52.631431Z" + "iopub.execute_input": "2026-10-22T16:52:26.284960Z", + "iopub.status.busy": "2026-10-22T16:52:26.284422Z", + "iopub.status.idle": "2026-10-22T16:52:28.908552Z", + "shell.execute_reply": "2026-10-22T16:52:28.906857Z" } }, "outputs": [ { "data": { "text/plain": [ - "[('barcodes.tsv', 62832), ('genes.tsv', 417116), ('matrix.mtx', 114507260)]" + "[('barcodes.tsv', 62832), ('genes.tsv', 865084), ('matrix.mtx', 114507260)]" ] }, "execution_count": 12, @@ -1059,10 +1071,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:44.859734Z | Wall time: 7.774 s" + "Started: 2026-10-22T16:52:26.284422Z | Wall time: 2.624 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:44.859734Z | Wall time: 7.774 s" + "Started: 2026-10-22T16:52:26.284422Z | Wall time: 2.624 s" ] }, "metadata": { @@ -1090,15 +1102,18 @@ "## Export to H5AD and AnnData\n", "\n", "`DataStore.to_anndata` builds an in-memory AnnData object from the selected counts and\n", - "metadata. In this example, add the imported UMAP to `obsm` and cluster labels to `obs`, then\n", - "write that object as H5AD. Those assignments affect the AnnData object and exported file;\n", - "they do not change the CyteArc store.\n", + "metadata, and `cytearc.to_h5ad` streams the same kind of export into a file without AnnData.\n", + "Both export artifacts beside the counts: coordinates and embeddings as `obsm`, cell labels and\n", + "scores as `obs`, and a neighbour graph as `graph`. Each artifact must hold every exported cell,\n", + "so export the cells of the selection that the artifacts cover with `cell_selection`. Exporting\n", + "does not change the CyteArc store.\n", "\n", "For a completed pipeline, prefer `ds.to_anndata(run=run)`. That form exports the run's frozen\n", - "assay, cell selection, feature selection, and metadata. It rejects live `cell_key`,\n", - "`feature_indexes`, and `feature_names` overrides so the exported axes cannot drift from the run.\n", + "assay, cell selection, feature selection, metadata, PCA, UMAP, and cluster labels. It rejects\n", + "live `cell_key`, `cell_selection`, `feature_indexes`, and `feature_names` overrides so the\n", + "exported axes cannot drift from the run.\n", "\n", - "### Attach exact imported artifacts" + "### Export imported artifacts" ] }, { @@ -1107,17 +1122,17 @@ "id": "d509ea09", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:52.638041Z", - "iopub.status.busy": "2026-10-21T12:14:52.637668Z", - "iopub.status.idle": "2026-10-21T12:14:54.254448Z", - "shell.execute_reply": "2026-10-21T12:14:54.252855Z" + "iopub.execute_input": "2026-10-22T16:52:28.911970Z", + "iopub.status.busy": "2026-10-22T16:52:28.911717Z", + "iopub.status.idle": "2026-10-22T16:52:30.168065Z", + "shell.execute_reply": "2026-10-22T16:52:30.166678Z" } }, "outputs": [ { "data": { "text/plain": [ - "{'file': 'diff_pancreas.h5ad', 'bytes': 59304360}" + "{'file': 'diff_pancreas.h5ad', 'bytes': 77932304}" ] }, "execution_count": 13, @@ -1127,10 +1142,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:52.637668Z | Wall time: 1.617 s" + "Started: 2026-10-22T16:52:28.911717Z | Wall time: 1.256 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:52.637668Z | Wall time: 1.617 s" + "Started: 2026-10-22T16:52:28.911717Z | Wall time: 1.256 s" ] }, "metadata": { @@ -1140,19 +1155,17 @@ } ], "source": [ - "# Build an AnnData object from the assay.\n", - "adata = ds.to_anndata(from_assay=\"RNA\")\n", - "\n", - "# Attach the imported UMAP in AnnData's embedding slot.\n", - "adata.obsm[\"X_umap\"] = imported_umap\n", - "\n", - "# Attach the imported cluster labels to AnnData's cell table.\n", - "adata.obs[\"clusters\"] = imported_clusters\n", + "# Export the imported cells with the imported UMAP in obsm and their labels in obs.\n", + "adata = ds.to_anndata(\n", + " cell_selection=h5ad_import.cellSelection,\n", + " obsm={\"X_umap\": h5ad_import.embeddingArtifacts[\"X_umap\"]},\n", + " obs={\"clusters\": h5ad_import.clusterArtifacts[\"clusters\"]},\n", + ")\n", "\n", "# Choose the exported H5AD filename.\n", "h5ad_export = output_dir / \"diff_pancreas.h5ad\"\n", "\n", - "# Write counts, metadata, and attached results to H5AD.\n", + "# Write counts, metadata, and the exported artifacts to H5AD.\n", "adata.write_h5ad(h5ad_export)\n", "\n", "# Confirm the written filename and its size in bytes.\n", @@ -1164,7 +1177,8 @@ "id": "4eaff1d3", "metadata": {}, "source": [ - "Reload the H5AD and confirm that the explicitly attached layout is in `obsm`:" + "Reload the H5AD and confirm that the layout is in `obsm` and that the labels are a categorical\n", + "with the categories, in their source order, and colors that the import kept:" ] }, { @@ -1173,17 +1187,27 @@ "id": "edcebdde", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:54.258224Z", - "iopub.status.busy": "2026-10-21T12:14:54.257853Z", - "iopub.status.idle": "2026-10-21T12:14:54.377858Z", - "shell.execute_reply": "2026-10-21T12:14:54.376268Z" + "iopub.execute_input": "2026-10-22T16:52:30.170518Z", + "iopub.status.busy": "2026-10-22T16:52:30.170280Z", + "iopub.status.idle": "2026-10-22T16:52:30.277857Z", + "shell.execute_reply": "2026-10-22T16:52:30.276650Z" } }, "outputs": [ { "data": { "text/plain": [ - "(['X_umap'], (3696, 2))" + "{'obsm': ['X_umap'],\n", + " 'umap shape': (3696, 2),\n", + " 'cluster categories': ['Ductal',\n", + " 'Ngn3 low EP',\n", + " 'Ngn3 high EP',\n", + " 'Pre-endocrine',\n", + " 'Beta',\n", + " 'Alpha',\n", + " 'Delta',\n", + " 'Epsilon'],\n", + " 'first cluster colors': ['#8fbc8f', '#f4a460', '#fdbf6f']}" ] }, "execution_count": 14, @@ -1193,10 +1217,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:54.257853Z | Wall time: 0.120 s" + "Started: 2026-10-22T16:52:30.170280Z | Wall time: 0.108 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:54.257853Z | Wall time: 0.120 s" + "Started: 2026-10-22T16:52:30.170280Z | Wall time: 0.108 s" ] }, "metadata": { @@ -1212,8 +1236,783 @@ "# Reload the exported H5AD to verify its contents.\n", "adata = ad.read_h5ad(h5ad_export)\n", "\n", - "# Verify the exported embedding name and dimensions.\n", - "sorted(adata.obsm.keys()), adata.obsm[\"X_umap\"].shape" + "# Verify the embedding, the label categories, and their colors.\n", + "{\n", + " \"obsm\": sorted(adata.obsm.keys()),\n", + " \"umap shape\": adata.obsm[\"X_umap\"].shape,\n", + " \"cluster categories\": adata.obs[\"clusters\"].cat.categories.tolist(),\n", + " \"first cluster colors\": adata.uns[\"clusters_colors\"][:3].tolist(),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "996e6fa7", + "metadata": {}, + "source": [ + "### Round trip with Scanpy\n", + "\n", + "Scanpy reads PCA coordinates from `obsm[\"X_pca\"]`, a neighbour graph from `obsp` with\n", + "`uns[\"neighbors\"]`, and cell groups from categorical `obs` columns. Build those results in\n", + "CyteArc, export them in Scanpy's layout, compute a new representation of the cells outside\n", + "CyteArc, and bring it back as graph coordinates. The outside step here uses scikit-learn, so this\n", + "page does not need Scanpy itself; a latent space from scVI or another tool returns the same way." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "9c5dc66d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-22T16:52:30.280203Z", + "iopub.status.busy": "2026-10-22T16:52:30.279995Z", + "iopub.status.idle": "2026-10-22T16:53:26.108507Z", + "shell.execute_reply": "2026-10-22T16:53:26.107524Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'obsm': ['X_pca', 'X_umap'],\n", + " 'obsp': ['connectivities', 'distances'],\n", + " 'varm': ['PCs'],\n", + " 'neighbors': {'n_neighbors': 12,\n", + " 'method': 'umap',\n", + " 'metric': 'euclidean',\n", + " 'use_rep': 'X_pca'},\n", + " 'leiden dtype': 'category'}" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/html": [ + "Started: 2026-10-22T16:52:30.279995Z | Wall time: 55.829 s" + ], + "text/plain": [ + "Started: 2026-10-22T16:52:30.279995Z | Wall time: 55.829 s" + ] + }, + "metadata": { + "cytearc_execution_timing": true + }, + "output_type": "display_data" + } + ], + "source": [ + "# Use the imported cells for the analysis.\n", + "cells = h5ad_import.cellSelection\n", + "\n", + "# Select highly variable genes over those cells.\n", + "hvgs = ds.features.hvgs(cells, top_n=2000, show_plot=False)\n", + "\n", + "# Normalize the selected genes.\n", + "normalized = ds.features.normalize(cells, hvgs)\n", + "\n", + "# Reduce the normalized values with PCA.\n", + "pca = ds.reduction.pca(normalized, dims=20)\n", + "\n", + "# Build the neighbour graph on the PCA coordinates.\n", + "graph = ds.graph.connectivity(ds.graph.neighbors(ds.graph.ann_index(pca), k=11))\n", + "\n", + "# Embed the graph with UMAP, starting from a k-means initialization of the PCA.\n", + "umap = ds.embeddings.umap(graph, ds.embeddings.initialization(pca))\n", + "\n", + "# Cluster the graph with Leiden.\n", + "leiden = ds.clusters.leiden(graph, resolution=1.0)\n", + "\n", + "# Export the counts with the PCA, the UMAP, the Leiden labels, and the graph.\n", + "adata = ds.to_anndata(\n", + " cell_selection=cells,\n", + " obsm={\"X_pca\": pca, \"X_umap\": umap},\n", + " obs={\"leiden\": leiden},\n", + " graph=graph,\n", + ")\n", + "\n", + "# List the elements that Scanpy reads.\n", + "{\n", + " \"obsm\": sorted(adata.obsm.keys()),\n", + " \"obsp\": sorted(adata.obsp.keys()),\n", + " \"varm\": sorted(adata.varm.keys()),\n", + " \"neighbors\": adata.uns[\"neighbors\"][\"params\"],\n", + " \"leiden dtype\": str(adata.obs[\"leiden\"].dtype),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "ebaa2053", + "metadata": {}, + "source": [ + "`obs[\"leiden\"]` is a categorical of text labels in natural order, so Scanpy groups by it as by\n", + "its own Leiden result, for example in `sc.tl.rank_genes_groups(adata, \"leiden\")`, and\n", + "`uns[\"neighbors\"]` points `sc.tl.paga` and `sc.tl.leiden` at the exported graph without\n", + "`sc.pp.neighbors`. `varm[\"PCs\"]` holds the PCA loadings, with zeros for the genes that the PCA\n", + "did not use. Here, count the cells of each cluster, and compute a new representation of the\n", + "cells from their counts of the PCA genes:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "49d8dda4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-22T16:53:26.113773Z", + "iopub.status.busy": "2026-10-22T16:53:26.113450Z", + "iopub.status.idle": "2026-10-22T16:53:26.279105Z", + "shell.execute_reply": "2026-10-22T16:53:26.277819Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "({'1': 510,\n", + " '2': 387,\n", + " '3': 304,\n", + " '4': 263,\n", + " '5': 227,\n", + " '6': 227,\n", + " '7': 207,\n", + " '8': 198,\n", + " '9': 193,\n", + " '10': 179,\n", + " '11': 174,\n", + " '12': 166,\n", + " '13': 143,\n", + " '14': 127,\n", + " '15': 124,\n", + " '16': 91,\n", + " '17': 89,\n", + " '18': 87},\n", + " (3696, 15))" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/html": [ + "Started: 2026-10-22T16:53:26.113450Z | Wall time: 0.166 s" + ], + "text/plain": [ + "Started: 2026-10-22T16:53:26.113450Z | Wall time: 0.166 s" + ] + }, + "metadata": { + "cytearc_execution_timing": true + }, + "output_type": "display_data" + } + ], + "source": [ + "# Build sparse diagonal scaling matrices.\n", + "from scipy.sparse import csr_matrix, diags\n", + "\n", + "# Fit a truncated SVD as the analysis step outside CyteArc.\n", + "from sklearn.decomposition import TruncatedSVD\n", + "\n", + "# Count the cells of each Leiden cluster, as Scanpy groups by a categorical column.\n", + "cluster_sizes = adata.obs[\"leiden\"].value_counts(sort=False)\n", + "\n", + "# Find the genes that the PCA used, from their nonzero loadings.\n", + "pca_genes = adata.varm[\"PCs\"].any(axis=1)\n", + "\n", + "# Scale each cell's counts of those genes to 10,000 and log-transform them.\n", + "counts = csr_matrix(adata.X[:, pca_genes], dtype=np.float32)\n", + "scaled = diags(1e4 / np.maximum(counts.sum(axis=1).A1, 1)) @ counts\n", + "scaled.data = np.log1p(scaled.data)\n", + "\n", + "# Compute a 15-dimensional latent space of the cells.\n", + "svd = TruncatedSVD(n_components=15, random_state=0)\n", + "adata.obsm[\"X_svd\"] = svd.fit_transform(scaled)\n", + "\n", + "# Show the cluster sizes and the shape of the latent space.\n", + "cluster_sizes.to_dict(), adata.obsm[\"X_svd\"].shape" + ] + }, + { + "cell_type": "markdown", + "id": "f05e2270", + "metadata": {}, + "source": [ + "Bring the latent space back as graph coordinates. `reduction.import_coordinates` aligns a\n", + "DataFrame to the selected cells by its index of cell IDs and records the tool that made it, so\n", + "lineage shows where the coordinates came from. Graph construction and clustering then use them\n", + "as they use a PCA:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "a87ff5bb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-22T16:53:26.281536Z", + "iopub.status.busy": "2026-10-22T16:53:26.281296Z", + "iopub.status.idle": "2026-10-22T16:53:26.961946Z", + "shell.execute_reply": "2026-10-22T16:53:26.960841Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
SVD Leiden123456789101112131415161718
PCA Leiden
14600000003201400040000
2036401400004000000050
30011917700102000005000
4000011245000000000700
50901080011000000000000
600002252000000000000
74300000030000103580000
8021671027000000000100
908000000182000000300
1020000001760001000000
11000000000413603220000
12000028003000135000000
1330000000013900010000
1400000012700000000000
1504517000040000000580
160001000010000087200
1700201410000000007200
1800000000001005900027
\n", + "
" + ], + "text/plain": [ + "SVD Leiden 1 2 3 4 5 6 7 8 9 10 11 12 13 \\\n", + "PCA Leiden \n", + "1 460 0 0 0 0 0 0 32 0 14 0 0 0 \n", + "2 0 364 0 14 0 0 0 0 4 0 0 0 0 \n", + "3 0 0 119 177 0 0 1 0 2 0 0 0 0 \n", + "4 0 0 0 0 11 245 0 0 0 0 0 0 0 \n", + "5 0 9 0 108 0 0 110 0 0 0 0 0 0 \n", + "6 0 0 0 0 225 2 0 0 0 0 0 0 0 \n", + "7 43 0 0 0 0 0 0 3 0 0 0 0 103 \n", + "8 0 2 167 1 0 27 0 0 0 0 0 0 0 \n", + "9 0 8 0 0 0 0 0 0 182 0 0 0 0 \n", + "10 2 0 0 0 0 0 0 176 0 0 0 1 0 \n", + "11 0 0 0 0 0 0 0 0 0 4 136 0 32 \n", + "12 0 0 0 0 28 0 0 3 0 0 0 135 0 \n", + "13 3 0 0 0 0 0 0 0 0 139 0 0 0 \n", + "14 0 0 0 0 0 0 127 0 0 0 0 0 0 \n", + "15 0 4 51 7 0 0 0 0 4 0 0 0 0 \n", + "16 0 0 0 1 0 0 0 0 1 0 0 0 0 \n", + "17 0 0 2 0 14 1 0 0 0 0 0 0 0 \n", + "18 0 0 0 0 0 0 0 0 0 0 1 0 0 \n", + "\n", + "SVD Leiden 14 15 16 17 18 \n", + "PCA Leiden \n", + "1 4 0 0 0 0 \n", + "2 0 0 0 5 0 \n", + "3 0 5 0 0 0 \n", + "4 0 0 7 0 0 \n", + "5 0 0 0 0 0 \n", + "6 0 0 0 0 0 \n", + "7 58 0 0 0 0 \n", + "8 0 0 1 0 0 \n", + "9 0 0 3 0 0 \n", + "10 0 0 0 0 0 \n", + "11 2 0 0 0 0 \n", + "12 0 0 0 0 0 \n", + "13 1 0 0 0 0 \n", + "14 0 0 0 0 0 \n", + "15 0 0 0 58 0 \n", + "16 0 87 2 0 0 \n", + "17 0 0 72 0 0 \n", + "18 59 0 0 0 27 " + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/html": [ + "Started: 2026-10-22T16:53:26.281296Z | Wall time: 0.681 s" + ], + "text/plain": [ + "Started: 2026-10-22T16:53:26.281296Z | Wall time: 0.681 s" + ] + }, + "metadata": { + "cytearc_execution_timing": true + }, + "output_type": "display_data" + } + ], + "source": [ + "# Record the version of the tool that computed the latent space.\n", + "import sklearn\n", + "\n", + "# Import the latent space as coordinates of the same cells, with a record of its source.\n", + "latent = ds.reduction.import_coordinates(\n", + " pd.DataFrame(adata.obsm[\"X_svd\"], index=adata.obs_names),\n", + " cell_selection=cells,\n", + " name=\"X_svd\",\n", + " source={\n", + " \"tool\": \"scikit-learn TruncatedSVD\",\n", + " \"version\": sklearn.__version__,\n", + " \"parameters\": {\"n_components\": 15, \"random_state\": 0},\n", + " },\n", + ")\n", + "\n", + "# Build a neighbour graph on the imported coordinates.\n", + "latent_graph = ds.graph.connectivity(\n", + " ds.graph.neighbors(ds.graph.ann_index(latent), k=11)\n", + ")\n", + "\n", + "# Cluster the new graph with Leiden.\n", + "latent_leiden = ds.clusters.leiden(latent_graph, resolution=1.0)\n", + "\n", + "# Compare the clusters of both graphs cell by cell.\n", + "pd.crosstab(\n", + " ds.artifacts.load_values(leiden).values,\n", + " ds.artifacts.load_values(latent_leiden).values,\n", + " rownames=[\"PCA Leiden\"],\n", + " colnames=[\"SVD Leiden\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1ce3e31e", + "metadata": {}, + "source": [ + "Each row is a cluster of the PCA graph and each column a cluster of the graph built on the\n", + "imported coordinates. The imported artifact's provenance keeps the source record:\n", + "`ds.artifacts.inspect(latent).parameters[\"source\"]` names the tool, its version, and its\n", + "parameters." ] }, { @@ -1230,14 +2029,14 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 18, "id": "3b02c63b", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:54.380718Z", - "iopub.status.busy": "2026-10-21T12:14:54.380418Z", - "iopub.status.idle": "2026-10-21T12:14:54.701018Z", - "shell.execute_reply": "2026-10-21T12:14:54.699514Z" + "iopub.execute_input": "2026-10-22T16:53:26.964400Z", + "iopub.status.busy": "2026-10-22T16:53:26.964150Z", + "iopub.status.idle": "2026-10-22T16:53:27.175931Z", + "shell.execute_reply": "2026-10-22T16:53:27.174792Z" } }, "outputs": [ @@ -1247,17 +2046,17 @@ "{'shape': (3696, 3), 'genes': ['Gcg', 'Sst', 'Krt19']}" ] }, - "execution_count": 15, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" }, { "data": { "text/html": [ - "Started: 2026-10-21T12:14:54.380418Z | Wall time: 0.321 s" + "Started: 2026-10-22T16:53:26.964150Z | Wall time: 0.212 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:54.380418Z | Wall time: 0.321 s" + "Started: 2026-10-22T16:53:26.964150Z | Wall time: 0.212 s" ] }, "metadata": { @@ -1308,14 +2107,14 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 19, "id": "9f992b17", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:54.704564Z", - "iopub.status.busy": "2026-10-21T12:14:54.704213Z", - "iopub.status.idle": "2026-10-21T12:14:54.710593Z", - "shell.execute_reply": "2026-10-21T12:14:54.709192Z" + "iopub.execute_input": "2026-10-22T16:53:27.178333Z", + "iopub.status.busy": "2026-10-22T16:53:27.178094Z", + "iopub.status.idle": "2026-10-22T16:53:27.183091Z", + "shell.execute_reply": "2026-10-22T16:53:27.181973Z" } }, "outputs": [ @@ -1335,10 +2134,10 @@ { "data": { "text/html": [ - "Started: 2026-10-21T12:14:54.704213Z | Wall time: 0.006 s" + "Started: 2026-10-22T16:53:27.178094Z | Wall time: 0.005 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:54.704213Z | Wall time: 0.006 s" + "Started: 2026-10-22T16:53:27.178094Z | Wall time: 0.005 s" ] }, "metadata": { @@ -1376,17 +2175,25 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 20, "id": "1a7892c7", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:54.713599Z", - "iopub.status.busy": "2026-10-21T12:14:54.713279Z", - "iopub.status.idle": "2026-10-21T12:14:55.869828Z", - "shell.execute_reply": "2026-10-21T12:14:55.868348Z" + "iopub.execute_input": "2026-10-22T16:53:27.185292Z", + "iopub.status.busy": "2026-10-22T16:53:27.185082Z", + "iopub.status.idle": "2026-10-22T16:53:27.865469Z", + "shell.execute_reply": "2026-10-22T16:53:27.864338Z" } }, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_4/821233974.py:14: UserWarning: 5 of 5 active cells have at most 10 features in assay 'RNA'. Will not remove low quality cells automatically, because that would remove at least half of the active cells. A default assay with few features, such as a small antibody panel, does this; later opens of this store do not filter it again. If this is a raw (unfiltered) droplet matrix, most of its barcodes are empty droplets, and bounds computed over them keep the empty droplets and remove the real cells: call cells first with qc.call_cells, or import the filtered matrix (for example Cell Ranger's filtered_feature_bc_matrix), before QC filtering.\n", + " ds_csv = cytearc.DataStore(str(csv_zarr))\n" + ] + }, { "data": { "text/html": [ @@ -1475,17 +2282,17 @@ "4 True cell_4 cell_4 19.0 2.0 50" ] }, - "execution_count": 17, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" }, { "data": { "text/html": [ - "Started: 2026-10-21T12:14:54.713279Z | Wall time: 1.157 s" + "Started: 2026-10-22T16:53:27.185082Z | Wall time: 0.680 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:54.713279Z | Wall time: 1.157 s" + "Started: 2026-10-22T16:53:27.185082Z | Wall time: 0.680 s" ] }, "metadata": { @@ -1520,6 +2327,12 @@ "metadata": {}, "source": [ "`quality` is cell metadata rather than a count column, which is what `cell_data_cols` is for.\n", + "Opening the store also warns that all five cells have at most 10 features. The first open of a\n", + "store removes such cells from `I`, unless that would remove at least half of the active cells, as\n", + "here; then it keeps them all and warns instead. The warning is expected for a three-gene toy\n", + "matrix, and its advice about raw droplet matrices does not apply. Open a store whose default\n", + "assay has few features, such as an antibody panel, with `min_features_per_cell=-1`, as\n", + "[](hto_demultiplexing.ipynb) does.\n", "\n", "## Import sparse matrices\n", "\n", @@ -1528,14 +2341,14 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 21, "id": "b0f970c3", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:55.872568Z", - "iopub.status.busy": "2026-10-21T12:14:55.872232Z", - "iopub.status.idle": "2026-10-21T12:14:55.879108Z", - "shell.execute_reply": "2026-10-21T12:14:55.877882Z" + "iopub.execute_input": "2026-10-22T16:53:27.868288Z", + "iopub.status.busy": "2026-10-22T16:53:27.868028Z", + "iopub.status.idle": "2026-10-22T16:53:27.881322Z", + "shell.execute_reply": "2026-10-22T16:53:27.880344Z" } }, "outputs": [ @@ -1547,17 +2360,17 @@ " [ 0, 0, 3, 0, 0, 0, 0, 0, 1, 5]])" ] }, - "execution_count": 18, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" }, { "data": { "text/html": [ - "Started: 2026-10-21T12:14:55.872232Z | Wall time: 0.007 s" + "Started: 2026-10-22T16:53:27.868028Z | Wall time: 0.013 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:55.872232Z | Wall time: 0.007 s" + "Started: 2026-10-22T16:53:27.868028Z | Wall time: 0.013 s" ] }, "metadata": { @@ -1593,17 +2406,25 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 22, "id": "d35a5a69", "metadata": { "execution": { - "iopub.execute_input": "2026-10-21T12:14:55.881736Z", - "iopub.status.busy": "2026-10-21T12:14:55.881444Z", - "iopub.status.idle": "2026-10-21T12:14:56.254068Z", - "shell.execute_reply": "2026-10-21T12:14:56.252573Z" + "iopub.execute_input": "2026-10-22T16:53:27.883733Z", + "iopub.status.busy": "2026-10-22T16:53:27.883522Z", + "iopub.status.idle": "2026-10-22T16:53:28.184289Z", + "shell.execute_reply": "2026-10-22T16:53:28.183421Z" } }, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_4/2906446954.py:14: UserWarning: 3 of 3 active cells have at most 10 features in assay 'RNA'. Will not remove low quality cells automatically, because that would remove at least half of the active cells. A default assay with few features, such as a small antibody panel, does this; later opens of this store do not filter it again. If this is a raw (unfiltered) droplet matrix, most of its barcodes are empty droplets, and bounds computed over them keep the empty droplets and remove the real cells: call cells first with qc.call_cells, or import the filtered matrix (for example Cell Ranger's filtered_feature_bc_matrix), before QC filtering.\n", + " ds_sparse = cytearc.DataStore(str(sparse_zarr))\n" + ] + }, { "data": { "text/plain": [ @@ -1614,17 +2435,17 @@ " 'I', 'ids', 'names', 'dropOuts', 'nCells'" ] }, - "execution_count": 19, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" }, { "data": { "text/html": [ - "Started: 2026-10-21T12:14:55.881444Z | Wall time: 0.373 s" + "Started: 2026-10-22T16:53:27.883522Z | Wall time: 0.301 s" ], "text/plain": [ - "Started: 2026-10-21T12:14:55.881444Z | Wall time: 0.373 s" + "Started: 2026-10-22T16:53:27.883522Z | Wall time: 0.301 s" ] }, "metadata": { @@ -1658,6 +2479,9 @@ "id": "c0cd6555", "metadata": {}, "source": [ + "Opening this store gives the same warning for the same reason: each of its three cells has three\n", + "features.\n", + "\n", "For a complete `DataStoreMerge` example, continue to [](dataset_merging.ipynb).\n", "\n", "## Other import paths\n", @@ -1716,8 +2540,8 @@ ], "metadata": { "description": "Inspect and convert common count formats, then export complete or selected data.", - "executed_at": "2026-10-21T12:14:57.178616+00:00", - "executed_code_sha256": "b1b42a2ea82d51c0441cdf33a66fe95544509cf02c7885eec20b9fa0ddbeca53", + "executed_at": "2026-10-22T16:53:30.967831+00:00", + "executed_code_sha256": "1eec23353eb67895c0a7f57a20739c6fe0804be5a0875f79512219d66afb82f6", "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", diff --git a/skills/cytearc-single-cell/references/data-access.md b/skills/cytearc-single-cell/references/data-access.md index f58af74..da009b4 100644 --- a/skills/cytearc-single-cell/references/data-access.md +++ b/skills/cytearc-single-cell/references/data-access.md @@ -133,13 +133,21 @@ cytearc.MtxToZarr(reader, zarr_loc="mtx.zarr").dump() import h5py insp = cytearc.inspect_h5ad("data.h5ad") +# Review the counts: matrices with counts rank first (raw/X, layers/counts, X, spliced, ...), +# ties by estimated total; a warning lists competing count matrices. Re-inspect with +# matrix_key="layers/counts" (or another candidate) when matrixKey is not the raw RNA counts. print(insp.matrixKey, insp.matrixCandidates, insp.integerLike, insp.layers, insp.suggestedAssays) with h5py.File("data.h5ad", "r") as h5: # pass only keys the file has; a missing one is a KeyError obsm = set(h5["obsm"]) if "obsm" in h5 else set() obs = h5["obs"] obs_cols = set(obs) if isinstance(obs, h5py.Group) else set(obs.dtype.names) # old files print(sorted(obsm), sorted(obs_cols)) -roles = {k: r for k, r in {"X_umap": "umap", "X_tsne": "tsne"}.items() if k in obsm} +# "coordinates" imports a latent space (X_pca, X_scVI, ...) that graph.ann_index accepts +roles = { + k: r + for k, r in {"X_umap": "umap", "X_tsne": "tsne", "X_scVI": "coordinates"}.items() + if k in obsm +} kw = dict(embedding_roles=roles, cluster_keys=tuple(c for c in ("clusters",) if c in obs_cols)) reader = cytearc.H5adReader.from_inspect(insp, **kw) res = cytearc.H5adToZarr(reader, zarr_loc="h5ad.zarr").dump() @@ -173,7 +181,16 @@ reductions whose `assayUsed` you import. An active SCT assay holds corrected cou the default (`sct_default_assay` notice). `reduction_roles={"phate": "umap"}` overrides the UMAP/t-SNE-versus-coordinates role that the DimReduc key and name decide. -`CSVReader`/`CSVtoZarr` (small dense CSV) and `SparseToZarr` (SciPy CSR plus IDs) also exist. +`CSVReader`/`CSVtoZarr` (small dense CSV, ID column read as text) and `SparseToZarr` (any SciPy +sparse or dense matrix plus unique cell and feature IDs) also exist. + +H5AD categoricals keep their category order, unused categories, `ordered`, and +`uns["_colors"]` (stored as `levels`, `ordered`, and `display` attributes, which plots use +and exports restore). A var or obs index that is neither the ID nor the name column imports as +`source_index` unless it repeats the IDs or names. Missing feature names take the feature ID. +Repeated obs names (`anndata.concat` without `index_unique`): inspection warns, and +`H5adReader.from_inspect(insp, duplicate_cell_ids="make_unique")` renames later ones `ID-1`, ...; +record that choice. Repeated feature IDs (some exporters use gene symbols as IDs) raise `ValueError: ... must contain unique values`. Renaming them is an explicit choice: `duplicate_feature_ids="make_unique"` on @@ -309,9 +326,10 @@ raw = repo.download_dataset("xin_1K_pancreas_rnaseq", destination="cytearc_datas `analysis.artifacts.find("embedding", imported="X_umap", from_assay="RNA")` and plots work on it; new artifacts are written to the mount. - `to_pandas_dataframe(columns)` defaults to `key=None` (all rows); pass `key="I"` for active cells. -- `to_mtx(..., compress=True)` writes the feature `feature_type` column as the 10x feature type. - CELLxGENE stores keep gene biotypes there, so re-import split one assay into thousands. Inspect - `reader.assayFeats` before `dump()` or export with `compress=False`. +- `to_mtx` writes one feature type per exported assay in both modes: the `feature_type` column + when every feature shares a value, else `Gene Expression` for RNA and the assay name otherwise, + so CELLxGENE gene biotypes no longer split a re-import. Still inspect `reader.assayFeats` before + `dump()`. - Source column names containing `/` or `\` are stored with `_`. Reserved columns `ids`, `names`, `I` from a source are skipped. H5AD multi-assay import needs `assay_split_key` on `H5adToZarr`. - `_I` marks the cells an assay measured (after a merge of stores with different assays). diff --git a/skills/cytearc-single-cell/references/performance-and-export.md b/skills/cytearc-single-cell/references/performance-and-export.md index aee8d0c..3c322b1 100644 --- a/skills/cytearc-single-cell/references/performance-and-export.md +++ b/skills/cytearc-single-cell/references/performance-and-export.md @@ -148,22 +148,22 @@ so it never reports the job as gone. On macOS, `timeout` is `gtimeout` from GNU Run export uses the frozen cells and feature universe. `to_anndata(run=run)` holds `X` in memory; `cytearc.to_h5ad(..., run=run)` writes the same object, streams `X`, and needs no `anndata` package. -Frozen text fields that repeat or have missing values are pandas categoricals in natural order -(`d2` before `d10`) in the object and the file alike, so `adata.write_h5ad` keeps those categories. +Label fields (`clusters`, `leiden_*`, `paris`, `cell_cycle_phase`) are categoricals of string labels +in natural order with the run's colors in `uns["_colors"]`, so `sc.tl.rank_genes_groups` +and categorical plots work directly; other frozen text that repeats or has missing values is a +categorical in natural order (`d2` before `d10`) too. The run's PCA is `obsm["X_pca"]` with its +loadings in `varm["PCs"]` (Harmony: `obsm["X_pca_harmony"]`). `cell_type` below and in later recipes is a placeholder for your own label column, not held-out author labels. ```python -adata = ds.to_anndata(run=run) # obs: frozen fields; obsm: X_umap; X: raw counts +# obs: frozen fields; obsm: X_umap, X_pca; varm: PCs; obsp + uns["neighbors"]: the graph +adata = ds.to_anndata(run=run, graph=run["connectivity_map"]) live = ds.cells.to_pandas_dataframe(["ids", "cell_type", "RNA_nCounts"]).set_index("ids") adata.obs = adata.obs.join(live) # live labels are not in the run: join on cell ids -adata.obsm["X_pca"] = np.asarray(ds.artifacts.load(run["pca"])["data"][:]) # run analysis cells -graph = ds.graph.load(graph=run["connectivity_map"], symmetric=True, upper_only=False) -assert graph.shape[0] == adata.n_obs -adata.obsp["connectivities"] = graph adata.write_h5ad("baseline_run.h5ad") -cytearc.to_h5ad(ds.RNA, "baseline_run_plain.h5ad", run=run) # same object, streamed; no extras +cytearc.to_h5ad(ds.RNA, "baseline_run_plain.h5ad", run=run) # same object without the graph, streamed ``` `adata.var_names` are feature IDs; symbols are in `adata.var["names"]`. Pass @@ -174,10 +174,36 @@ exports (see `pipeline-runs-and-artifacts.md`). ```python cytearc.to_h5ad(ds.RNA, "full_assay.h5ad") # every cell, including I False; live metadata -cytearc.to_mtx(ds.RNA, "full_assay_mtx") # matrix.mtx, genes.tsv, barcodes.tsv +cytearc.to_mtx(ds.RNA, "full_assay_mtx") # matrix.mtx, genes.tsv (3 columns), barcodes.tsv cytearc.to_mtx(ds.RNA, "full_assay_mtx_gz", compress=True) # Cell Ranger 3 names, .gz ``` +### Export artifacts for Scanpy, and bring a latent space back + +```python +import pandas as pd + +adata = ds.to_anndata( + cell_selection=cells, # the selection the artifacts cover (each must hold every cell) + obsm={"X_pca": pca, "X_umap": umap}, # X_pca also writes varm["PCs"] + obs={"leiden": leiden, "s_score": (cell_cycle, "s_score")}, # labels become categoricals + graph=graph, # obsp connectivities/distances + uns["neighbors"] +) +cytearc.to_h5ad(ds.RNA, "analysis.h5ad", cell_selection=cells, obsm={"X_pca": pca}, graph=graph) + +latent = ds.reduction.import_coordinates( # e.g. adata.obsm["X_scVI"] from scvi-tools + pd.DataFrame(adata.obsm["X_scVI"], index=adata.obs_names), # aligned by cell id + cell_selection=cells, + name="X_scVI", + source={"tool": "scvi-tools", "version": "1.2.0", "parameters": {"n_latent": 30}}, +) +graph2 = ds.graph.connectivity(ds.graph.neighbors(ds.graph.ann_index(latent), k=11)) +``` + +Raw `uint8`/`uint16` counts export as `float32` by default (arithmetic on narrow integers wraps); +pass `dtype=` to `to_anndata` or `to_h5ad` to choose. Store columns named `_index` export as +`source_index`, and a column whose name the export already uses gets `_2`. + ### Export a gene panel or normalized values ```python @@ -254,9 +280,10 @@ contains no artifacts or pipeline runs. Use it to hand off, or before `to_mtx` o | Cells | the run's analysis selection | `to_h5ad(ds.RNA, path)` exports all rows | | Features | the full feature universe; HVG flag in `var["highly_variable_features"]`; `matrix="normed"`: HVGs only | `feature_names=` without `run` | | `X` | raw counts; `matrix="normed"`: the run's `normalized` artifact, float32 | | -| `obs` | frozen fields: cell-cycle, `leiden_*`, `paris`, `clusters`, `doublet_score`, `snapshot_columns` | join `ds.cells` by `ids` | -| `obsm` | `X_umap` | PCA: `ds.artifacts.load(run["pca"])["data"]` | -| `obsp`, markers, run report | not exported | `ds.graph.load`, `markers.load`, `run.report(format="markdown")` | +| `obs` | frozen fields: cell-cycle, `leiden_*`, `paris`, `clusters` (categoricals with `uns` colors), `doublet_score`, `snapshot_columns` | join `ds.cells` by `ids` | +| `obsm`, `varm` | `X_umap`, `X_pca` (`X_pca_harmony` with Harmony), `varm["PCs"]` | other artifacts: `obsm=` | +| `obsp` | with `graph=run["connectivity_map"]`: connectivities, distances, `uns["neighbors"]` | | +| markers, run report | not exported | `markers.load`, `run.report(format="markdown")` | | MTX | `to_mtx` has no `run=` | `SubsetZarr` with run cells, then `to_mtx` | ## Parameters that matter @@ -270,6 +297,9 @@ contains no artifacts or pipeline runs. Use it to hand off, or before `to_mtx` o | env `CYTEARC_SOURCE_CACHE_BYTES` | `2G` of `TMPDIR` per open composite with remote sources | lower it for a small or memory-backed `TMPDIR`; `0` reads the sources directly | | `to_anndata(matrix=)`, `to_h5ad(matrix=)` | `"raw"` | `"normed"`: with `run=`, the values its PCA read; `to_anndata` without `run=`, live `normed` | | `to_h5ad(embeddings_cols=)` | `["UMAP", "tSNE"]` live column prefixes | legacy stores only; rejected with `run=` | +| `to_anndata`/`to_h5ad` `obsm=`, `obs=`, `graph=` | `None` | export artifacts in Scanpy's layout; each must cover every exported cell | +| `to_anndata`/`to_h5ad` `cell_selection=` | `None` (`I` cells; `to_h5ad`: every cell) | the selection your artifacts cover; rejected with `run=` | +| `to_anndata`/`to_h5ad` `dtype=` | stored dtype, `uint8`/`uint16` as `float32` | a smaller or integer dtype for a size-sensitive hand-off | | `to_mtx(compress=)` | `False` | `True` for Cell Ranger 3 file names | | `markers.load` / `markers.export_csv` `min_score`, `min_frac_exp` | `0.25`, `0.2` | widen or narrow the exported marker list | | `compare.compute_bulk(aggr_type=, feature_label=, pseudo_reps=)` | `"mean"`, `"index"`, `1` | `"sum"` and `"id"` for count models; never `pseudo_reps` above 1 for inference | @@ -291,7 +321,8 @@ contains no artifacts or pipeline runs. Use it to hand off, or before `to_mtx` o - Every `to_anndata` call (panel, run, or full) materializes `X`; size it before exporting a large atlas. `to_h5ad` streams; without `run=` it writes every cell, inactive ones included. -- Run export rejects `from_assay`, `cell_key`, `feature_names`, and `feature_indexes`. +- Run export rejects `from_assay`, `cell_key`, `cell_selection`, `feature_names`, and + `feature_indexes`. `to_h5ad(run=)` also rejects `embeddings_cols`, `skip_recalc_nfeats=False`, and `nthreads`. - Run `obs` lacks live columns (author labels, QC) unless captured with `snapshot_columns`. - Live exports (`to_h5ad` and `to_anndata` without `run=`) keep the exported assay's membership @@ -312,8 +343,8 @@ contains no artifacts or pipeline runs. Use it to hand off, or before `to_mtx` o a pending derived assay (see `data-access.md`). - `to_anndata` raises `ImportError` without the `anndata` package (`pip install "cytearc[extra]"`); `to_h5ad` needs none, with or without `run=`. -- `to_mtx(compress=True)` on CELLxGENE stores writes gene biotypes as feature types; see - `data-access.md` before re-importing. +- `to_mtx` writes one feature type per assay, so CELLxGENE biotypes do not split a re-import; + see `data-access.md`. ## See also diff --git a/tests/smoke_workflow.py b/tests/smoke_workflow.py index bf4ea46..6194da1 100644 --- a/tests/smoke_workflow.py +++ b/tests/smoke_workflow.py @@ -84,7 +84,10 @@ def _check_run_export(path: Path, run: Any, matrix: str, counts: np.dtype) -> No assert h5["obsm/X_umap"].shape == (len(cells), 2), matrix assert int(h5["X/indptr"][-1]) == h5["X/data"].shape[0], matrix values = h5["X/data"][:] - expected = np.dtype(np.float32) if matrix == "normed" else counts + # Normalized values, and raw counts stored as uint8 or uint16, export as + # float32; raw counts of any other dtype keep it. + narrow = counts in (np.dtype(np.uint8), np.dtype(np.uint16)) + expected = np.dtype(np.float32) if matrix == "normed" or narrow else counts assert values.dtype == expected, (matrix, values.dtype) assert values.size and np.isfinite(values).all(), matrix diff --git a/tests/test_anndata_export.py b/tests/test_anndata_export.py index 9948c00..e6a8cd2 100644 --- a/tests/test_anndata_export.py +++ b/tests/test_anndata_export.py @@ -78,7 +78,11 @@ def _stream_blocks(self, **_kwargs): class _FrozenView: - """A run axis view over a table whose ``I`` column selects its rows.""" + """A run axis view over a table whose ``I`` column selects its rows. + + As a run's view does, it gives its ``clusters`` field a categorical + display: the run's labels with their colors. + """ def __init__(self, frame): self._frame = frame @@ -93,6 +97,18 @@ def fetch_all(self, column): def _selected_field(self, column): return self.fetch_all(column)[self._selected], None + def _field_display(self, column): + if column != "clusters": + return None + labels = sorted(set(self._frame[column].tolist())) + return { + "kind": "categorical", + "categories": [ + {"value": label, "label": str(label), "color": f"#00000{index}"} + for index, label in enumerate(labels) + ], + } + def _fake_run_store(monkeypatch, cell_frame: pd.DataFrame): """A presentation mixin and a fake completed run of three cells.""" @@ -113,6 +129,10 @@ def __init__(self, owner): ) ) + def __contains__(self, key): + # The fake run has no outputs, such as a PCA. + return False + store = _PresentationOperationsMixin() assay = SimpleNamespace( name="RNA", @@ -142,7 +162,12 @@ def test_to_anndata_materializes_frozen_pipeline_run_views(monkeypatch) -> None: np.testing.assert_array_equal(exported.X.toarray(), [[2], [6]]) assert list(exported.obs_names) == ["c0", "c2"] assert exported.obs["names"].tolist() == ["frozen-a", "frozen-c"] - assert exported.obs["clusters"].tolist() == [1, 1] + # Cluster labels are categoricals of text labels, as Scanpy groups by, + # with the run's display colors of the exported labels. + assert isinstance(exported.obs["clusters"].dtype, pd.CategoricalDtype) + assert exported.obs["clusters"].cat.categories.tolist() == ["1"] + assert exported.obs["clusters"].tolist() == ["1", "1"] + assert exported.uns["clusters_colors"].tolist() == ["#000000"] assert "umap_1" not in exported.obs assert "X_umap" not in exported.obsm assert list(exported.var_names) == ["g1"] @@ -459,7 +484,8 @@ def test_to_anndata_run_export_lifts_consecutive_umap_into_obsm(monkeypatch) -> exported = store.to_anndata(run=run) assert list(exported.obs_names) == ["c0", "c2"] - assert exported.obs["clusters"].tolist() == [1, 1] + assert exported.obs["clusters"].cat.categories.tolist() == ["1"] + assert exported.obs["clusters"].tolist() == ["1", "1"] assert "umap_1" not in exported.obs assert "umap_2" not in exported.obs np.testing.assert_allclose( @@ -504,7 +530,8 @@ def test_to_anndata_run_export_ignores_non_positive_umap_suffixes( assert "X_umap" not in exported.obsm assert "umap_0" in exported.obs - assert exported.obs["clusters"].tolist() == [1, 2] + assert exported.obs["clusters"].cat.categories.tolist() == ["1", "2"] + assert exported.obs["clusters"].tolist() == ["1", "2"] @pytest.mark.parametrize( @@ -825,9 +852,15 @@ def test_run_h5ad_needs_no_anndata_and_matches_to_anndata( assert sparse.isspmatrix_csr(written.X) assert written.X.dtype == expected.X.dtype np.testing.assert_array_equal(written.X.toarray(), expected.X.toarray()) - assert set(written.obsm) == {"X_umap"} + # The run's PCA is exported beside its UMAP, with its stored coordinates. + assert set(written.obsm) == {"X_umap", "X_pca"} assert written.obsm["X_umap"].dtype == expected.obsm["X_umap"].dtype np.testing.assert_array_equal(written.obsm["X_umap"], expected.obsm["X_umap"]) + np.testing.assert_array_equal( + written.obsm["X_pca"], + np.asarray(datastore.artifacts.load(run["pca"])["data"][:]), + ) + np.testing.assert_array_equal(written.obsm["X_pca"], expected.obsm["X_pca"]) assert written.obs_names.tolist() == expected.obs_names.tolist() assert written.var_names.tolist() == expected.var_names.tolist() assert written.obs["donor"].cat.categories.tolist() == [ @@ -856,6 +889,67 @@ def test_run_h5ad_needs_no_anndata_and_matches_to_anndata( np.testing.assert_array_equal(frame["m_text"].isna(), ~present) +@pytest.mark.slow +def test_run_export_holds_labels_as_categoricals_with_pca_and_graph( + exported_run, tmp_path +) -> None: + from cytearc.writers.export import _natural_key + + anndata = pytest.importorskip("anndata") + datastore, run = exported_run + graph = run["connectivity_map"] + + exported = datastore.to_anndata(run=run, graph=graph) + path = tmp_path / "run_graph.h5ad" + cytearc.to_h5ad(datastore.RNA, str(path), run=run, graph=graph) + written = anndata.read_h5ad(path) + + labels = [ + field + for field in run.cells.columns + if field == "clusters" or field.startswith("leiden_") + ] + assert "clusters" in labels and len(labels) >= 2 + for adata in (exported, written): + for field in labels: + values = [str(value) for value in run.cells.fetch(field).tolist()] + column = adata.obs[field] + # Scanpy's rank_genes_groups and categorical plots need these. + assert isinstance(column.dtype, pd.CategoricalDtype) + categories = sorted(set(values), key=_natural_key) + assert column.cat.categories.tolist() == categories + assert column.tolist() == values + display = run.cells._field_display(field) + colors = { + str(category["value"]): category["color"] + for category in display["categories"] + } + assert adata.uns[f"{field}_colors"].tolist() == [ + colors[category] for category in categories + ] + pca = datastore.artifacts.load(run["pca"]) + np.testing.assert_array_equal(adata.obsm["X_pca"], pca["data"][:]) + hvg = run.features.fetch("highly_variable_features") + np.testing.assert_array_equal(adata.varm["PCs"][hvg], pca["loadings"][:]) + assert not adata.varm["PCs"][~hvg].any() + n_cells = int(_EXPORTED_RUN_CELLS.sum()) + assert adata.obsp["connectivities"].shape == (n_cells, n_cells) + assert adata.obsp["distances"].getnnz(axis=1).tolist() == [5] * n_cells + assert adata.uns["neighbors"]["params"]["use_rep"] == "X_pca" + assert adata.uns["neighbors"]["params"]["n_neighbors"] == 6 + with pytest.raises(ValueError, match="obsm 'X_pca' names an array that the"): + datastore.to_anndata(run=run, obsm={"X_pca": run["pca"]}) + with pytest.raises(ValueError, match="cell_selection cannot be provided"): + cytearc.to_h5ad( + datastore.RNA, + str(tmp_path / "rejected.h5ad"), + run=run, + cell_selection=run["input_cell_selection"], + ) + with pytest.raises(ValueError, match="frozen run selection and assay"): + datastore.to_anndata(run=run, cell_selection=run["input_cell_selection"]) + + @pytest.mark.slow def test_normed_run_export_is_the_normalized_artifact(exported_run) -> None: datastore, run = exported_run diff --git a/tests/test_anndata_round_trip.py b/tests/test_anndata_round_trip.py new file mode 100644 index 0000000..60338a9 --- /dev/null +++ b/tests/test_anndata_round_trip.py @@ -0,0 +1,711 @@ +"""AnnData round trips: artifacts in exports and external coordinates in imports.""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from scipy.sparse import csr_matrix + +import cytearc +from cytearc.readers import H5adReader, inspect_h5ad +from cytearc.storage.artifacts import ( + artifact_group, + fingerprint_array, + fingerprint_strings, +) +from cytearc.storage.refs import ArtifactRef +from cytearc.writers import H5adToZarr + +anndata = pytest.importorskip("anndata") + +_N_CELLS = 48 +_N_GENES = 30 + + +def _source_counts() -> np.ndarray: + """Counts of two groups of cells that express different genes.""" + rng = np.random.default_rng(17) + rates = np.full((_N_CELLS, _N_GENES), 0.5) + rates[: _N_CELLS // 2, :10] = 6.0 + rates[_N_CELLS // 2 :, 10:20] = 6.0 + return rng.poisson(rates).astype(np.float32) + + +def _import_store(directory: Path) -> tuple[cytearc.DataStore, object]: + """Import an AnnData file with a UMAP into a store.""" + counts = _source_counts() + adata = anndata.AnnData( + X=csr_matrix(counts), + obs=pd.DataFrame(index=[f"cell{index}" for index in range(_N_CELLS)]), + var=pd.DataFrame(index=[f"gene{index}" for index in range(_N_GENES)]), + ) + adata.obsm["X_umap"] = np.column_stack( + [np.arange(_N_CELLS, dtype=np.float32), np.ones(_N_CELLS, dtype=np.float32)] + ) + path = directory / "source.h5ad" + adata.write_h5ad(path) + reader = H5adReader.from_inspect( + inspect_h5ad(str(path)), embedding_roles={"X_umap": "umap"} + ) + try: + result = H5adToZarr( + reader, str(directory / "store.zarr"), nthreads=1, analysis_assay="RNA" + ).dump() + finally: + reader.close() + store = cytearc.DataStore( + str(directory / "store.zarr"), min_features_per_cell=0, nthreads=1 + ) + return store, result + + +@pytest.fixture(scope="module") +def analysis(tmp_path_factory): + """A store with a PCA, a KNN graph, a connectivity map, and Leiden labels.""" + store, result = _import_store(tmp_path_factory.mktemp("round_trip")) + cells = store.snapshot_cell_selection("I") + hvgs = store.features.hvgs( + cells, top_n=20, min_cells=0, max_cells=np.inf, show_plot=False + ) + normalized = store.features.normalize(cells, hvgs) + pca = store.reduction.pca(normalized, dims=4) + ann = store.graph.ann_index(pca, ann_efc=40, ann_ef=40, ann_m=8) + neighbors = store.graph.neighbors(ann, k=5) + graph = store.graph.connectivity(neighbors) + leiden = store.clusters.leiden(graph, resolution=0.5) + return { + "store": store, + "import": result, + "cells": cells, + "hvgs": hvgs, + "pca": pca, + "neighbors": neighbors, + "graph": graph, + "leiden": leiden, + } + + +def _export(analysis, **kwargs): + return analysis["store"].to_anndata( + cell_selection=analysis["cells"], + obsm={ + "X_pca": analysis["pca"], + "X_umap": analysis["import"].embeddingArtifacts["X_umap"], + }, + obs={ + "leiden": analysis["leiden"], + "leiden_values": (analysis["leiden"], "values"), + }, + graph=analysis["graph"], + **kwargs, + ) + + +def _expected_graph(analysis) -> tuple[np.ndarray, np.ndarray]: + """Return the fuzzy-union connectivities and the KNN distances, densely.""" + store = analysis["store"] + directed = store.graph.load(analysis["graph"]).toarray() + union = directed + directed.T - directed * directed.T + knn = store.artifacts.load(analysis["neighbors"]) + indices = np.asarray(knn["indices"][:]).astype(np.int64) + distances = np.zeros((_N_CELLS, _N_CELLS), dtype=np.float32) + rows = np.repeat(np.arange(_N_CELLS), indices.shape[1]) + distances[rows, indices.ravel()] = np.asarray(knn["distances"][:]).ravel() + return union, distances + + +@pytest.mark.slow +def test_live_export_holds_artifacts_in_scanpy_layout(analysis, tmp_path) -> None: + store = analysis["store"] + exported = _export(analysis) + + pca = store.artifacts.load(analysis["pca"]) + np.testing.assert_array_equal(exported.obsm["X_pca"], pca["data"][:]) + np.testing.assert_array_equal( + exported.obsm["X_umap"], + store.artifacts.load(analysis["import"].embeddingArtifacts["X_umap"])["values"][ + : + ], + ) + # varm["PCs"] holds the loadings at the highly variable genes, and zeros + # at the genes the PCA did not use, as Scanpy stores them. + hvg_mask = np.asarray( + store.artifacts.load(analysis["hvgs"])["values"][:], dtype=bool + ) + assert hvg_mask.sum() == 20 + assert exported.varm["PCs"].shape == (_N_GENES, 4) + np.testing.assert_array_equal(exported.varm["PCs"][hvg_mask], pca["loadings"][:]) + assert not exported.varm["PCs"][~hvg_mask].any() + # uns["pca"] holds the variance and variance ratio that the PCA stored. + assert sorted(exported.uns["pca"]) == ["variance", "variance_ratio"] + np.testing.assert_array_equal(exported.uns["pca"]["variance"], pca["variance"][:]) + np.testing.assert_array_equal( + exported.uns["pca"]["variance_ratio"], pca["variance_ratio"][:] + ) + assert exported.uns["pca"]["variance"].shape == (4,) + # Labels are categoricals of text labels in natural order. + labels = store.artifacts.load_values(analysis["leiden"]).values + leiden = exported.obs["leiden"] + assert isinstance(leiden.dtype, pd.CategoricalDtype) + assert leiden.cat.categories.tolist() == [ + str(label) for label in sorted(set(labels.tolist())) + ] + assert leiden.tolist() == [str(label) for label in labels] + assert exported.obs["leiden_values"].tolist() == leiden.tolist() + # The graph in Scanpy's layout: symmetric connectivities, KNN distances + # without the cell itself, and the neighbors entry that points to them. + union, distances = _expected_graph(analysis) + connectivities = exported.obsp["connectivities"] + assert connectivities.format == "csr" + np.testing.assert_array_equal(connectivities.toarray(), union) + np.testing.assert_array_equal(connectivities.toarray(), connectivities.T.toarray()) + np.testing.assert_array_equal(exported.obsp["distances"].toarray(), distances) + assert (exported.obsp["distances"].getnnz(axis=1) == 5).all() + assert exported.uns["neighbors"] == { + "connectivities_key": "connectivities", + "distances_key": "distances", + "params": { + "n_neighbors": 6, + "method": "umap", + "metric": "euclidean", + "use_rep": "X_pca", + }, + } + assert exported.uns["cytearc"] == { + "artifacts": { + "obsm": { + "X_pca": analysis["pca"].to_dict(), + "X_umap": analysis["import"].embeddingArtifacts["X_umap"].to_dict(), + }, + "obs": { + "leiden": analysis["leiden"].to_dict(), + "leiden_values": {**analysis["leiden"].to_dict(), "value": "values"}, + }, + "obsp": {"connectivities": analysis["graph"].to_dict()}, + } + } + # AnnData writes the object, and reads it back unchanged. + exported.write_h5ad(tmp_path / "written.h5ad") + reread = anndata.read_h5ad(tmp_path / "written.h5ad") + np.testing.assert_array_equal(reread.obsp["connectivities"].toarray(), union) + assert reread.obs["leiden"].cat.categories.tolist() == ( + leiden.cat.categories.tolist() + ) + + +@pytest.mark.slow +def test_streamed_export_holds_what_the_in_memory_export_holds( + analysis, tmp_path, monkeypatch +) -> None: + import sys + + store = analysis["store"] + expected = _export(analysis) + path = tmp_path / "streamed.h5ad" + with monkeypatch.context() as patched: + # The streamed export needs no AnnData. + patched.setitem(sys.modules, "anndata", None) + cytearc.to_h5ad( + store.RNA, + str(path), + cell_selection=analysis["cells"], + obsm={ + "X_pca": analysis["pca"], + "X_umap": analysis["import"].embeddingArtifacts["X_umap"], + }, + obs={ + "leiden": analysis["leiden"], + "leiden_values": (analysis["leiden"], "values"), + }, + graph=analysis["graph"], + ) + written = anndata.read_h5ad(path) + + assert written.obs_names.tolist() == expected.obs_names.tolist() + for name in ("X_pca", "X_umap"): + np.testing.assert_array_equal(written.obsm[name], expected.obsm[name]) + np.testing.assert_array_equal(written.varm["PCs"], expected.varm["PCs"]) + for name in ("connectivities", "distances"): + np.testing.assert_array_equal( + written.obsp[name].toarray(), expected.obsp[name].toarray() + ) + pd.testing.assert_series_equal( + written.obs["leiden"], expected.obs["leiden"], check_index=False + ) + assert written.uns["neighbors"] == expected.uns["neighbors"] + assert written.uns["cytearc"] == expected.uns["cytearc"] + np.testing.assert_array_equal(written.X.toarray(), expected.X.toarray()) + + +@pytest.mark.slow +def test_empty_artifact_mappings_export_nothing_beside_the_cells( + analysis, tmp_path +) -> None: + store = analysis["store"] + cells = analysis["cells"] + plain = store.to_anndata(cell_selection=cells) + exported = store.to_anndata(cell_selection=cells, obsm={}, obs={}) + cytearc.to_h5ad(store.RNA, str(tmp_path / "plain.h5ad"), cell_selection=cells) + cytearc.to_h5ad( + store.RNA, str(tmp_path / "empty.h5ad"), cell_selection=cells, obsm={}, obs={} + ) + plain_written = anndata.read_h5ad(tmp_path / "plain.h5ad") + written = anndata.read_h5ad(tmp_path / "empty.h5ad") + + for adata, reference in ((exported, plain), (written, plain_written)): + # Empty mappings add no arrays, columns, or record of artifacts. + assert not adata.obsm and not adata.obsp and not adata.varm + assert "artifacts" not in adata.uns.get("cytearc", {}) + assert adata.obs.columns.tolist() == reference.obs.columns.tolist() + assert dict(adata.uns) == dict(reference.uns) + assert adata.obs_names.tolist() == reference.obs_names.tolist() + np.testing.assert_array_equal(adata.X.toarray(), reference.X.toarray()) + + +@pytest.mark.slow +def test_exports_reject_misaligned_and_malformed_artifacts(analysis, tmp_path): + store = analysis["store"] + cells = analysis["cells"] + graph = analysis["graph"] + pca = analysis["pca"] + leiden = analysis["leiden"] + # Labels and coordinates of some cells, which cannot cover every cell. + subset = store.select_cells(leiden, include=[1]) + n_subset = int(np.count_nonzero(store.artifacts.load(subset)["values"][:])) + assert 0 < n_subset < _N_CELLS + part = store.snapshot_cluster_labels(leiden, cell_selection=subset) + partial = store.reduction.import_coordinates( + np.ones((n_subset, 2)), cell_selection=subset, name="partial" + ) + with pytest.raises( + ValueError, + match=rf"obs 'part': the cluster_labels artifact holds values for " + rf"{n_subset} of the 48 exported cells\. Export the cells of its cell " + "selection, with cell_selection=", + ): + store.to_anndata(cell_selection=cells, obs={"part": part}) + with pytest.raises( + ValueError, match=r"obsm 'partial': the imported_coordinates artifact holds" + ): + store.to_anndata(cell_selection=cells, obsm={"partial": partial}) + # A graph is exported only over exactly its cells, never over a subset. + with pytest.raises( + ValueError, + match=rf"graph: the connectivity_map artifact covers 48 cells, and a graph " + rf"is exported only over exactly its cells; the export holds {n_subset} " + "other or more cells", + ): + store.to_anndata(cell_selection=subset, graph=graph) + # A subset of an artifact's cells is exported. + assert store.to_anndata(cell_selection=subset, obsm={"X_pca": pca}).obsm[ + "X_pca" + ].shape == (n_subset, 4) + over_cells = [ + ({"obsm": {"bad/name": pca}}, ValueError, "contains '/'"), + ({"obsm": {"": pca}}, ValueError, "names must be non-empty strings"), + ({"obsm": [pca]}, TypeError, "obsm must map names to artifact references"), + ({"obs": {"x": (leiden,)}}, TypeError, r"obs\['x'\] must be an ArtifactRef"), + ({"obs": {"x": pca}}, ValueError, "reduction coordinates belong in obsm"), + ({"obsm": {"x": leiden}}, ValueError, "is not two-dimensional; export it"), + ( + {"obs": {"x": analysis["import"].embeddingArtifacts["X_umap"]}}, + ValueError, + "is not one-dimensional; export it in obsm", + ), + ({"obs": {"x": (leiden, "nope")}}, ValueError, r"obs 'x': 'cluster_labels'"), + ({"obsm": {"x": (pca, "loadings")}}, ValueError, "export only their 'data'"), + ({"obs": {"I": leiden}}, ValueError, "obs 'I' names a column that the export"), + ({"graph": pca}, ValueError, "graph must be a connectivity_map"), + ({"graph": "graph"}, TypeError, "graph must be an ArtifactRef"), + ({"obsm": {"x": analysis["hvgs"]}}, ValueError, "'feature_selection'"), + ] + for kwargs, error, message in over_cells: + with pytest.raises(error, match=message): + store.to_anndata(cell_selection=cells, **kwargs) + with pytest.raises(ValueError, match="cell_key and cell_selection are mutually"): + store.to_anndata(cell_key="I", cell_selection=cells) + with pytest.raises(TypeError, match="cell_selection must be an ArtifactRef"): + store.to_anndata(cell_selection="I") + with pytest.raises(ValueError, match="cell_selection must be a cell_selection"): + store.to_anndata(cell_selection=pca) + with pytest.raises(MemoryError, match="Exporting the graph's 240 edges needs"): + cytearc.to_h5ad( + _small_budget(store).RNA, + str(tmp_path / "graph.h5ad"), + cell_selection=cells, + graph=graph, + ) + assert not (tmp_path / "graph.h5ad").exists() + + +def _small_budget(store): + """Open ``store`` again with a budget that cannot hold its graph.""" + return cytearc.DataStore( + store.zw.store, min_features_per_cell=0, nthreads=1, mem_budget=1000 + ) + + +@pytest.mark.slow +def test_imported_coordinates_feed_graph_construction(analysis) -> None: + store = analysis["store"] + cells = analysis["cells"] + exported = store.to_anndata(cell_selection=cells) + rng = np.random.default_rng(5) + latent = rng.normal(size=(_N_CELLS, 3)).astype(np.float32) + # Rows in another order, and a row of a cell outside the selection. + frame = pd.concat( + [ + pd.DataFrame(latent, index=exported.obs_names).iloc[::-1], + pd.DataFrame(np.zeros((1, 3), dtype=np.float32), index=["elsewhere"]), + ] + ) + assert (frame.dtypes == np.float32).all() + source = {"tool": "scvi-tools", "version": "1.2.0", "parameters": {"n": [3]}} + + ref = store.reduction.import_coordinates( + frame, cell_selection=cells, name="X_scVI", source=source + ) + + assert ref.kind == "imported_coordinates" and ref.assay == "RNA" + stored = store.artifacts.load(ref)["data"] + assert stored.dtype == np.float32 + np.testing.assert_array_equal(stored[:], latent) + status = store.artifacts.inspect(ref) + assert status.parameters["source"] == source + assert status.parameters["dimreduc_key"] == "X_scVI" + assert status.parameters["role"] == "coordinates" + assert status.inputs["payload_fingerprints"] == {"data": fingerprint_array(latent)} + assert status.inputs["source_digest"] == {"bytes_hex": fingerprint_array(latent)} + # A DataFrame of exactly the selected cells imports the same values. + assert ( + store.reduction.import_coordinates( + pd.DataFrame(latent, index=exported.obs_names), + cell_selection=cells, + name="X_scVI", + source=source, + ) + == ref + ) + # The same values in store order reuse the artifact. + assert ( + store.reduction.import_coordinates( + latent, cell_selection=cells, name="X_scVI", source=source + ) + == ref + ) + # Integer coordinates are stored as float64; another source is another + # artifact. + whole = store.reduction.import_coordinates( + np.arange(_N_CELLS * 2).reshape(_N_CELLS, 2), cell_selection=cells, name="X" + ) + assert store.artifacts.load(whole)["data"].dtype == np.float64 + assert "source" not in store.artifacts.inspect(whole).parameters + ann = store.graph.ann_index(ref, ann_efc=40, ann_ef=40, ann_m=8) + graph = store.graph.connectivity(store.graph.neighbors(ann, k=5)) + clusters = store.clusters.leiden(graph, resolution=0.5) + assert store.artifacts.load_values(clusters).values.shape == (_N_CELLS,) + # The graph is exported over the selection, with the imported + # coordinates as its representation. + exported = store.to_anndata(cell_selection=cells, obsm={"X_scVI": ref}, graph=graph) + assert exported.uns["neighbors"]["params"]["use_rep"] == "X_scVI" + + +@pytest.mark.slow +def test_import_coordinates_rejects_misaligned_values(analysis, tmp_path) -> None: + store = analysis["store"] + cells = analysis["cells"] + ids = store.cells.fetch_all("ids") + values = np.ones((_N_CELLS, 2)) + cases = [ + (values[:-1], ValueError, "have 47 rows; the cell selection holds 48 cells"), + (np.ones(_N_CELLS), ValueError, "must be two-dimensional; found 1"), + (np.ones((_N_CELLS, 0)), ValueError, "need at least one dimension"), + (np.full((_N_CELLS, 2), np.nan), ValueError, "only finite values"), + (np.full((_N_CELLS, 2), "a"), TypeError, "uses unsupported dtype"), + ( + pd.DataFrame(values[1:], index=ids[1:]), + ValueError, + "have no row for 1 of the 48 selected cells, such as 'cell0'", + ), + ( + pd.DataFrame(np.ones((2, 2)), index=["cell0", "cell0"]), + ValueError, + "repeat cell IDs, such as 'cell0'", + ), + ] + for coordinates, error, message in cases: + with pytest.raises(error, match=message): + store.reduction.import_coordinates( + coordinates, cell_selection=cells, name="bad" + ) + with pytest.raises(ValueError, match="name must be a non-empty string"): + store.reduction.import_coordinates(values, cell_selection=cells, name="") + with pytest.raises(TypeError, match=r"source\['tool'\] holds a object"): + store.reduction.import_coordinates( + values, cell_selection=cells, name="x", source={"tool": object()} + ) + with pytest.raises(ValueError, match=r"source\['n'\]\[0\] must be a finite"): + store.reduction.import_coordinates( + values, cell_selection=cells, name="x", source={"n": [float("nan")]} + ) + with pytest.raises(TypeError, match=r"source\['p'\] must have text keys"): + store.reduction.import_coordinates( + values, cell_selection=cells, name="x", source={"p": {1: 2}} + ) + with pytest.raises(TypeError, match="source must be a mapping"): + store.reduction.import_coordinates( + values, cell_selection=cells, name="x", source=["tool"] + ) + read_only = cytearc.DataStore( + store.zw.store, min_features_per_cell=0, nthreads=1, zarr_mode="r" + ) + with pytest.raises(PermissionError, match="import_coordinates"): + read_only.reduction.import_coordinates( + values * 7, cell_selection=cells, name="new" + ) + + +@pytest.mark.slow +def test_count_dtype_option_and_default(analysis, tmp_path) -> None: + store = analysis["store"] + assert store.RNA.rawData.dtype == np.uint8 + counts = _source_counts() + + default = store.to_anndata(layers={"raw": "RNA"}) + narrow = store.to_anndata(dtype="uint8", layers={"raw": "RNA"}) + + # Narrow unsigned counts export as float32, whose arithmetic cannot wrap. + assert default.X.dtype == np.float32 + assert default.layers["raw"].dtype == np.float32 + assert narrow.X.dtype == np.uint8 and narrow.layers["raw"].dtype == np.uint8 + np.testing.assert_array_equal(default.X.toarray(), counts) + np.testing.assert_array_equal(narrow.X.toarray(), counts) + assert (default.X * 300).max() == counts.max() * 300 + # Normalized values keep their dtype; dtype applies to raw counts only. + assert store.to_anndata(matrix="normed", dtype="uint8").X.dtype == np.float64 + # A dtype that cannot hold a count raises instead of wrapping. + assert counts.max() > 1 + with pytest.raises(OverflowError, match="Sparse values exceed"): + store.to_anndata(dtype=bool) + with pytest.raises(OverflowError, match="Sparse values exceed"): + cytearc.to_h5ad(store.RNA, str(tmp_path / "bool.h5ad"), dtype=bool) + assert not (tmp_path / "bool.h5ad").exists() + with pytest.raises(TypeError, match="dtype must be a boolean, integer"): + store.to_anndata(dtype="U4") + path = tmp_path / "counts.h5ad" + cytearc.to_h5ad(store.RNA, str(path)) + assert anndata.read_h5ad(path).X.dtype == np.float32 + cytearc.to_h5ad(store.RNA, str(path), dtype=np.uint16) + assert anndata.read_h5ad(path).X.dtype == np.uint16 + with pytest.raises(TypeError, match="dtype must be a boolean, integer"): + cytearc.to_h5ad(store.RNA, str(path), dtype="U4") + + +def test_export_count_dtype_policy() -> None: + from cytearc.writers.export import cast_counts, export_count_dtype + + assert export_count_dtype(np.uint8) == np.float32 + assert export_count_dtype(np.uint16) == np.float32 + assert export_count_dtype(np.uint32) == np.uint32 + assert export_count_dtype(np.float64) == np.float64 + assert export_count_dtype(np.uint16, "int32") == np.int32 + with pytest.raises(TypeError, match="not complex128"): + export_count_dtype(np.uint8, np.complex128) + matrix = csr_matrix(np.asarray([[0, 70_000]], dtype=np.uint32)) + assert cast_counts(matrix, np.dtype(np.uint32)) is matrix + assert cast_counts(matrix, np.dtype(np.float32)).toarray().tolist() == [ + [0.0, 70_000.0] + ] + with pytest.raises(OverflowError, match="exceed the range of the export dtype"): + cast_counts(matrix, np.dtype(np.float16)) + with pytest.raises(OverflowError, match="exceed the destination dtype"): + cast_counts(matrix, np.dtype(np.uint16)) + + +@pytest.fixture(scope="module") +def multimodal(tmp_path_factory): + """A store with RNA and ADT graphs over one selection, and their SNN graph.""" + from tests.storage_helpers import write_count_store + + rng = np.random.default_rng(23) + adt = rng.poisson(5.0, size=(_N_CELLS, 6)) + location = str(tmp_path_factory.mktemp("multimodal") / "store.zarr") + write_count_store(location, {"RNA": _source_counts(), "ADT": adt}, np.uint16) + store = cytearc.DataStore( + location, default_assay="RNA", min_features_per_cell=0, nthreads=1 + ) + cells = store.snapshot_cell_selection("I") + features = { + "RNA": store.features.hvgs( + cells, top_n=20, min_cells=0, max_cells=np.inf, show_plot=False + ), + "ADT": store.features.universe(from_assay="ADT"), + } + graphs = {} + for assay, selected in features.items(): + normalized = store.features.normalize(cells, selected) + pca = store.reduction.pca(normalized, dims=3) + ann = store.graph.ann_index(pca, ann_efc=40, ann_ef=40, ann_m=8) + graphs[assay] = store.graph.connectivity(store.graph.neighbors(ann, k=5)) + integrated = store.integration.modalities( + [graphs["RNA"], graphs["ADT"]], method="snn" + ) + return store, cells, integrated + + +@pytest.mark.slow +def test_integrated_graph_exports_connectivities_without_distances(multimodal): + store, cells, integrated = multimodal + + exported = store.to_anndata(cell_selection=cells, graph=integrated) + + directed = store.graph.load(integrated).toarray() + union = directed + directed.T - directed * directed.T + assert set(exported.obsp) == {"connectivities"} + np.testing.assert_array_equal(exported.obsp["connectivities"].toarray(), union) + k = int(store.artifacts.load(integrated).attrs["n_neighbors"]) + assert exported.uns["neighbors"] == { + "connectivities_key": "connectivities", + "params": {"n_neighbors": k + 1, "method": "snn"}, + } + + +@pytest.mark.slow +def test_pca_entries_follow_the_coordinates_that_hold_them(analysis) -> None: + from cytearc.embeddings.imported import write_imported_coordinates + + store = analysis["store"] + cells = analysis["cells"] + coordinates = np.arange(_N_CELLS * 2, dtype=np.float32).reshape(_N_CELLS, 2) + plain = store.reduction.import_coordinates( + coordinates, cell_selection=cells, name="plain" + ) + # Imported coordinates with loadings over two genes and a deviation per + # dimension, as a Seurat import stores them. + loadings = np.asarray([[1.0, 2.0], [3.0, 4.0]]) + feature_ids = np.asarray(["gene3", "gene0"]) + stdev = np.asarray([2.0, 0.5]) + with_loadings = write_imported_coordinates( + store.zw, + assay="RNA", + dimreduc_key="pca", + role="pca", + coordinates=coordinates + 1, + source_digest=b"\x01" * 32, + payload_fingerprints={ + "data": fingerprint_array(coordinates + 1), + "loadings": fingerprint_array(loadings), + "feature_ids": fingerprint_strings(feature_ids), + "stdev": fingerprint_array(stdev), + }, + source_cell_ids=store.cells.fetch_all("ids"), + cell_selection=cells, + loadings=loadings, + feature_ids=feature_ids, + stdev=stdev, + ) + + exported = store.to_anndata(cell_selection=cells, obsm={"X_pca": with_loadings}) + + pcs = np.zeros((_N_GENES, 2)) + pcs[3], pcs[0] = loadings + np.testing.assert_array_equal(exported.varm["PCs"], pcs) + np.testing.assert_array_equal(exported.uns["pca"]["variance"], [4.0, 0.25]) + without = store.to_anndata(cell_selection=cells, obsm={"X_pca": plain}) + assert "PCs" not in without.varm and "pca" not in without.uns + # A PCA from before 1.3.0 stored no variance, so it exports none. + earlier = store.reduction.pca( + store.features.normalize(cells, analysis["hvgs"]), dims=3 + ) + group = artifact_group(store.zw, earlier) + del group["variance"] + del group["variance_ratio"] + old_pca = store.to_anndata(cell_selection=cells, obsm={"X_pca": earlier}) + assert old_pca.varm["PCs"].shape == (_N_GENES, 3) and "pca" not in old_pca.uns + # A layout as X_pca has no loadings either. + layout = store.to_anndata( + cell_selection=cells, + obsm={"X_pca": analysis["import"].embeddingArtifacts["X_umap"]}, + ) + assert "PCs" not in layout.varm and "pca" not in layout.uns + + +@pytest.mark.slow +def test_exports_refuse_unavailable_and_damaged_artifacts(analysis) -> None: + from cytearc.storage.artifacts import artifact_group + + store = analysis["store"] + cells = analysis["cells"] + missing = ArtifactRef( + scope="assay", kind="cluster_labels", artifact_id="0" * 64, assay="RNA" + ) + with pytest.raises( + ValueError, match="The cluster_labels artifact is unavailable or incomplete" + ): + store.to_anndata(cell_selection=cells, obs={"x": missing}) + labels = store.snapshot_cluster_labels(analysis["leiden"], cell_selection=cells) + # A damaged artifact whose label array was deleted. + del artifact_group(store.zw, labels)["values"] + with pytest.raises( + ValueError, match="obs 'x': the cluster_labels artifact has no 'values' array" + ): + store.to_anndata(cell_selection=cells, obs={"x": labels}) + + +def test_plans_check_obsp_and_varm_shapes(tmp_path) -> None: + import dataclasses + + from cytearc.writers.export import ( + H5adColumn, + H5adExportPlan, + H5adMatrix, + write_h5ad_plan, + ) + + names = np.asarray(["a", "b"]) + plan = H5adExportPlan( + x=H5adMatrix( + shape=(2, 2), + dtype=np.dtype(np.int32), + blocks=lambda: iter([np.eye(2, dtype=np.int32)]), + ), + obs_index=H5adColumn("_index", lambda: (names, None)), + obs=(), + var_index=H5adColumn("_index", lambda: (names, None)), + var=(), + ) + path = tmp_path / "shapes.h5ad" + for changes, message in ( + ( + {"obsp": {"g": lambda: csr_matrix((2, 3))}}, + r"obsp 'g' has shape \(2, 3\); the export declares \(2, 2\)", + ), + ( + {"varm": {"PCs": lambda: np.zeros((3, 2))}}, + r"varm 'PCs' has shape \(3, 2\); the export declares 2 features", + ), + ( + {"varm": {"PCs": lambda: np.zeros(2)}}, + r"varm 'PCs' has shape \(2,\); the export declares 2 features", + ), + ): + with pytest.raises(ValueError, match=message): + write_h5ad_plan(dataclasses.replace(plan, **changes), path) + assert not path.exists() + write_h5ad_plan( + dataclasses.replace( + plan, + obsp={"g": lambda: csr_matrix(np.eye(2))}, + varm={"PCs": lambda: np.ones((2, 1))}, + uns={"flag": True, "name": "x", "nested": {"values": [1, 2]}}, + ), + path, + ) + exported = anndata.read_h5ad(path) + np.testing.assert_array_equal(exported.obsp["g"].toarray(), np.eye(2)) + np.testing.assert_array_equal(exported.varm["PCs"], np.ones((2, 1))) + assert np.asarray(exported.uns["flag"]).dtype == bool + assert bool(exported.uns["flag"]) is True + assert exported.uns["name"] == "x" + assert exported.uns["nested"]["values"].tolist() == [1, 2] diff --git a/tests/test_datastore_contracts.py b/tests/test_datastore_contracts.py index 79d355a..3ea4513 100644 --- a/tests/test_datastore_contracts.py +++ b/tests/test_datastore_contracts.py @@ -112,6 +112,7 @@ "show_zarr_tree": DataStore.show_zarr_tree, "smart_label": ClustersAccessor.relabel_by_overlap, "to_anndata": DataStore.to_anndata, + "import_coordinates": ReductionAccessor.import_coordinates, }, ArtifactAccessor: {"find": ArtifactAccessor.find}, } @@ -127,7 +128,9 @@ # run_hto_demultiplexing gained the keyword-only positive_quantile: float # with the default 0.99. auto_filter_cells gained the keyword-only # metric_policies: dict[str, dict[str, str]] | None with the default None. - DataStore: "a7a59b4a948dc14725f9f11daf11c904c61ea21172f009fb218183412ca1a3b7", + # to_anndata gained the keyword-only cell_selection, obsm, obs, graph, + # and dtype, each None by default, and reduction.import_coordinates is new. + DataStore: "e7d9a83a1c4ac84af55fe61bf1fe11ee344bb6ad12bcc030e929afd47c15f3ff", ArtifactAccessor: "c48b4badcb3fbd03d03326278eafae0cf429ac31e5d053390e60ed9316d6b54b", } @@ -158,7 +161,8 @@ def test_datastore_namespace_bindings_cover_all_moved_methods_and_find(): - assert len(_NAMESPACE_METHODS) == 74 + # reduction.import_coordinates joined the moved methods and find. + assert len(_NAMESPACE_METHODS) == 75 @pytest.mark.parametrize( diff --git a/tests/test_h5ad_import_fidelity.py b/tests/test_h5ad_import_fidelity.py new file mode 100644 index 0000000..7a0a668 --- /dev/null +++ b/tests/test_h5ad_import_fidelity.py @@ -0,0 +1,859 @@ +"""H5AD import fidelity: categoricals, colors, indexes, IDs, and matrix choice.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import h5py +import numpy as np +import pandas as pd +import pytest +import zarr +from loguru import logger +from scipy.sparse import csr_matrix + +import cytearc +from cytearc.metadata.artifacts import categorical_display +from cytearc.readers import H5adReader, inspect_h5ad +from cytearc.readers._h5ad_columns import ( + H5adCategories, + category_colors, + column_categories, + hex_color, +) +from cytearc.writers import H5adToZarr + +anndata = pytest.importorskip("anndata") + +_STAGES = ["early", "mid", "late", "unused"] +_STAGE_COLORS = ["#000000", "#888888", "#ff0000", "#00ff00"] + + +@contextmanager +def _warnings() -> Iterator[list[str]]: + messages: list[str] = [] + handler = logger.add(messages.append, level="WARNING", format="{message}") + try: + yield messages + finally: + logger.remove(handler) + + +def _write(path: Path, adata) -> str: + adata.write_h5ad(path) + return str(path) + + +def _import(path: str, destination: Path, **reader_kwargs): + """Import ``path`` with its inspected keys and open the store.""" + reader = H5adReader.from_inspect(inspect_h5ad(path), **reader_kwargs) + try: + result = H5adToZarr( + reader, + str(destination), + nthreads=1, + analysis_assay=( + "RNA" if reader.embeddingRoles or reader.clusterKeys else None + ), + ).dump() + finally: + reader.close() + store = cytearc.DataStore(str(destination), min_features_per_cell=0, nthreads=1) + return store, result + + +def _categorical_adata() -> "anndata.AnnData": + """Cells with an ordered stage, integer clusters with a missing label, and more.""" + n_cells = 8 + obs = pd.DataFrame(index=[f"cell{index}" for index in range(n_cells)]) + obs["stage"] = pd.Categorical( + ["late", "early", "mid", "late", "early", "mid", "late", "early"], + categories=_STAGES, + ordered=True, + ) + clusters = pd.Categorical([3, 1, 2, 3, 1, 2, 3, 1], categories=[3, 1, 2, 7]) + clusters[0] = np.nan + obs["clusters_int"] = clusters + obs["flag"] = pd.Categorical( + [True, False] * 4, categories=[True, False], ordered=False + ) + obs["level"] = pd.Categorical([1.5, 0.5] * 4, categories=[1.5, 0.5]) + obs["named"] = pd.Categorical(["x", "y"] * 4, categories=["x", "y"]) + obs["short"] = pd.Categorical(["p", "q"] * 4, categories=["p", "q", "r"]) + obs["plain"] = [f"text{index}" for index in range(n_cells)] + var = pd.DataFrame(index=[f"gene{index}" for index in range(4)]) + var["kind"] = pd.Categorical(["x", "y", "x", "y"], categories=["y", "x", "z"]) + counts = np.arange(n_cells * 4, dtype=np.float32).reshape(n_cells, 4) % 5 + adata = anndata.AnnData(X=csr_matrix(counts), obs=obs, var=var) + adata.uns["stage_colors"] = np.asarray(_STAGE_COLORS, dtype=object) + adata.uns["named_colors"] = ["red", "#00F"] + # Fewer colors than categories are not used, as Scanpy does not use them. + adata.uns["short_colors"] = ["#111111", "#222222"] + return adata + + +@pytest.fixture(scope="module") +def categorical_store(tmp_path_factory): + """A store imported from an H5AD file with many kinds of categoricals.""" + directory = tmp_path_factory.mktemp("categoricals") + source = _categorical_adata() + path = _write(directory / "source.h5ad", source) + store, _ = _import(path, directory / "store.zarr") + return source, path, store + + +def _generated_colors(levels: list) -> list[str]: + generated = { + category["value"]: category["color"] + for category in categorical_display(np.asarray(levels, dtype=object))[ + "categories" + ] + } + return [generated[level] for level in levels] + + +def test_import_keeps_categories_order_unused_levels_and_colors( + categorical_store, +) -> None: + _source, _path, store = categorical_store + root = zarr.open_group(store.zw.store, mode="r") + cells = root["cellData"] + + stage = dict(cells["stage"].attrs) + assert stage["levels"] == _STAGES + assert stage["ordered"] is True + assert stage["display"] == { + "kind": "categorical", + "categories": [ + {"value": level, "label": level, "color": color} + for level, color in zip(_STAGES, _STAGE_COLORS, strict=True) + ], + } + # The integer categories keep their type and source order; one label is + # missing, so the display names its missing color. + clusters = dict(cells["clusters_int"].attrs) + assert clusters["levels"] == [3, 1, 2, 7] + assert clusters["ordered"] is False + assert clusters["display"] == { + "kind": "categorical", + "categories": [ + {"value": level, "label": str(level), "color": color} + for level, color in zip( + [3, 1, 2, 7], _generated_colors([3, 1, 2, 7]), strict=True + ) + ], + "missing_label": "NA", + "missing_color": "#bdbdbd", + } + assert np.asarray(cells["clusters_int"][:]).tolist() == [0, 1, 2, 3, 1, 2, 3, 1] + assert dict(cells["flag"].attrs)["levels"] == [True, False] + assert dict(cells["level"].attrs)["levels"] == [1.5, 0.5] + # Named and short hexadecimal colors become #rrggbb. + assert [ + category["color"] for category in cells["named"].attrs["display"]["categories"] + ] == ["#ff0000", "#0000ff"] + assert [ + category["color"] for category in cells["short"].attrs["display"]["categories"] + ] == _generated_colors(["p", "q", "r"]) + # A text column that is not categorical gets no attributes. + assert "levels" not in cells["plain"].attrs + # Feature categoricals keep their categories, without a display. + kind = dict(root["RNA/featureData/kind"].attrs) + assert kind == {"levels": ["y", "x", "z"], "ordered": False} + + +def test_plots_read_imported_category_order_and_colors(categorical_store) -> None: + from cytearc.plotting._display import stored_categorical_scale + + _source, _path, store = categorical_store + scale = stored_categorical_scale(store, "stage") + + assert scale is not None + assert scale.order == tuple(_STAGES) + assert scale.palette == dict(zip(_STAGES, _STAGE_COLORS, strict=True)) + + +def _assert_categorical(series: pd.Series, source: pd.Series) -> None: + assert isinstance(series.dtype, pd.CategoricalDtype) + assert series.cat.categories.tolist() == source.cat.categories.tolist() + assert series.cat.ordered == source.cat.ordered + assert series.astype(object).where(series.notna(), None).tolist() == ( + source.astype(object).where(source.notna(), None).tolist() + ) + + +@pytest.mark.parametrize("route", ["to_anndata", "to_h5ad"]) +def test_exports_restore_imported_categoricals_and_colors( + categorical_store, tmp_path, route +) -> None: + source, _path, store = categorical_store + if route == "to_anndata": + exported = store.to_anndata() + # AnnData writes the categoricals and colors that the object holds. + exported.write_h5ad(tmp_path / "written.h5ad") + exported = anndata.read_h5ad(tmp_path / "written.h5ad") + else: + cytearc.to_h5ad(store.RNA, str(tmp_path / "streamed.h5ad")) + exported = anndata.read_h5ad(tmp_path / "streamed.h5ad") + + for column in ("stage", "clusters_int", "flag", "level", "named", "short"): + _assert_categorical(exported.obs[column], source.obs[column]) + _assert_categorical(exported.var["kind"], source.var["kind"]) + assert exported.uns["stage_colors"].tolist() == _STAGE_COLORS + assert exported.uns["named_colors"].tolist() == ["#ff0000", "#0000ff"] + assert exported.uns["clusters_int_colors"].tolist() == _generated_colors( + [3, 1, 2, 7] + ) + assert not isinstance(exported.obs["plain"].dtype, pd.CategoricalDtype) + assert exported.obs["plain"].tolist() == source.obs["plain"].tolist() + + +def test_export_appends_values_that_the_stored_levels_omit(tmp_path) -> None: + from cytearc.writers.export import H5adCategorical, _explicit_categorical + + with _warnings() as messages: + categorical, colors = _explicit_categorical( + "stage", + np.asarray(["b", "z", "a", "y", ""]), + np.asarray([False, False, False, False, True]), + H5adCategorical( + levels=("b", "a"), ordered=True, colors={"b": "#000001", "a": "#000002"} + ), + ) + + assert categorical.categories.tolist() == ["b", "a", "y", "z"] + assert categorical.ordered + assert categorical.astype(object).tolist()[:4] == ["b", "z", "a", "y"] + assert pd.isna(categorical[4]) + # Not every category has a color, so none are written. + assert colors is None + assert any( + "holds 2 values that are not among its stored categories, such as 'y'" in m + for m in messages + ) + + +def test_cluster_labels_keep_categories_and_export_with_them(tmp_path) -> None: + source = _categorical_adata() + path = _write(tmp_path / "clusters.h5ad", source) + store, result = _import( + path, tmp_path / "store.zarr", cluster_keys=("stage", "plain") + ) + ref = result.clusterArtifacts["stage"] + # Labels that were not a categorical keep no categories. + assert "levels" not in dict( + store.artifacts.load(result.clusterArtifacts["plain"])["values"].attrs + ) + + attributes = dict(store.artifacts.load(ref)["values"].attrs) + assert attributes["levels"] == _STAGES + assert attributes["ordered"] is True + assert [ + category["color"] for category in attributes["display"]["categories"] + ] == _STAGE_COLORS + exported = store.to_anndata( + cell_selection=result.cellSelection, obs={"stage_labels": ref} + ) + _assert_categorical(exported.obs["stage_labels"], source.obs["stage"]) + assert exported.uns["stage_labels_colors"].tolist() == _STAGE_COLORS + + +def test_categoricals_with_many_categories_keep_no_levels(tmp_path, monkeypatch): + import cytearc.writers.h5ad as h5ad_writer + + monkeypatch.setattr(h5ad_writer, "_MAX_STORED_CATEGORIES", 3) + source = _categorical_adata() + store, _ = _import(_write(tmp_path / "many.h5ad", source), tmp_path / "s.zarr") + cells = zarr.open_group(store.zw.store, mode="r")["cellData"] + + # Four categories exceed the limit; two do not. + assert "levels" not in cells["stage"].attrs + assert cells["named"].attrs["levels"] == ["x", "y"] + + +@pytest.mark.parametrize( + ("dtype", "categories", "levels"), + [ + (np.dtype("U3"), [b"a", 1, "b"], ["a", "1", "b"]), + (np.dtype(bool), [True, False], [True, False]), + (np.dtype(np.int64), [3, 1], [3, 1]), + (np.dtype(np.uint8), [3, 1], [3, 1]), + (np.dtype(np.float64), [1, 0.5], [1.0, 0.5]), + # Categories that the column cannot hold exactly keep no levels. + (np.dtype(np.int64), ["a", 1], None), + (np.dtype(np.float64), [float("inf")], None), + (np.dtype(np.float64), [True], None), + (np.dtype("M8[ns]"), [1], None), + ], +) +def test_stored_levels_follow_the_stored_column_type(dtype, categories, levels): + from cytearc.writers.h5ad import _stored_levels + + assert _stored_levels(dtype, categories) == levels + + +def test_display_metadata_that_would_be_invalid_is_not_stored() -> None: + from cytearc.writers.h5ad import _categorical_attributes, _categorical_display + + # Two categories that compare equal cannot both be display values. + assert _categorical_display([1, 1.0], None, False) is None + attributes = _categorical_attributes( + np.dtype(np.float64), + H5adCategories((1, 1.0), False), + colors=None, + has_missing=False, + display=True, + ) + assert attributes == {"levels": [1.0, 1.0], "ordered": False} + + +@pytest.mark.parametrize( + ("text", "color"), + [ + ("#ABC", "#aabbcc"), + ("#abcd", "#aabbcc"), + ("#1F77B4", "#1f77b4"), + ("#1f77b4ff", "#1f77b4"), + (" #1f77b4 ", "#1f77b4"), + ("red", "#ff0000"), + ("tab:blue", "#1f77b4"), + ("#12345", None), + ("#", None), + ("#ggg", None), + ("not a color", None), + ], +) +def test_hex_color_reads_hexadecimal_and_named_colors(text, color) -> None: + assert hex_color(text) == color + + +def test_hex_color_needs_matplotlib_only_for_named_colors(monkeypatch) -> None: + import sys + + monkeypatch.setitem(sys.modules, "matplotlib.colors", None) + assert hex_color("#00ff00") == "#00ff00" + assert hex_color("red") is None + + +def test_category_colors_follow_scanpy_rules(tmp_path) -> None: + path = tmp_path / "colors.h5" + with h5py.File(path, "w") as h5: + uns = h5.create_group("uns") + uns.create_dataset("a_colors", data=np.asarray([b"#000000", b"#ffffff"])) + uns.create_dataset("b_colors", data=np.asarray([1, 2])) + uns.create_dataset("c_colors", data=np.asarray([b"#000000", b"nonsense"])) + uns.create_dataset("d_colors", data=np.zeros((2, 2))) + uns.create_group("e_colors") + with h5py.File(path, "r") as h5: + # More colors than categories: the first ones are used. + assert category_colors(h5, "a", 1) == ("#000000",) + assert category_colors(h5, "a", 2) == ("#000000", "#ffffff") + assert category_colors(h5, "a", 3) is None + assert category_colors(h5, "a", 0) is None + assert category_colors(h5, "b", 2) is None + assert category_colors(h5, "c", 2) is None + assert category_colors(h5, "d", 2) is None + assert category_colors(h5, "e", 1) is None + assert category_colors(h5, "missing", 1) is None + + +def test_legacy_categoricals_keep_their_categories(tmp_path) -> None: + path = tmp_path / "legacy.h5" + with h5py.File(path, "w") as h5: + obs = h5.create_group("obs") + obs.create_dataset("batch", data=np.asarray([1, 0, 1], dtype=np.int8)) + obs.create_dataset("score", data=np.asarray([0.5, 1.5, 2.5])) + obs.create_dataset("other", data=np.asarray([0, 1, 1], dtype=np.int8)) + # AnnData 0.6 categories of a group's column, kept in uns. + obs.create_dataset("donor", data=np.asarray([1, 1, 0], dtype=np.int8)) + categories = obs.create_group("__categories") + ordered = categories.create_dataset("batch", data=np.asarray([b"B", b"A"])) + ordered.attrs["ordered"] = True + compound = np.zeros( + 2, dtype=[("index", "S2"), ("group", "i1"), ("value", "f8")] + ) + compound["group"] = [0, 1] + h5.create_dataset("var", data=compound) + uns = h5.create_group("uns") + uns.create_dataset("group_categories", data=np.asarray([b"g", b"h"])) + uns.create_dataset("donor_categories", data=np.asarray([b"d1", b"d2"])) + nullable = obs.create_group("nullable") + nullable.create_dataset("values", data=np.asarray([1, 2, 3])) + nullable.create_dataset("mask", data=np.asarray([False, False, True])) + with h5py.File(path, "r") as h5: + groups = ("__categories",) + assert column_categories(h5, h5["obs"], "batch", groups) == H5adCategories( + ("B", "A"), True + ) + assert column_categories(h5, h5["obs"], "donor", groups) == H5adCategories( + ("d1", "d2"), False + ) + assert column_categories(h5, h5["obs"], "score", groups) is None + # Integer codes without stored categories are plain integers. + assert column_categories(h5, h5["obs"], "other", groups) is None + assert column_categories(h5, h5["obs"], "nullable", groups) is None + # AnnData 0.6 kept the categories of a compound column in uns. + assert column_categories(h5, h5["var"], "group", groups) == H5adCategories( + ("g", "h"), False + ) + assert column_categories(h5, h5["var"], "value", groups) is None + + +@pytest.mark.filterwarnings("ignore:Observation names are not unique") +def test_index_that_repeats_the_names_is_left_out_and_exports_work(tmp_path): + n_cells, n_genes = 6, 4 + var = pd.DataFrame(index=[f"SYM{index}" for index in range(n_genes)]) + var["gene_ids"] = [f"ENSG{index}" for index in range(n_genes)] + var["gene_name"] = [f"SYM{index}" for index in range(n_genes)] + obs = pd.DataFrame(index=[f"c{index % 3}" for index in range(n_cells)]) + obs["barcode"] = [f"bc{index}" for index in range(n_cells)] + counts = csr_matrix(np.arange(n_cells * n_genes).reshape(n_cells, n_genes) % 3) + path = _write( + tmp_path / "index.h5ad", + anndata.AnnData(X=counts.astype(np.float32), obs=obs, var=var), + ) + inspection = inspect_h5ad(path) + assert (inspection.featureIdsKey, inspection.featureNameKey) == ( + "gene_ids", + "gene_name", + ) + assert inspection.cellIdsKey == "barcode" + + store, _ = _import(path, tmp_path / "store.zarr") + + # The var index holds the names, so it is left out; the obs index holds + # other values and imports as source_index, never as _index. + assert set(store.RNA.feats.columns) == {"I", "ids", "names", "dropOuts", "nCells"} + assert "_index" not in store.cells.columns + assert store.cells.fetch_all("source_index").tolist() == list(obs.index) + cytearc.to_h5ad(store.RNA, str(tmp_path / "streamed.h5ad")) + streamed = anndata.read_h5ad(tmp_path / "streamed.h5ad") + assert streamed.var["gene_short_name"].tolist() == list(var["gene_name"]) + assert streamed.obs["source_index"].tolist() == list(obs.index) + store.to_anndata().write_h5ad(tmp_path / "written.h5ad") + assert anndata.read_h5ad(tmp_path / "written.h5ad").obs_names.tolist() == list( + obs["barcode"] + ) + + +def test_unnamed_index_imports_under_a_free_source_index_name(tmp_path) -> None: + n_genes = 3 + var = pd.DataFrame(index=[f"alias{index}" for index in range(n_genes)]) + var["gene_ids"] = [f"ENSG{index}" for index in range(n_genes)] + var["gene_name"] = [f"SYM{index}" for index in range(n_genes)] + var["source_index"] = ["taken"] * n_genes + obs = pd.DataFrame(index=["a", "b"]) + path = _write( + tmp_path / "alias.h5ad", + anndata.AnnData( + X=csr_matrix(np.ones((2, n_genes), dtype=np.float32)), obs=obs, var=var + ), + ) + store, _ = _import(path, tmp_path / "store.zarr") + + assert store.RNA.feats.fetch_all("source_index_2").tolist() == list(var.index) + assert store.RNA.feats.fetch_all("source_index").tolist() == ["taken"] * n_genes + + +def test_missing_feature_names_take_the_feature_ids(tmp_path) -> None: + var = pd.DataFrame(index=[f"ENSG{index}" for index in range(5)]) + names = pd.Categorical(["A", None, "C", "D", None]) + var["gene_symbols"] = names + path = _write( + tmp_path / "names.h5ad", + anndata.AnnData( + X=csr_matrix(np.ones((2, 5), dtype=np.float32)), + obs=pd.DataFrame(index=["a", "b"]), + var=var, + ), + ) + with _warnings() as messages: + store, _ = _import(path, tmp_path / "store.zarr") + + assert store.RNA.feats.fetch_all("names").tolist() == [ + "A", + "ENSG1", + "C", + "D", + "ENSG4", + ] + assert ( + sum( + "2 feature names in 'gene_symbols' are missing or blank" in message + for message in messages + ) + == 1 + ) + + +def _duplicated_cells(tmp_path: Path, **columns) -> str: + obs = pd.DataFrame(index=["c0", "c1", "c0", "c2"]) + for name, values in columns.items(): + obs[name] = values + adata = anndata.AnnData( + X=csr_matrix(np.arange(8, dtype=np.float32).reshape(4, 2)), + obs=obs, + var=pd.DataFrame(index=["g0", "g1"]), + ) + adata.obsm["X_umap"] = np.arange(8, dtype=np.float32).reshape(4, 2) + return _write(tmp_path / "duplicates.h5ad", adata) + + +@pytest.mark.filterwarnings("ignore:Observation names are not unique") +def test_repeated_cell_ids_import_only_when_renamed_on_request(tmp_path) -> None: + # A unique float score and a unique count are not IDs. + path = _duplicated_cells( + tmp_path, score=[0.1, 0.2, 0.3, 0.4], n_counts=[10, 20, 30, 40] + ) + with _warnings() as messages: + inspection = inspect_h5ad(path) + + assert inspection.cellIdsKey == "_index" + assert any( + "The cell IDs in the obs index '_index' repeat values" in message + and "duplicate_cell_ids='make_unique'" in message + for message in messages + ) + assert not any("generated IDs" in message for message in messages) + reader = H5adReader.from_inspect(inspection) + try: + with pytest.raises( + ValueError, + match="H5AD cell IDs must contain unique values; pass " + "duplicate_cell_ids='make_unique' to rename repeated IDs", + ): + reader.cell_ids() + finally: + reader.close() + + store, result = _import( + path, + tmp_path / "store.zarr", + duplicate_cell_ids="make_unique", + embedding_roles={"X_umap": "umap"}, + ) + assert store.cells.fetch_all("ids").tolist() == ["c0", "c1", "c0-1", "c2"] + # The embedding aligns to the renamed IDs. + np.testing.assert_array_equal( + store.artifacts.load(result.embeddingArtifacts["X_umap"])["values"][:], + np.arange(8, dtype=np.float32).reshape(4, 2), + ) + + +@pytest.mark.filterwarnings("ignore:Observation names are not unique") +def test_duplicate_cell_ids_option_is_validated(tmp_path) -> None: + path = _duplicated_cells(tmp_path) + with pytest.raises( + ValueError, match="duplicate_cell_ids must be 'error' or 'make_unique'" + ): + H5adReader(path, duplicate_cell_ids="unique") # type: ignore[arg-type] + reader = H5adReader(path, duplicate_cell_ids="make_unique") + try: + assert reader._clone_kwargs()["duplicate_cell_ids"] == "make_unique" + finally: + reader.close() + + +@pytest.mark.filterwarnings("ignore:Observation names are not unique") +def test_preferred_integer_cell_ids_are_identifiers(tmp_path) -> None: + path = _duplicated_cells(tmp_path, cell_id=[5, 6, 7, 8]) + + assert inspect_h5ad(path).cellIdsKey == "cell_id" + + +def _write_matrices(path: Path, matrices: dict, n_cells: int, n_genes: int) -> str: + """Write an H5AD file whose ``matrices`` are its candidate count matrices.""" + adata = anndata.AnnData( + X=matrices.pop("X", None), + obs=pd.DataFrame(index=[f"c{index}" for index in range(n_cells)]), + var=pd.DataFrame(index=[f"g{index}" for index in range(n_genes)]), + ) + for name, values in matrices.items(): + adata.layers[name] = values + return _write(path, adata) + + +def test_inspection_imports_total_counts_of_velocity_data(tmp_path) -> None: + rng = np.random.default_rng(0) + spliced = rng.poisson(3, size=(30, 10)) + unspliced = rng.poisson(1, size=(30, 10)) + ambiguous = rng.poisson(0.2, size=(30, 10)) + + def normalized(values): + totals = values.sum(axis=1, keepdims=True).clip(1) + return csr_matrix(np.log1p(values / totals * 1e4).astype(np.float32)) + + path = _write_matrices( + tmp_path / "velocity.h5ad", + { + "X": normalized(spliced + unspliced), + "spliced": normalized(spliced), + "unspliced": normalized(unspliced), + "ambiguous": csr_matrix(ambiguous), + "matrix": csr_matrix(spliced + unspliced + ambiguous), + }, + 30, + 10, + ) + with _warnings() as messages: + inspection = inspect_h5ad(path) + + # Before, ties fell to the alphabetically first layer, the ambiguous reads. + assert inspection.matrixKey == "layers/matrix" + assert inspection.matrixCandidates[:2] == ("layers/matrix", "layers/ambiguous") + (warning,) = [ + m.rstrip() for m in messages if "Several matrices hold integer counts" in m + ] + assert warning.startswith( + "Several matrices hold integer counts: layers/matrix (about " + ) + assert "layers/ambiguous (about " in warning + assert warning.endswith( + "inspect_h5ad chose layers/matrix; review the choice and pass matrix_key " + "to choose another" + ) + with _warnings() as forced: + assert ( + inspect_h5ad(path, matrix_key="layers/ambiguous").matrixKey + == "layers/ambiguous" + ) + assert not any("Several matrices" in message for message in forced) + + +@pytest.mark.parametrize( + ("layers", "chosen"), + [ + # A counts layer outranks integer X, which outranks spliced counts. + ({"X": 2, "counts": 1, "spliced": 3}, "layers/counts"), + ({"X": 1, "spliced": 3, "unspliced": 5}, "X"), + ({"spliced": 1, "unspliced": 5, "ambiguous": 9}, "layers/spliced"), + # Matrices of one tier rank by their total counts. + ({"first": 1, "second": 4}, "layers/second"), + ({"raw_counts": 1, "counts": 3}, "layers/counts"), + ], +) +def test_inspection_prefers_conventional_count_names(tmp_path, layers, chosen): + rng = np.random.default_rng(1) + matrices = { + name: csr_matrix(rng.poisson(scale, size=(20, 6)).astype(np.float32)) + for name, scale in layers.items() + } + path = _write_matrices(tmp_path / "counts.h5ad", matrices, 20, 6) + + assert inspect_h5ad(path).matrixKey == chosen + + +def test_inspection_skips_a_null_x(tmp_path) -> None: + rng = np.random.default_rng(2) + counts = csr_matrix(rng.poisson(2, size=(20, 6)).astype(np.float32)) + path = _write_matrices(tmp_path / "null_x.h5ad", {"counts": counts}, 20, 6) + # anndata before 0.13 writes X=None as a null dataspace without a shape. + with h5py.File(path, "r+") as h5: + if "X" in h5: + del h5["X"] + h5.create_dataset("X", data=h5py.Empty("f4")) + h5["X"].attrs["encoding-type"] = "null" + h5["X"].attrs["encoding-version"] = "0.1.0" + + assert inspect_h5ad(path).matrixKey == "layers/counts" + + +def test_inspection_samples_values_across_the_whole_matrix(tmp_path) -> None: + import cytearc.readers._h5ad_inspect as inspection_module + + # Whole values first, fractions after them: the first 101 values, which + # inspection used to read, are all whole. + values = np.ones((40, 50), dtype=np.float32) + values[20:] = 0.5 + dense = values.copy() + dense[:10] = 0 + path = tmp_path / "sampled.h5ad" + with h5py.File(path, "w") as h5: + matrix = csr_matrix(values) + group = h5.create_group("X") + group.attrs.update({"encoding-type": "csr_matrix", "shape": matrix.shape}) + group.create_dataset("data", data=matrix.data) + group.create_dataset("indices", data=matrix.indices) + group.create_dataset("indptr", data=matrix.indptr) + h5.create_group("layers").create_dataset("dense", data=dense) + h5.create_group("obs").create_dataset( + "_index", data=np.asarray([f"c{index}".encode() for index in range(40)]) + ) + + with h5py.File(path, "r") as h5: + candidates = { + candidate.key: candidate + for candidate in inspection_module._matrix_candidates(h5) + } + assert candidates["X"].integerLike is False + assert candidates["layers/dense"].integerLike is False + + +@pytest.mark.parametrize( + ("values", "integer_like", "total"), + [ + (np.asarray([1, 2, 3], dtype=np.int32), True, 6.0), + (np.asarray([1, -2, 3], dtype=np.int32), False, 0.0), + (np.asarray([1.0, 2.0, np.nan]), False, 0.0), + (np.asarray([1.0, 2.0, -3.0]), False, 0.0), + (np.asarray([], dtype=np.float32), False, 0.0), + ], +) +def test_sampled_counts_must_be_finite_non_negative_and_whole( + tmp_path, values, integer_like, total +): + from cytearc.readers._h5ad_inspect import _sparse_counts + + path = tmp_path / "values.h5" + with h5py.File(path, "w") as h5: + h5.create_dataset("data", data=values) + with h5py.File(path, "r") as h5: + assert _sparse_counts(h5["data"]) == (integer_like, total) + + +def test_sample_windows_spread_over_the_values() -> None: + from cytearc.readers._h5ad_inspect import _sample_windows + + assert _sample_windows(0, 4) == [] + assert _sample_windows(3, 10) == [slice(0, 3)] + windows = _sample_windows(100_000, 1024) + assert len(windows) == 32 + assert windows[0] == slice(0, 1024) + assert windows[-1] == slice(100_000 - 1024, 100_000) + + +def test_dense_matrix_without_columns_holds_no_counts(tmp_path) -> None: + from cytearc.readers._h5ad_inspect import _dense_counts + + path = tmp_path / "dense.h5" + with h5py.File(path, "w") as h5: + h5.create_dataset("wide", data=np.zeros((3, 0))) + h5.create_dataset("counts", data=np.asarray([[1, 2], [3, 4]], dtype=np.uint8)) + with h5py.File(path, "r") as h5: + assert _dense_counts(h5["wide"]) == (False, 0.0) + assert _dense_counts(h5["counts"]) == (True, 10.0) + + +def test_coordinates_role_imports_graph_coordinates(tmp_path) -> None: + rng = np.random.default_rng(3) + latent = rng.normal(size=(12, 3)).astype(np.float32) + adata = anndata.AnnData( + X=csr_matrix(rng.poisson(2, size=(12, 5)).astype(np.float32)), + obs=pd.DataFrame(index=[f"c{index}" for index in range(12)]), + var=pd.DataFrame(index=[f"g{index}" for index in range(5)]), + ) + adata.obsm["X_scVI"] = latent + path = _write(tmp_path / "latent.h5ad", adata) + store, result = _import( + path, tmp_path / "store.zarr", embedding_roles={"X_scVI": "coordinates"} + ) + ref = result.embeddingArtifacts["X_scVI"] + + assert ref.kind == "imported_coordinates" + np.testing.assert_array_equal(store.artifacts.load(ref)["data"][:], latent) + status = store.artifacts.inspect(ref) + assert status.parameters["role"] == "coordinates" + assert status.parameters["dimreduc_key"] == "X_scVI" + ann = store.graph.ann_index(ref, ann_efc=10, ann_ef=10, ann_m=4) + neighbors = store.graph.neighbors(ann, k=3) + assert store.artifacts.load(neighbors)["indices"].shape == (12, 3) + + +@pytest.mark.parametrize( + ("name", "missing"), + [ + (None, True), + (float("nan"), True), + (np.float32("nan"), True), + (1.5, False), + (b" ", True), + ("", True), + ("GENE", False), + (b"GENE", False), + ], +) +def test_feature_names_that_count_as_missing(name, missing) -> None: + from cytearc.readers.h5ad import _is_missing_name + + assert _is_missing_name(name) is missing + + +@pytest.mark.parametrize( + ("key", "tier"), + [ + ("raw/X", 0), + ("layers/counts", 0), + ("layers/Raw_Counts", 0), + ("X", 1), + ("layers/spliced", 2), + ("layers/matrix", 3), + ("raw/layers/counts", 3), + ("layers/unspliced", 4), + ("layers/ambiguous", 4), + ], +) +def test_count_matrix_names_rank_by_convention(key, tier) -> None: + from cytearc.readers._h5ad_inspect import _name_tier + + assert _name_tier(key) == tier + + +def test_sampled_values_must_be_numbers() -> None: + from cytearc.readers._h5ad_inspect import _sampled_counts + + assert _sampled_counts(np.asarray([True, False])) is False + assert _sampled_counts(np.asarray([], dtype=np.float32)) is False + assert _sampled_counts(np.asarray([0, 3], dtype=np.uint8)) is True + + +def test_inspection_without_counts_warns_about_no_competition(tmp_path) -> None: + rng = np.random.default_rng(2) + normalized = csr_matrix(rng.random((6, 4)).astype(np.float32)) + path = _write_matrices( + tmp_path / "normalized.h5ad", + {"X": normalized, "scaled": normalized.copy()}, + 6, + 4, + ) + with _warnings() as messages: + inspection = inspect_h5ad(path) + + assert inspection.integerLike is False + assert not any("Several matrices" in message for message in messages) + + +def test_tables_without_an_index_import_every_column(tmp_path) -> None: + path = tmp_path / "no_index.h5ad" + with h5py.File(path, "w") as h5: + matrix = csr_matrix(np.asarray([[1, 0], [0, 2]], dtype=np.uint8)) + group = h5.create_group("X") + group.attrs.update({"encoding-type": "csr_matrix", "shape": matrix.shape}) + group.create_dataset("data", data=matrix.data) + group.create_dataset("indices", data=matrix.indices) + group.create_dataset("indptr", data=matrix.indptr) + h5.create_group("obs").create_dataset("barcode", data=np.asarray([b"a", b"b"])) + var = h5.create_group("var") + var.create_dataset("gene_ids", data=np.asarray([b"g0", b"g1"])) + var.create_dataset("chromosome", data=np.asarray([b"1", b"2"])) + reader = H5adReader(str(path), cell_ids_key="barcode", feature_ids_key="gene_ids") + try: + assert reader._index_member("var") is None + assert dict(reader.get_feat_columns())["chromosome"].tolist() == [b"1", b"2"] + assert list(dict(reader.get_cell_columns())) == [] + finally: + reader.close() + + +def test_categoricals_whose_categories_the_column_cannot_hold_keep_nothing(): + from cytearc.writers.h5ad import _categorical_attributes + + assert ( + _categorical_attributes( + np.dtype(np.int64), + H5adCategories(("a", "b"), False), + colors=None, + has_missing=False, + display=True, + ) + == {} + ) diff --git a/tests/test_io_format_fixes.py b/tests/test_io_format_fixes.py new file mode 100644 index 0000000..951ef78 --- /dev/null +++ b/tests/test_io_format_fixes.py @@ -0,0 +1,308 @@ +"""Matrix Market, CSV, and sparse-matrix import and export fixes.""" + +import gzip +import io +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import zarr +from loguru import logger +from scipy.sparse import coo_matrix, csc_matrix, csr_array, csr_matrix +from zarr.storage import MemoryStore + +import cytearc +from cytearc.readers import CSVReader, CrDirReader +from cytearc.writers import CrToZarr, CSVtoZarr, SparseToZarr, to_mtx +from tests.storage_helpers import write_count_store + + +@contextmanager +def _warnings() -> Iterator[list[str]]: + messages: list[str] = [] + handler = logger.add(messages.append, level="WARNING", format="{message}") + try: + yield messages + finally: + logger.remove(handler) + + +def _reference_matrix_market(dense: np.ndarray, numeric_type: str) -> bytes: + """Write a Matrix Market file as CyteArc wrote it with pandas before.""" + coo = coo_matrix(dense) + text = io.StringIO() + text.write( + f"%%MatrixMarket matrix coordinate {numeric_type} general\n" + "% Generated by CyteArc\n" + f"{dense.shape[1]} {dense.shape[0]} {coo.nnz}\n" + ) + pd.DataFrame({"col": coo.col + 1, "row": coo.row + 1, "d": coo.data}).to_csv( + text, sep=" ", header=False, index=False, lineterminator="\n" + ) + return text.getvalue().encode() + + +def _store(tmp_path: Path, counts: dict[str, np.ndarray], dtype) -> cytearc.DataStore: + location = str(tmp_path / "store.zarr") + write_count_store(location, counts, dtype) + return cytearc.DataStore( + location, default_assay="RNA", min_features_per_cell=0, nthreads=1 + ) + + +@pytest.mark.parametrize("compress", [False, True]) +def test_matrix_market_counts_keep_their_text_and_feature_types(tmp_path, compress): + rng = np.random.default_rng(4) + rna = rng.integers(0, 300, size=(7, 5)) * (rng.random((7, 5)) > 0.4) + adt = rng.integers(0, 50, size=(7, 3)) + store = _store(tmp_path, {"RNA": rna, "ADT": adt}, np.uint16) + directory = tmp_path / f"adt_{compress}" + + to_mtx(store.ADT, str(directory), compress=compress) + + if compress: + matrix = (directory / "matrix.mtx.gz").read_bytes() + # gzip records a compression level of 9 as 2 in its extra-flags byte. + assert matrix[8] == 0 + matrix = gzip.decompress(matrix) + features = gzip.decompress((directory / "features.tsv.gz").read_bytes()) + else: + matrix = (directory / "matrix.mtx").read_bytes() + features = (directory / "genes.tsv").read_bytes() + assert matrix == _reference_matrix_market(adt, "integer") + # The feature type column keeps an antibody panel ADT on re-import. + assert features.decode().splitlines() == [ + f"ADT{index}\tADT{index}\tADT" for index in range(3) + ] + reimported = tmp_path / f"reimported_{compress}.zarr" + CrToZarr(CrDirReader(str(directory)), str(reimported), nthreads=1).dump() + reopened = cytearc.DataStore(str(reimported), min_features_per_cell=0, nthreads=1) + assert reopened.assay_names == ["ADT"] + assert reopened.ADT.assayType == "ADT" + np.testing.assert_array_equal(np.asarray(reopened.ADT.rawData.compute()), adt) + + +def test_matrix_market_exports_write_one_feature_type_per_assay(tmp_path) -> None: + rna = np.asarray([[1, 0, 2], [0, 3, 1]]) + adt = np.asarray([[4, 1], [0, 2]]) + store = _store(tmp_path, {"RNA": rna, "ADT": adt}, np.uint8) + # CELLxGENE keeps gene biotypes where 10x keeps modalities. + store.RNA.feats.insert( + "feature_type", np.asarray(["protein_coding", "lncRNA", "protein_coding"]) + ) + store.ADT.feats.insert("feature_type", np.asarray(["Antibody Capture"] * 2)) + + with _warnings() as messages: + to_mtx(store.RNA, str(tmp_path / "rna")) + to_mtx(store.ADT, str(tmp_path / "adt")) + + assert [ + line.split("\t")[2] + for line in (tmp_path / "rna/genes.tsv").read_text().splitlines() + ] == ["Gene Expression"] * 3 + assert ( + "Assay 'RNA' holds 2 feature types; the Matrix Market export writes " + "'Gene Expression' for every feature" in [m.rstrip() for m in messages] + ) + assert [ + line.split("\t")[2] + for line in (tmp_path / "adt/genes.tsv").read_text().splitlines() + ] == ["Antibody Capture"] * 2 + + +def test_matrix_market_fractions_keep_the_text_pandas_wrote(tmp_path) -> None: + values = np.asarray( + [[0.1, 0, 2.75], [1e-7, 1 / 3, 0], [123456.5, 0, 5e15]], dtype=np.float64 + ) + store = SparseToZarr( + csr_matrix(values), + MemoryStore(), + cell_ids=["c0", "c1", "c2"], + feature_ids=["g0", "g1", "g2"], + nthreads=1, + ) + store.dump() + datastore = cytearc.DataStore(store.z.store, min_features_per_cell=0, nthreads=1) + + to_mtx(datastore.RNA, str(tmp_path / "fractions")) + + assert (tmp_path / "fractions" / "matrix.mtx").read_bytes() == ( + _reference_matrix_market(values, "real") + ) + + +def test_integer_lines_write_python_text_of_every_width() -> None: + from cytearc.writers.export import _integer_lines + + values = np.asarray( + [0, 9, 10, 99, 100, 4294967295, 4294967296, 18446744073709551615], + dtype=np.uint64, + ) + other = np.arange(1, values.size + 1, dtype=np.int64) + expected = "".join( + f"{a} {b} {c}\n" for a, b, c in zip(other, other * 7, values.tolist()) + ) + + assert _integer_lines([other, other * 7, values]).decode() == expected + assert _integer_lines([np.empty(0, dtype=np.int64)] * 3) == b"" + + +def test_csv_cell_ids_stay_text(tmp_path) -> None: + path = tmp_path / "counts.csv" + path.write_text("id,a,b\n001,1,2\n002,3,4\n010,0,1\n") + converters = {2: int} + + reader = CSVReader(str(path), id_column=0, pandas_kwargs={"converters": converters}) + + assert reader.cell_ids().tolist() == ["001", "002", "010"] + assert reader.cell_ids().dtype.kind == "U" + # The caller's converters are kept and not changed. + assert converters == {2: int} + assert reader.pandas_kwargs["converters"] == {2: int, 0: str} + store = MemoryStore() + CSVtoZarr(reader, store, assay_name="RNA", nthreads=1).dump() + assert zarr.open_group(store, mode="r")["cellData/ids"][:].tolist() == [ + "001", + "002", + "010", + ] + + +def test_csv_without_an_id_column_generates_ids(tmp_path) -> None: + path = tmp_path / "counts.csv" + path.write_text("a,b\n1,2\n3,4\n") + + reader = CSVReader(str(path)) + + assert "converters" not in reader.pandas_kwargs + assert reader.cell_ids().tolist() == ["cell_0", "cell_1"] + + +def test_csv_rejects_a_missing_cell_id(tmp_path) -> None: + path = tmp_path / "missing.csv" + path.write_text("id,a\n001,1\n,2\n") + + with pytest.raises(ValueError, match="CSV cell IDs must contain non-empty"): + CSVReader(str(path), id_column=0) + + +@pytest.mark.parametrize( + "matrix", + [ + lambda values: coo_matrix(values), + lambda values: csc_matrix(values), + lambda values: csr_array(values), + lambda values: values, + ], + ids=["coo", "csc", "csr_array", "dense"], +) +def test_sparse_writer_converts_every_input_to_csr(matrix) -> None: + values = np.asarray([[1, 0, 2], [0, 3, 0], [4, 0, 5], [0, 0, 6]], dtype=np.int32) + store = MemoryStore() + + SparseToZarr( + matrix(values), + store, + cell_ids=[b"c0", "c1", "c2", "c3"], + feature_ids=["g0", "g1", "g2"], + nthreads=1, + ).dump() + + np.testing.assert_array_equal( + zarr.open_group(store, mode="r")["RNA/counts"][:], values + ) + + +def test_sparse_writer_uses_a_csr_input_as_given() -> None: + values = csr_matrix(np.eye(3, dtype=np.float32)) + writer = SparseToZarr( + values, MemoryStore(), cell_ids=["a", "b", "c"], feature_ids=["x", "y", "z"] + ) + assert writer.mat is values + + +@pytest.mark.parametrize( + ("cell_ids", "feature_ids", "message"), + [ + (["c0", "c0"], ["g0", "g1"], "Cell IDs must contain unique values"), + (["c0", "c1"], ["g0", "g0"], "Feature IDs must contain unique values"), + (["c0", None], ["g0", "g1"], "Cell IDs must contain non-empty values"), + (["c0", " "], ["g0", "g1"], "Cell IDs must contain non-empty values"), + (["c0", "c1"], ["g0", float("nan")], "Feature IDs must contain non-empty"), + ([["c0"], ["c1"]], ["g0", "g1"], "Cell IDs must be one-dimensional"), + # Integers are text once written, so 1 and "1" repeat. + ([1, "1"], ["g0", "g1"], "Cell IDs must contain unique values"), + ], +) +def test_sparse_writer_rejects_identifiers_that_readers_reject( + cell_ids, feature_ids, message +): + store = MemoryStore() + with pytest.raises(ValueError, match=message): + SparseToZarr( + csr_matrix(np.ones((2, 2))), + store, + cell_ids=cell_ids, + feature_ids=feature_ids, + ) + # Nothing is written. + assert not list(zarr.open_group(store, mode="a").keys()) + + +def test_sparse_writer_rejects_a_one_dimensional_matrix() -> None: + with pytest.raises(ValueError, match="The count matrix must be two-dimensional"): + SparseToZarr(np.ones(3), MemoryStore(), cell_ids=["a"], feature_ids=["x"]) + + +def test_exports_rename_columns_that_anndata_reserves_or_repeats(tmp_path) -> None: + anndata = pytest.importorskip("anndata") + store = _store(tmp_path, {"RNA": np.asarray([[1, 0], [2, 3]])}, np.uint8) + # Columns that earlier imports stored and that clash on export. + store.cells.insert("_index", np.asarray(["x", "y"])) + store.RNA.feats.insert("_index", np.asarray(["SYM0", "SYM1"])) + store.RNA.feats.insert("gene_short_name", np.asarray(["S0", "S1"])) + store.RNA.feats.insert("gene_ids", np.asarray(["E0", "E1"])) + path = tmp_path / "renamed.h5ad" + + with _warnings() as messages: + cytearc.to_h5ad(store.RNA, str(path)) + streamed = anndata.read_h5ad(path) + + assert streamed.obs["source_index"].tolist() == ["x", "y"] + assert streamed.var["source_index"].tolist() == ["SYM0", "SYM1"] + assert streamed.var["gene_short_name"].tolist() == ["RNA0", "RNA1"] + assert streamed.var["gene_short_name_2"].tolist() == ["S0", "S1"] + assert streamed.var["gene_ids"].tolist() == ["E0", "E1"] + assert ( + "Exporting var column 'gene_short_name' as 'gene_short_name_2' because the " + "export already uses its name" in [m.rstrip() for m in messages] + ) + assert ( + "Exporting obs column '_index' as 'source_index' because AnnData reserves " + "its name" in [m.rstrip() for m in messages] + ) + # The in-memory export renames the column that takes its index's name. + in_memory = store.to_anndata() + assert in_memory.var.index.name == "gene_ids" + assert in_memory.var["gene_ids_2"].tolist() == ["E0", "E1"] + assert in_memory.var["source_index"].tolist() == ["SYM0", "SYM1"] + in_memory.write_h5ad(tmp_path / "written.h5ad") + assert anndata.read_h5ad(tmp_path / "written.h5ad").obs[ + "source_index" + ].tolist() == ["x", "y"] + # The store keeps its columns. + assert "_index" in store.cells.columns + assert "_index" in store.RNA.feats.columns + + +def test_unique_column_names_take_the_first_free_suffix() -> None: + from cytearc.writers.export import unique_column_names + + assert unique_column_names( + "_index", + ["a", "_index", "source_index", "a", "a_2", "source_index"], + table="obs", + ) == ["a", "source_index", "source_index_2", "a_2", "a_2_2", "source_index_3"] diff --git a/tests/test_metadata_column_names.py b/tests/test_metadata_column_names.py index dcab190..b7861df 100644 --- a/tests/test_metadata_column_names.py +++ b/tests/test_metadata_column_names.py @@ -283,12 +283,15 @@ def test_h5ad_reader_reports_column_order_names_the_file_lacks( source = _write_separator_h5ad(tmp_path / "ghost.h5ad", extra_order=("ghost",)) reader = H5adReader(str(source), feature_name_key="feature_name") try: + # The names are those the columns import under, over which storage + # keys are planned: AnnData's unnamed index, which no column may be + # named, would import as source_index. assert reader._source_column_names("obs") == [ EGFR, "c\\d", "a/b", "a_b", - "_index", + "source_index", ] columns, messages = _capture_warnings(lambda: dict(reader.get_cell_columns())) finally: diff --git a/tests/test_presentation_show_zarr_tree.py b/tests/test_presentation_show_zarr_tree.py index 57d0ea3..6829115 100644 --- a/tests/test_presentation_show_zarr_tree.py +++ b/tests/test_presentation_show_zarr_tree.py @@ -267,6 +267,8 @@ def test_to_anndata_exports_an_empty_feature_selection() -> None: return_value=pd.DataFrame({"ids": ["f0", "f1"], "names": ["g0", "g1"]}) ), fetch_all=Mock(return_value=np.asarray(["f0", "f1"])), + # Export reads column attributes to restore source categoricals. + _get_array=Mock(return_value=SimpleNamespace(attrs={})), ) assay = SimpleNamespace( feats=features, @@ -279,6 +281,8 @@ def test_to_anndata_exports_an_empty_feature_selection() -> None: columns=["ids"], active_index=Mock(return_value=np.asarray([0, 1])), to_pandas_dataframe=Mock(return_value=pd.DataFrame({"ids": ["c0", "c1"]})), + # Export reads each column, as to_h5ad does. + fetch_all=Mock(return_value=np.asarray(["c0", "c1"])), # Export reads column attributes to declare membership columns. _get_array=Mock(return_value=SimpleNamespace(attrs={})), ) diff --git a/tests/test_reader_h5ad_seurat_paths.py b/tests/test_reader_h5ad_seurat_paths.py index 68143c3..7230efd 100644 --- a/tests/test_reader_h5ad_seurat_paths.py +++ b/tests/test_reader_h5ad_seurat_paths.py @@ -256,7 +256,11 @@ def _write_analysis_h5ad(path: Path) -> Path: [ (["X_umap"], TypeError, "embedding_roles must be a mapping"), ({"": "umap"}, ValueError, "keys must be non-empty strings"), - ({"X_umap": "pca"}, ValueError, "values must be 'umap' or 'tsne'"), + ( + {"X_umap": "pca"}, + ValueError, + "values must be 'umap', 'tsne', or 'coordinates'", + ), ({"X_missing": "umap"}, KeyError, "'X_missing' was not found in obsm"), ({"X_sparse": "umap"}, TypeError, "must be a dense H5AD array"), ({"X_short": "umap"}, ValueError, r"incompatible shape \(3, 2\)"), diff --git a/tests/test_readers_contracts.py b/tests/test_readers_contracts.py index a467e69..36a484d 100644 --- a/tests/test_readers_contracts.py +++ b/tests/test_readers_contracts.py @@ -84,7 +84,9 @@ CrReader: "0237d8e7c63b64fb6526f3536d5f65c1d704fe56675bf38b41644be419a5607f", CrH5Reader: "435e2965b15010e0d1a41a36dae1c492d24eac90386e3e1f58ea8b705570db7d", CrDirReader: "47ae6207108e6433f78e3214a0b38c11cadf5cf2d6a79eb218949d6137311bef", - H5adReader: "ca5ddfbe930e729f419b42a5b7018e6a03b221c371479c26716183cb2ff08daa", + # H5adReader gained the keyword-only duplicate_cell_ids="error", and its + # embedding roles gained "coordinates". + H5adReader: "216ef56b5e006545a348a90b7fde68e51adb532cca98996f85a2fe578dac2a25", CSVReader: "8aa6c17c876afb62765584fc7ff64d2838c66ef53095da10d7198ca60ab83851", MtxReader: "b0ce7fc6984e0b49abd3e1e3d65ae97194af7b7de083b1d0ea0496735a8c5dd0", SeuratReader: "68678f67361080e8121eaae116ace832c1e0defc73ca36e3e3aa02d4d96718e6", diff --git a/tests/test_writers.py b/tests/test_writers.py index b8b8cc2..a39bd85 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -2541,12 +2541,16 @@ def test_to_mtx_preserves_counts_barcodes_and_features(export_assay_store, tmp_p barcodes = (out_dir / "barcodes.tsv").read_text().splitlines() assert barcodes == ["b1", "b2", "b3"] + # The feature type column keeps the assay's type in an uncompressed + # export too, so an import reads RNA back as RNA. features = [ line.split("\t") for line in (out_dir / "genes.tsv").read_text().splitlines() if line ] - assert features == [[name, name] for name in ("g1", "g2", "g3", "g4")] + assert features == [ + [name, name, "Gene Expression"] for name in ("g1", "g2", "g3", "g4") + ] def test_to_mtx_compress_writes_gzipped_matrix_market(export_assay_store, tmp_path): diff --git a/tests/test_writers_contracts.py b/tests/test_writers_contracts.py index 48583bf..ac928ff 100644 --- a/tests/test_writers_contracts.py +++ b/tests/test_writers_contracts.py @@ -73,9 +73,10 @@ "to_mtx", "write_renorm_subset_to_zarr", ) -# to_h5ad takes the keyword-only matrix="raw" or "normed" beside run. +# to_h5ad takes the keyword-only matrix="raw" or "normed" beside run, and the +# keyword-only cell_selection, obsm, obs, graph, and dtype, each None by default. _MODULE_SIGNATURE_DIGEST = ( - "d2b52149156853c361e7a78b8bbeaee30788df456db0b32da6acae49677a1076" + "4b6d6cdd593f4f8f6b363a1e447eae26724e0f25fb57453220956fa4ac4b10c8" )