"""
:func:`prepare`: an AnnData, a MuData, or arrays and data frames in; one Cellucid-ready AnnData or MuData out.
"""
from __future__ import annotations
import functools
import os
import warnings
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any, Literal, ParamSpec, TypeVar
import numpy as np
import pandas as pd
from scipy import sparse
from .._anndata_files import AnnDataContents, is_mudata
from .._console import console_print
from .._contracts import (
_require_dataset_id,
_require_dataset_name,
_require_display_text,
_require_native_boolean,
)
from .._launch_defaults import launch_defaults
from .._plural import plural
from ..data_mapping import EmbeddingKeySpec, column_gene_names, default_modality
from ..subsample import _require_uint32
from ._data import (
anndata_components,
cellucid_record,
is_anndata,
is_cell_array,
split_obsm,
)
from ._inputs import (
linked_text,
resolve_connectivities,
resolve_embeddings,
resolve_expression,
resolve_genes,
resolve_obs,
resolve_vector_fields,
storable_columns,
stored_expression,
)
from ._mudata import (
Modality,
MuDataLayout,
_modality_error,
check_mudata,
global_var_index,
modality_obsm_widths,
placed_rows,
resolve_modality,
)
from ._outliers import DEFAULT_MIN_POINTS, stored_outlier_quantiles
from ._priority import CellPriority, prefix_levels, stored_order_of, without_positions
from ._store import DEFAULT_ZARR_FORMAT, Elements, OutputTarget, write_store
#: The order ``uns['cellucid']['defaults']`` lists its keys in.
_DEFAULTS_ORDER = (
"embedding1d",
"embedding2d",
"embedding3d",
"modality",
"geneIdColumn",
"latentKey",
"vectorFieldDefault",
"obsKeys",
"normalizeEmbeddings",
"centroidMinPoints",
)
#: The suffix ``out_path`` ends in for each ``format``, and what that writes.
_FORMATS: dict[str, tuple[str, str]] = {
"h5ad": (".h5ad", "an .h5ad file"),
"zarr": (".zarr", "a .zarr folder"),
"zarr.zip": (".zarr.zip", "a .zarr.zip file"),
"h5mu": (".h5mu", "an .h5mu file"),
}
#: Which ``format`` to choose; every refusal of ``format`` quotes it.
_FORMAT_CHOICES = (
'format must be "h5ad", "zarr", "zarr.zip" or "h5mu":\n'
' "h5ad" one .h5ad file: for single-file hosts (Zenodo, Figshare, Hugging Face, '
"S3, a lab server), local files, cellucid serve and Jupyter.\n"
' "zarr" a .zarr folder of small files: for GitHub (no file over 100 MB, no LFS).\n'
' "zarr.zip" the Zarr store in one .zarr.zip file: wherever an .h5ad goes.\n'
' "h5mu" one .h5mu file, for a MuData (data=mdata): wherever an .h5ad goes.'
)
_P = ParamSpec("_P")
_R = TypeVar("_R")
def _requires_format(function: Callable[_P, _R]) -> Callable[_P, _R]:
"""Refuse a call without ``format`` with the choices, not Python's bare message."""
@functools.wraps(function)
def checked(*args: _P.args, **kwargs: _P.kwargs) -> _R:
if "format" not in kwargs:
raise TypeError(f"prepare() needs format=, the kind of output to write. {_FORMAT_CHOICES}")
return function(*args, **kwargs)
return checked
_FORMAT_OF_SUFFIX = {suffix: name for name, (suffix, _) in _FORMATS.items()}
def _known_suffix(name: str) -> str | None:
"""The output suffix ``name`` ends in, the longest that fits."""
lowered = name.lower()
for suffix in (".zarr.zip", ".zarr", ".h5ad", ".h5mu"):
if lowered.endswith(suffix):
return suffix
return None
def _resolve_target(
out_path: object, *, output_format: object, zarr_format: object, force: bool, mudata: bool
) -> OutputTarget:
"""What ``format`` writes, checked against ``out_path``, and whether it may go there."""
if not (isinstance(output_format, str) and output_format in _FORMATS):
raise ValueError(f"{_FORMAT_CHOICES}\nGot {output_format!r}.")
if output_format == "h5mu" and not mudata:
raise ValueError(
'format="h5mu" writes a MuData: pass the MuData as data= (read one with '
'mudata.read_h5mu; pip install "cellucid[mudata]"). An AnnData is written as '
'format="h5ad", "zarr" or "zarr.zip".'
)
if output_format == "h5ad" and mudata:
raise ValueError(
'A MuData is written as format="h5mu", "zarr" or "zarr.zip"; an .h5ad holds one '
"AnnData. To write one modality alone, pass data=mdata.mod['<name>']."
)
if not isinstance(out_path, str | os.PathLike):
raise TypeError("out_path must be a string or os.PathLike path.")
suffix, what = _FORMATS[output_format]
given = Path(out_path).expanduser()
name = given.name
present = _known_suffix(name)
if present != suffix or len(name) == len(suffix):
stem = name if present is None else name[: len(name) - len(present)]
instead = repr(str(Path(out_path).parent / f"{stem or 'data'}{suffix}"))
if present is None:
problem = f"does not end in {suffix}"
elif present == suffix:
problem = "is only the suffix"
else:
problem = f'ends in {present}, which format="{_FORMAT_OF_SUFFIX[present]}" writes'
raise ValueError(
f'format="{output_format}" writes {what}, and out_path {str(out_path)!r} '
f"{problem}: write {instead} instead."
)
kind: Literal["h5ad", "h5mu", "zarr"] = (
"zarr" if output_format in ("zarr", "zarr.zip") else "h5mu" if output_format == "h5mu" else "h5ad"
)
zipped = output_format == "zarr.zip"
resolved_format: Literal[2, 3] | None
if kind != "zarr":
if zarr_format is not None:
raise ValueError(
f'zarr_format applies to format="zarr" or "zarr.zip", not format="{output_format}".'
)
resolved_format = None
elif zarr_format is None:
resolved_format = DEFAULT_ZARR_FORMAT
elif zarr_format == 2 and type(zarr_format) is int:
resolved_format = 2
elif zarr_format == 3 and type(zarr_format) is int:
resolved_format = 3
else:
raise ValueError(f"zarr_format must be 2 or 3, got {zarr_format!r}.")
path = given.parent.resolve() / name
if path.exists() or path.is_symlink():
if not force:
raise FileExistsError(f"{path} already exists; pass force=True to replace it.")
if path.is_symlink():
raise ValueError(f"{path} is a symbolic link; remove it yourself if it should go.")
if kind == "zarr" and not zipped:
if not (path.is_dir() and ((path / ".zgroup").is_file() or (path / "zarr.json").is_file())):
raise ValueError(
f"{path} exists and is not a Zarr store, so force=True does not replace "
"it; remove it yourself if it should go."
)
elif not path.is_file():
raise ValueError(
f"{path} is a folder, and format=\"{output_format}\" writes a file; remove it "
"yourself if it should go."
)
return OutputTarget(path=path, kind=kind, zipped=zipped, zarr_format=resolved_format)
def _uns_value(value: Any) -> Any:
"""A JSON-shaped default as anndata stores it: a list becomes an array."""
if isinstance(value, dict):
return {key: _uns_value(item) for key, item in value.items()}
if isinstance(value, list):
if all(isinstance(item, str) for item in value):
return np.asarray(value, dtype=object)
return np.asarray(value, dtype=np.int64)
return value
[docs]
@_requires_format
def prepare(
out_path: str | os.PathLike[str],
*,
format: Literal["h5ad", "zarr", "zarr.zip", "h5mu"],
data: Any = None,
embeddings: Mapping[str, Any] | None = None,
dataset_id: str | None = None,
dataset_name: str | None = None,
obs: pd.DataFrame | None = None,
var: pd.DataFrame | None = None,
gene_expression: np.ndarray | sparse.spmatrix | sparse.sparray | None = None,
connectivities: np.ndarray | sparse.spmatrix | sparse.sparray | None = None,
vector_fields: Mapping[str, Mapping[str, Any]] | None = None,
modalities: Sequence[str] | None = None,
gene_identifiers: Sequence[str] | None = None,
dataset_description: str | None = None,
citation_text: str | None = None,
citation_url: str | None = None,
source_name: str | None = None,
source_url: str | None = None,
embedding_defaults: Mapping[str, EmbeddingKeySpec] | None = None,
latent_key: str | None = None,
obs_keys: Sequence[str] | None = None,
var_gene_id_column: str | None = None,
vector_field_default: str | None = None,
normalize_embeddings: bool | None = None,
centroid_min_points: int | None = None,
modality: str | None = None,
priority_seed: int | None = 0,
zarr_format: Literal[2, 3] | None = None,
force: bool = False,
) -> Path:
"""Write a Cellucid-ready AnnData (or MuData): a standard file laid out for the browser.
The output is an ordinary AnnData that ``anndata.read_h5ad`` or
``anndata.read_zarr`` reads, written so the Cellucid web app reads it fast
over HTTP: the expression matrix gene-major (CSC), arrays chunked for range
requests and compressed with codecs browsers decode natively, and the
dataset's name, citation and launch defaults in ``uns['cellucid']``. Open
it with ``cellucid serve``, :func:`~cellucid.show_anndata`, or the web app
directly. The written contract is the "Cellucid-ready AnnData" page of the
documentation; cellucid-r's ``cellucid_prepare()`` writes the same.
The inputs come from ``data`` -- an AnnData or a MuData -- or are passed
one by one, or both: every component ``data`` holds is its default, and an
argument given explicitly replaces that one component. The output is
byte for byte what the same components passed by hand write.
A MuData is written as a Cellucid-ready MuData (``format="h5mu"``,
``"zarr"`` or ``"zarr.zip"``): a standard MuData whose modalities share
the cells, each modality a Cellucid-ready AnnData (gene-major ``X``,
chunked and compressed for the browser), the cells' metadata, shared
embeddings and graph at the root with ``uns['cellucid']``. The written
contract is the "Cellucid-ready MuData" section of the specification.
Every input is checked before anything is written (a MuData modality's
matrix as it is written, one modality at a time), and the output is
written beside ``out_path`` under a temporary name and moved into place
only once it is complete.
Parameters
----------
out_path
Where to write, ending in the suffix of ``format``: ``.h5ad``,
``.zarr``, ``.zarr.zip`` or ``.h5mu``. A leading ``~`` is expanded.
format
Required, with no default -- which one depends on where the data goes
(``"h5mu"`` is the single-file form of a MuData, like ``"h5ad"``;
``"zarr"`` and ``"zarr.zip"`` write a MuData as MuData Zarr):
- ``"zarr"``: a ``.zarr`` folder of small files, for GitHub (a
repository or its Pages site). GitHub refuses files over 100 MB and
answers over HTTP/2, 100 requests at a time, so a folder's one
request per chunk costs nothing there.
- ``"h5ad"``: one ``.h5ad`` file, for everything else: single-file
hosts (Zenodo, Figshare, Hugging Face, S3, a lab server), local
files, ``cellucid serve`` and Jupyter. Its ranges are merged into
few requests; most such hosts answer over HTTP/1.1, six requests at
a time, where a folder is several times slower. What opening reads
comes first in the file (431 KB of a 561,947-cell atlas of 5,103
genes), which the web app reads with its first request of 512 KiB.
- ``"zarr.zip"``: the Zarr store in one ``.zarr.zip`` file (entries
stored, metadata last), wherever an ``.h5ad`` goes.
Measured on a 561,947-cell atlas, every read the web app makes, in
total: over GitHub's HTTP/2 (80 ms, 50 MB/s) 8.3 s from the ``.h5ad``,
8.0 s from the folder, 8.2 s from the ``.zarr.zip``; over HTTP/1.1
(80 ms, 50 MB/s) 9.0 s, 46.8 s and 9.0 s; from a local file in a
browser 2.5, 2.6 and 2.7 s. The ``.zarr.zip`` is 13% smaller than the
``.h5ad`` (198 against 228 MB). The "Which format?" section of the
documentation's preparation guide has the full table.
data
An ``anndata.AnnData`` or a ``mudata.MuData`` (``pip install
"cellucid[mudata]"`` to read and build one) that fills every component
below an explicit argument does not:
- AnnData: ``obs``; ``X`` as ``gene_expression`` with ``var`` (neither
when ``X`` is absent); every numeric ``obsm`` array as an embedding
under its own key, except a ``<field>_<basis>`` array as wide as
``X_<basis>``, which is that vector field; ``obsp['connectivities']``;
and from ``uns['cellucid']`` the identity (``id``, ``name``,
``description``, ``citation``, ``source``) and the launch defaults.
Layers, ``raw``, other ``obsp`` matrices, ``obsm`` entries that are
not numeric arrays, and ``obs``/``var`` columns of no AnnData
encoding Cellucid writes (a date, a time span) are not written, and
a message names each one, as cellucid-r names them (the same column
given as ``obs=`` is refused, saying how to convert it). To leave
out a component ``data`` holds, remove it from a copy first.
- MuData (modalities over shared cells, ``axis`` 0): the cells'
``mdata.obs``, ``mdata.obsm`` (the shared embeddings and vector
fields), ``mdata.obsp['connectivities']`` and ``mdata.uns['cellucid']``
at the root, as above; each modality whole -- its ``X``, ``var``,
``obs``, numeric ``obsm`` arrays and ``obsp['connectivities']`` --
as ``mod/<name>``. Columns and arrays mudata copies to the root
from a modality (``<name>:<column>``) are not written twice: the
viewer shows every modality's own under that name. The genes are
each modality's own, so ``gene_expression``, ``var`` and
``gene_identifiers`` are refused with a MuData: change
``mdata.mod[name]`` first.
embeddings
``{obsm key: (n_cells, k) array}``, at least one (with a MuData, at
the root or in a modality). Each is stored whole
as ``obsm[key]`` in float32, every column, at its own coordinates (the
viewer normalizes them). A latent space for outlier measurement is one
of them, named by ``latent_key``. A plain ``(n_cells,)`` vector is one
column; sparse input is densified. A row holding NaN (or an infinity)
is a cell the embedding does not place (one computed on some of the
cells): it is written as given, the viewer leaves it out of that
embedding's views, and in the latent space it gets no outlier
quantile; at least one cell must have finite values. ``X_umap_1d``/``X_umap_2d``/``X_umap_3d``,
else the first 1- to 3-column of ``X_umap``, ``X_UMAP``, ``X_wnn.umap``
and ``X_ref.umap``, are shown by default.
dataset_id
The dataset's stable id: 1 to 180 ASCII letters, digits, ``.``, ``_``
or ``-``, starting with a letter or digit and not ending with ``.``.
Sessions and community annotations are keyed by it.
dataset_name
The name shown for the dataset, also stored as ``uns['title']``.
obs
Cell metadata, one row per cell. Its index names the cells (pandas'
default ``0 .. n-1`` index names them ``"0"`` to ``"n - 1"``);
repeated names are made unique, as anndata does. Text columns become
categoricals when a value repeats or is missing (anndata's rule);
numbers, booleans, categoricals and pandas nullable integers and
booleans are stored as they are. Infinities are refused.
var
Gene metadata, one row per column of ``gene_expression``. Its index
names the genes; with pandas' default index, ``var_gene_id_column``
names them. Every column is stored.
gene_expression
``(n_cells, n_genes)``, sparse (any scipy format) or dense. Stored as
``X``: CSC when sparse, dense otherwise, in float32 -- or float64 when
the genes it keeps (after ``gene_identifiers``) hold integer counts
above 2^24, which float32 cannot hold exactly. Values must be finite.
A stored zero is dropped.
connectivities
``(n_cells, n_cells)`` neighbour graph, stored as
``obsp['connectivities']`` (CSR float32). Exactly symmetric, finite,
non-negative; its diagonal is left out (a self-loop is no edge: Seurat's
``RNA_snn`` has 1 there), and a directed graph (``RNA_nn``,
``distances``) is refused. From an AnnData ``data=``, the graph the
viewer's default rule opens with: ``obsp['connectivities']``, else
Seurat's ``<assay>_snn``.
vector_fields
``{field: {embedding key: (n_cells, k) array}}``, one column per
column of the embedding. Each array is stored as
``obsm['<field>_<basis>']`` for the embedding ``X_<basis>``, scVelo's
name (``{"velocity": {"X_umap": v}}`` writes ``velocity_umap``), which
the viewer draws as the field ``<field>`` over that embedding, so the
embedding's key must start with ``X_``.
modalities
With a MuData, the modalities to write, in this order (names of
``mdata.mod``); None writes every one, in ``mdata.mod`` order.
Several are written as a Cellucid-ready MuData of those modalities
(the root ``var`` and ``varmap`` hold theirs alone). One is written as
a plain Cellucid-ready AnnData (``format="h5ad"``, ``"zarr"`` or
``"zarr.zip"``): the cells' ``obs``, embeddings, graph and
``uns['cellucid']`` from the root, and that modality's ``X``, ``var``
and ``obsm`` arrays, its rows in the MuData's cell order -- so it must
have measured every cell -- with ``gene_expression``, ``var`` and
``gene_identifiers`` then applying to it. cellucid-r's
``cellucid_prepare(modalities = )`` takes the same names.
gene_identifiers
Keep only these genes, in this order, matched against the names the
viewer shows (``var_gene_id_column``, else the var index).
dataset_description
One paragraph about the dataset.
citation_text, citation_url
How to cite the dataset; the address needs the text.
source_name, source_url
Where the data comes from; the address needs the name.
embedding_defaults
The embedding each dimension opens on: ``{"2d": "X_tsne"}`` (its
first columns) or ``{"2d": ("X_pca", [2, 0])}`` (0-based columns).
latent_key
The embedding (an ``embeddings`` key) the viewer's outlier filter
measures in: a latent space such as scVI or PCA, never a 2D/3D
layout. The outlier quantiles of every categorical obs column (each
cell's distance to its category's centre, as the share of its
category at that distance or closer) are computed here and stored in
``uns['cellucid']['outlierQuantiles']``, so the viewer filters
outliers at once instead of reading the whole latent space to
compute them.
obs_keys
The obs columns the viewer lists, in this order; every column is
stored either way.
var_gene_id_column
The var column whose values name the genes in the viewer. Omitted,
the var index names them; Data mapping can choose a column later.
vector_field_default
The ``vector_fields`` field the overlay opens on; one of its
embeddings must be shown when the file opens (a chosen or default
embedding, or a 1-3 column array that is not the latent space).
normalize_embeddings
False shows embeddings at their own coordinates.
centroid_min_points
The fewest cells a category needs to get outlier quantiles (10 when
None).
modality
With a MuData, the modality the viewer opens on (one of
``mdata.mod``); ``var_gene_id_column`` names a column of its ``var``.
When None the viewer opens on the first gene-expression modality
(``rna``, ``gex``, ``gene expression``, ``gene_expression``, ``sct``,
any case), else the first. Refused for an AnnData, which has one
feature space.
priority_seed
The seed of the cells' priority order, an integer in
``[0, 2**32 - 1]``. The cells are stored by ascending priority -- the
``uniform`` rank of the subsampling specification
(:func:`~cellucid.subsample_cells`) with this seed -- so every
subsample Cellucid opens with this seed is the first rows of every
array, and reads nothing of the cells it leaves out. Each row's
original position is kept in ``obs['cellucid_original_position']``;
:func:`~cellucid.restore_original_order` puts the cells back in the
order given. None stores the cells in the order given, without a
priority.
zarr_format
2 or 3, for ``format="zarr"`` or ``"zarr.zip"``; Zarr v2 when None.
Refused with ``format="h5ad"`` and ``"h5mu"``.
force
Replace an existing output of the same kind. A folder that is not a
Zarr store is never replaced.
The launch defaults (``embedding_defaults`` to ``modality``)
are written to ``uns['cellucid']['defaults']`` only when given; a choice
left at None stays the viewer's own.
Returns
-------
Path
The written file or folder.
Raises
------
TypeError
A required input is missing (``dataset_id`` or ``dataset_name`` that
``data`` does not name, ``embeddings`` without ``data``) or an input
has the wrong type.
ValueError
An input is invalid or the inputs do not fit together (``out_path``
not ending in the suffix of ``format``, a MuData of several
modalities with ``format="h5ad"``, an AnnData with ``format="h5mu"``,
``zarr_format`` with ``"h5ad"`` or ``"h5mu"``, ...).
FileExistsError
``out_path`` exists and ``force`` is False.
"""
force = _require_native_boolean(force, label="force")
if priority_seed is not None:
priority_seed = _require_uint32(priority_seed, "priority_seed")
explicit = {
"embeddings": embeddings,
"dataset_id": dataset_id,
"dataset_name": dataset_name,
"obs": obs,
"var": var,
"gene_expression": gene_expression,
"connectivities": connectivities,
"vector_fields": vector_fields,
"gene_identifiers": gene_identifiers,
"dataset_description": dataset_description,
"citation_text": citation_text,
"citation_url": citation_url,
"source_name": source_name,
"source_url": source_url,
"embedding_defaults": embedding_defaults,
"latent_key": latent_key,
"obs_keys": obs_keys,
"var_gene_id_column": var_gene_id_column,
"vector_field_default": vector_field_default,
"normalize_embeddings": normalize_embeddings,
"centroid_min_points": centroid_min_points,
"modality": modality,
}
given = {key: value for key, value in explicit.items() if value is not None}
if data is not None and not is_mudata(data) and not is_anndata(data):
raise TypeError(
f"data must be an anndata.AnnData or a mudata.MuData, got {type(data).__name__}. "
"Pass arrays and data frames as embeddings=, obs=, gene_expression= and the rest."
)
positions: np.ndarray | None = None
if data is not None:
# An object read from a file in a verified priority order holds its
# cells in that stored order: each row's original position is known,
# and the way back to the original order and on to the file's is one
# permutation (CellPriority.of), so a file prepared from it names
# every cell as that file did -- and one in the same order is written
# as it is, with no copy of the object.
stored = stored_order_of(data)
if stored.notice is not None:
warnings.warn(stored.notice, UserWarning, stacklevel=3)
positions = stored.positions
selected = _selected_modalities(data, modalities)
notes: list[tuple[str, str]] = []
multimodal = data is not None and is_mudata(data) and (selected is None or len(selected) > 1)
target = _resolve_target(
out_path, output_format=format, zarr_format=zarr_format, force=force, mudata=multimodal
)
if multimodal:
refused = [key for key in ("gene_expression", "var", "gene_identifiers") if key in given]
if refused:
raise ValueError(
f"{refused[0]} describes the genes of one matrix, and a MuData's genes are each "
"modality's own. Change the modality before prepare, for example "
"mdata.mod['rna'] = mdata.mod['rna'][:, genes].copy(); mdata.update(), or "
"write one modality with modalities=['rna']."
)
names = check_mudata(data, selected)
every = [str(name) for name in data.mod]
arguments = {**_mudata_root_components(data, every, notes), **given, "priority_seed": priority_seed}
_say_not_written(notes, given)
_require_identity(arguments, data_given=True)
write_store(target, _mudata_layout(data, names, arguments, positions))
else:
if selected is not None:
if "modality" in given:
raise ValueError(
"modality names the modality the viewer opens on, and modalities= writes "
"one, which is stored as an AnnData with no modalities."
)
components = _single_modality_components(data, selected[0], notes)
else:
components = anndata_components(data, notes) if data is not None else {}
arguments = {**components, **given, "priority_seed": priority_seed}
_say_not_written(notes, given)
_require_identity(arguments, data_given=data is not None)
if "embeddings" not in given and data is None:
raise TypeError(
"prepare() needs embeddings=: at least one {obsm key: (n_cells, k) array}, "
"for example embeddings={'X_umap': adata.obsm['X_umap']}, or data= an "
"AnnData or MuData that holds them."
)
if "embeddings" not in given and not arguments["embeddings"]:
raise ValueError(
"data holds no embedding: its obsm has no numeric array. Compute one, for "
"example sc.pp.neighbors(adata); sc.tl.umap(adata), or pass embeddings=."
)
write_store(target, _anndata_elements(arguments, positions))
return target.path
def _say_not_written(notes: list[tuple[str, str]], given: Mapping[str, Any]) -> None:
"""Say what of ``data`` is not written, for each component an explicit argument does not replace."""
for component, note in notes:
if component not in given:
console_print(note)
def _require_identity(arguments: dict[str, Any], *, data_given: bool) -> None:
"""``dataset_id`` and ``dataset_name`` are given, or ``data`` names itself."""
for key, record in (("dataset_id", "id"), ("dataset_name", "name")):
if arguments.get(key) is None:
where = (
f"data's uns['cellucid'] has no {record!r}, so pass {key}="
if data_given
else f"pass {key}="
)
raise TypeError(f"prepare() needs {key}: {where}.")
def _cellucid_identity(arguments: Mapping[str, Any]) -> dict[str, Any]:
"""``uns['cellucid']`` without its defaults: schema, id, name, description, citation, source."""
dataset_id = _require_dataset_id(arguments["dataset_id"], label="dataset_id")
dataset_name = _require_dataset_name(arguments["dataset_name"], label="dataset_name")
description = arguments.get("dataset_description")
if description is not None:
description = _require_display_text(description, label="dataset_description", allow_empty=True)
citation = linked_text(
arguments.get("citation_text"),
arguments.get("citation_url"),
text_label="citation_text",
url_label="citation_url",
text_key="text",
)
source = linked_text(
arguments.get("source_name"),
arguments.get("source_url"),
text_label="source_name",
url_label="source_url",
text_key="name",
)
cellucid: dict[str, Any] = {"schema": 1, "id": dataset_id, "name": dataset_name}
if description is not None:
cellucid["description"] = description
if citation is not None:
cellucid["citation"] = citation
if source is not None:
cellucid["source"] = source
return cellucid
def _checked_defaults(contents: AnnDataContents, arguments: Mapping[str, Any]) -> dict[str, Any]:
"""The launch defaults ``uns['cellucid']['defaults']`` holds, checked against ``contents``."""
embedding_defaults = arguments.get("embedding_defaults")
defaults = launch_defaults(
contents,
latent_key=arguments.get("latent_key"),
gene_id_column=arguments.get("var_gene_id_column"),
normalize_embeddings=arguments.get("normalize_embeddings"),
centroid_min_points=arguments.get("centroid_min_points"),
obs_keys=arguments.get("obs_keys"),
embedding_keys=embedding_defaults,
knn_key=None,
expression_source=None,
vector_field_default=arguments.get("vector_field_default"),
embedding_label="embedding_defaults",
gene_id_label="var_gene_id_column",
modality=arguments.get("modality"),
)
# An embedding default named by its key alone is stored as the key: the
# viewer then plots its first columns, as it does for any key.
for slot, spec in (embedding_defaults or {}).items():
if isinstance(spec, str):
defaults[f"embedding{slot}"] = spec
return defaults
def _with_defaults(
cellucid: dict[str, Any],
defaults: Mapping[str, Any],
*,
obs_columns: dict[str, Any],
latent: np.ndarray | None,
priority: dict[str, Any] | None,
) -> dict[str, Any]:
"""``cellucid`` with its launch defaults, for a latent space its outlier quantiles, and its priority."""
if defaults:
cellucid["defaults"] = _uns_value({key: defaults[key] for key in _DEFAULTS_ORDER if key in defaults})
if latent is not None:
cellucid["outlierQuantiles"] = stored_outlier_quantiles(
obs_columns,
defaults["latentKey"],
latent,
defaults.get("centroidMinPoints", DEFAULT_MIN_POINTS),
)
if priority is not None:
cellucid["priority"] = priority
return cellucid
def _require_vector_field_default(field: object, fields: Sequence[str]) -> None:
if field is not None and field not in fields:
if not fields:
raise ValueError("vector_field_default names a field, and no vector_fields were supplied.")
raise ValueError(
f"vector_field_default must name one of vector_fields: {list(fields)}; got {field!r}."
)
def _anndata_elements(arguments: Mapping[str, Any], given: np.ndarray | None = None) -> Elements:
"""One Cellucid-ready AnnData's elements, every component checked.
``given`` is each row's original position when the components are an
object's in a verified stored order (:func:`prepare`), None when each row
is its own.
"""
cellucid = _cellucid_identity(arguments)
obsm, n_cells = resolve_embeddings(arguments["embeddings"])
vectors, fields = resolve_vector_fields(arguments.get("vector_fields"), obsm, n_cells)
obs_index, obs_columns = resolve_obs(arguments.get("obs"), n_cells)
obs_columns = without_positions(obs_columns, "obs", verified=given is not None)
gene_expression = arguments.get("gene_expression")
var_gene_id_column = arguments.get("var_gene_id_column")
if gene_expression is None:
supplied = [
name for name in ("var", "gene_identifiers", "var_gene_id_column") if arguments.get(name) is not None
]
if supplied:
raise ValueError(
f"{supplied[0]} describes the genes of gene_expression, and no "
"gene_expression was supplied."
)
expression = None
genes = None
var_index = np.asarray([], dtype=object)
var_columns: dict[str, Any] = {}
else:
expression = resolve_expression(gene_expression, n_cells)
genes, var_index, var_columns = resolve_genes(
int(expression.shape[1]),
arguments.get("var"),
var_gene_id_column,
arguments.get("gene_identifiers"),
)
connectivities = arguments.get("connectivities")
obsp = {} if connectivities is None else {
"connectivities": resolve_connectivities(connectivities, n_cells)
}
# Every per-cell element in the file's order (a priority order, or the
# original one for an object in a stored order), before anything is
# derived from it (the outlier quantiles), so the file holds what its rows give.
priority = CellPriority.of(n_cells, arguments.get("priority_seed"), given)
levels: np.ndarray | None = None
if priority is not None:
obsm = priority.rows_of_mapping(obsm)
vectors = priority.rows_of_mapping(vectors)
obs_index = priority.rows(obs_index)
obs_columns = priority.obs_columns(obs_columns)
obsp = {key: priority.graph(graph) for key, graph in obsp.items()}
if priority.recorded:
levels = prefix_levels(n_cells)
order, new_row = (None, None) if priority is None else priority.rows_moved
# Gene-major in the file's order, in one pass (:mod:`._gene_major`).
x = None if expression is None else stored_expression(expression, genes=genes, order=order, new_row=new_row, levels=levels)
del expression
_require_vector_field_default(arguments.get("vector_field_default"), fields)
all_obsm = {**obsm, **vectors}
latent_key = arguments.get("latent_key")
if latent_key is not None and latent_key not in obsm:
raise ValueError(f"latent_key must name one of the embeddings: {list(obsm)}; got {latent_key!r}.")
defaults = _checked_defaults(
AnnDataContents(
n_obs=n_cells,
n_vars=len(var_index),
obs_columns=frozenset(obs_columns),
var_columns=frozenset(var_columns),
obsm={key: int(values.shape[1]) for key, values in all_obsm.items()},
obsp=frozenset(obsp),
layers=frozenset(),
has_x=x is not None,
raw_var_columns=None,
),
arguments,
)
cellucid = _with_defaults(
cellucid,
defaults,
obs_columns=obs_columns,
latent=None if latent_key is None else obsm[latent_key],
priority=None if priority is None or not priority.recorded else priority.record(levels),
)
return Elements(
x=x,
obs_index=obs_index,
obs=obs_columns,
var_index=var_index,
var=var_columns,
obsm=all_obsm,
obsp=obsp,
uns={"title": cellucid["name"], "cellucid": cellucid},
)
def _selected_modalities(data: Any, modalities: object) -> list[str] | None:
"""The modalities ``modalities=`` chooses, checked against ``data``; None for every one."""
if modalities is None:
return None
if data is None:
raise ValueError("modalities chooses among the modalities of data, and no data was supplied.")
if not is_mudata(data):
raise ValueError(
"modalities chooses among a MuData's modalities, and data is an AnnData, which has "
"one feature space."
)
if isinstance(modalities, str | bytes) or not isinstance(modalities, Sequence):
raise TypeError("modalities must be a sequence of modality names, such as ['rna', 'prot'].")
chosen = list(modalities)
if not all(isinstance(name, str) for name in chosen):
raise TypeError("modalities must hold modality names (strings).")
if not chosen or len(set(chosen)) != len(chosen):
raise ValueError("modalities must name at least one modality of data, each once.")
available = [str(name) for name in data.mod]
unknown = [name for name in chosen if name not in available]
if unknown:
raise ValueError(
f"modalities names {unknown}, which data does not hold. Its modalities: {available}."
)
return chosen
def _single_modality_components(mdata: Any, name: str, notes: list[tuple[str, str]] | None = None) -> dict[str, Any]:
"""One modality of a MuData as an AnnData's components (``modalities=[name]``).
The root's cells, obs, embeddings, vector fields, graph and identity, and
the modality's matrix, genes and ``obsm`` arrays, its rows placed in the
MuData's cell order. A modality that did not measure every cell is
refused: an AnnData has a row for every cell.
"""
every = [str(item) for item in mdata.mod]
check_mudata(mdata, [name])
modality = mdata.mod[name]
obsmap = np.asarray(mdata.obsmap[name])
measured = int(np.count_nonzero(obsmap))
if measured != int(mdata.n_obs):
raise ValueError(
f"Modality {name!r} measured {measured:,} of the {int(mdata.n_obs):,} {plural(int(mdata.n_obs), 'cell')}, and one "
"modality is written as an AnnData, which has a value for every cell. Write it with "
f"another modality as a MuData (modalities=[..., {name!r}]), or subset the MuData to "
"its cells first."
)
rows = obsmap.astype(np.int64) - 1
in_order = bool(np.array_equal(rows, np.arange(rows.size)))
def placed(values: Any) -> Any:
return values if in_order else values[rows]
components = _mudata_root_components(mdata, every, notes)
own = {key: placed(value) for key, value in modality.obsm.items() if is_cell_array(value)}
clash = sorted(set(own) & set(components["embeddings"]))
if clash:
raise ValueError(
f"Modality {name!r} and the MuData's root both hold obsm {clash}; an AnnData has one "
"obsm. Rename one of them first."
)
embeddings, fields = split_obsm({**components["embeddings"], **own})
components["embeddings"] = embeddings
if fields:
merged = {**components.get("vector_fields", {})}
for field, arrays in fields.items():
merged.setdefault(field, {}).update(arrays)
components["vector_fields"] = merged
components["gene_expression"] = placed(modality.X)
var, note = storable_columns(modality.var, f"mdata.mod[{name!r}].var")
components["var"] = var
if note is not None and notes is not None:
notes.append(("var", note))
if "connectivities" not in components and "connectivities" in modality.obsp:
graph = modality.obsp["connectivities"]
components["connectivities"] = graph if in_order else graph[rows][:, rows]
return components
def _copied_from_modality(key: str, names: Sequence[str], keys_of: Callable[[str], Any]) -> bool:
"""Whether ``key`` is mudata's root copy ``<modality>:<key>`` of a modality's own entry."""
name, separator, inner = key.partition(":")
return bool(separator) and name in names and inner in keys_of(name)
def _mudata_root_components(mdata: Any, names: Sequence[str], notes: list[tuple[str, str]] | None = None) -> dict[str, Any]:
"""What a MuData holds at its root, as ``prepare()`` takes it (:func:`._data.split_obsm`).
``names`` are every modality of ``mdata``: mudata's root copies of any of
them (``<name>:<column>``) and its boolean masks are not written, whichever
modalities are.
"""
obs = mdata.obs
copied = [
column
for column in obs.columns
if _copied_from_modality(str(column), names, lambda name: mdata.mod[name].obs.columns)
]
root_obsm = {
key: value
for key, value in mdata.obsm.items()
if not (key in names and np.asarray(value).dtype == bool)
and not _copied_from_modality(str(key), names, lambda name: mdata.mod[name].obsm)
}
embeddings, fields = split_obsm(root_obsm)
stored_obs, note = storable_columns(obs.drop(columns=copied), "mdata.obs")
if note is not None and notes is not None:
notes.append(("obs", note))
components: dict[str, Any] = {"obs": stored_obs, "embeddings": embeddings}
if fields:
components["vector_fields"] = fields
if "connectivities" in mdata.obsp:
components["connectivities"] = mdata.obsp["connectivities"]
components.update(cellucid_record(mdata.uns, what="data"))
return components
def _modality_contents(modality: Modality) -> AnnDataContents:
"""What one modality holds, as the launch defaults are checked against it."""
return AnnDataContents(
n_obs=len(modality.obs_index),
n_vars=len(modality.var_index),
obs_columns=frozenset(modality.obs),
var_columns=frozenset(modality.var),
obsm={key: int(values.shape[1]) for key, values in modality.obsm.items()},
obsp=frozenset(modality.obsp),
layers=frozenset(),
has_x=modality.matrix is not None,
raw_var_columns=None,
)
def _selected_var_axis(
mdata: Any, modalities: Mapping[str, Modality]
) -> tuple[np.ndarray, dict[str, Modality]]:
"""The root ``var`` and ``varmap`` of the modalities written (``modalities=``).
mudata's root var is every modality's features in modality order, and a
modality's varmap places them there (1-based, 0 for another modality's);
for a selection both are made from the modalities written alone.
"""
from dataclasses import replace
names = [np.asarray([str(value) for value in mdata.mod[name].var_names], dtype=object) for name in modalities]
var_index = np.concatenate(names) if names else np.asarray([], dtype=object)
placed: dict[str, Modality] = {}
offset = 0
for (name, item), own in zip(modalities.items(), names, strict=True):
varmap = np.zeros(len(var_index), dtype=np.uint32)
count = len(own)
varmap[offset : offset + count] = np.arange(1, count + 1, dtype=np.uint32)
offset += count
placed[name] = replace(item, varmap=varmap)
return var_index, placed
def _mudata_layout(mdata: Any, names: Sequence[str], arguments: Mapping[str, Any], given: np.ndarray | None = None) -> MuDataLayout:
"""One Cellucid-ready MuData, every component but the modalities' matrices checked.
``given`` is each cell's original position when the MuData is in a
verified stored order (:func:`prepare`), None when each row is its own.
"""
cellucid = _cellucid_identity(arguments)
n_cells = int(mdata.n_obs)
supplied = arguments.get("embeddings") or {}
if supplied:
obsm, rows = resolve_embeddings(supplied)
if rows != n_cells:
raise ValueError(
f"The embeddings have {rows:,} {plural(rows, 'row')}, and the MuData has {n_cells:,} {plural(n_cells, 'cell')} "
"(mdata.n_obs): a root embedding has one row per cell of the MuData."
)
else:
obsm = {}
vectors, fields = resolve_vector_fields(arguments.get("vector_fields"), obsm, n_cells)
given_obs = arguments.get("obs")
if given_obs is not None and len(given_obs) != n_cells:
raise ValueError(
f"obs has {len(given_obs):,} {plural(len(given_obs), 'row')}, and the MuData has {n_cells:,} {plural(n_cells, 'cell')} (mdata.n_obs): "
"obs has one row per cell of the MuData."
)
obs_index, obs_columns = resolve_obs(given_obs, n_cells)
obs_columns = without_positions(obs_columns, "obs", verified=given is not None)
connectivities = arguments.get("connectivities")
obsp = {} if connectivities is None else {
"connectivities": resolve_connectivities(connectivities, n_cells)
}
masks = {
name: np.asarray(mdata.obsm[name], dtype=bool)
for name in names
if name in mdata.obsm and np.asarray(mdata.obsm[name]).dtype == bool
}
modalities = {name: resolve_modality(mdata, name, stored=given is not None) for name in names}
if list(names) == [str(name) for name in mdata.mod]:
var_index = global_var_index(mdata)
else:
var_index, modalities = _selected_var_axis(mdata, modalities)
# The cells in priority order, each modality's rows by its cells' global
# priority, so a prefix of the cells is a prefix of every modality.
priority = CellPriority.of(n_cells, arguments.get("priority_seed"), given)
levels: np.ndarray | None = None
if priority is not None:
obsm = priority.rows_of_mapping(obsm)
vectors = priority.rows_of_mapping(vectors)
obs_index = priority.rows(obs_index)
obs_columns = priority.obs_columns(obs_columns)
obsp = {key: priority.graph(graph) for key, graph in obsp.items()}
masks = {name: priority.rows(mask) for name, mask in masks.items()}
if priority.recorded:
levels = prefix_levels(n_cells)
modalities = {name: priority.modality(item, levels) for name, item in modalities.items()}
if not obsm and not any(modality.obsm for modality in modalities.values()):
raise ValueError(
"This MuData holds no embedding: neither mdata.obsm nor any modality's obsm has "
"a numeric array. Compute one, for example sc.pp.neighbors(mdata.mod['rna']); "
"sc.tl.umap(mdata.mod['rna']), or pass embeddings=."
)
_require_vector_field_default(arguments.get("vector_field_default"), fields)
all_obsm = {**obsm, **vectors}
placed_widths = modality_obsm_widths(modalities)
latent_key = arguments.get("latent_key")
latent: np.ndarray | None = None
if latent_key is not None:
if latent_key in obsm:
latent = obsm[latent_key]
elif latent_key in placed_widths:
# A cell the modality did not measure has a NaN row: it gets no
# outlier quantile, and the other cells are measured without it.
name, _, key = latent_key.partition(":")
modality = modalities[name]
latent = placed_rows(modality.obsm[key], modality.obsmap)
else:
raise ValueError(
f"latent_key must name one of the embeddings: {[*obsm, *placed_widths]}; got {latent_key!r}."
)
chosen = arguments.get("modality")
opened = modalities.get(chosen) if isinstance(chosen, str) else None
if opened is None:
opened = modalities[default_modality(names) or names[0]]
gene_column = arguments.get("var_gene_id_column")
opened_var = mdata.mod[opened.name].var
if gene_column is not None and gene_column in opened_var.columns:
# The column names the genes of the modality the viewer opens on, by
# the rule an AnnData's var_gene_id_column is checked with (an absent
# one is named by the launch-default check below).
try:
column_gene_names(opened_var, gene_column)
except ValueError as error:
raise _modality_error(opened.name, ValueError(f"var_gene_id_column: {error}")) from error
defaults = _checked_defaults(
AnnDataContents(
n_obs=n_cells,
n_vars=len(opened.var_index),
obs_columns=frozenset(
[
*obs_columns,
*(f"{name}:{column}" for name, item in modalities.items() for column in item.obs),
*(f"{name}:measured" for name in masks),
]
),
var_columns=frozenset(opened.var),
obsm={**{key: int(values.shape[1]) for key, values in all_obsm.items()}, **placed_widths},
obsp=frozenset(
[*obsp, *(f"{name}:{key}" for name, item in modalities.items() for key in item.obsp)]
),
layers=frozenset(),
has_x=True,
raw_var_columns=None,
modalities={name: _modality_contents(item) for name, item in modalities.items()},
),
arguments,
)
cellucid = _with_defaults(
cellucid,
defaults,
obs_columns=obs_columns,
latent=latent,
priority=None if priority is None or not priority.recorded else priority.record(levels),
)
return MuDataLayout(
obs_index=obs_index,
obs=obs_columns,
obsm=all_obsm,
masks=masks,
obsp=obsp,
uns={"title": cellucid["name"], "cellucid": cellucid},
var_index=var_index,
modalities=tuple(modalities.values()),
)