Skip to content

Objects

The container, before any analysis touches it. Truecell holds one or more assays, the reductions computed off them, the neighbour graphs, the per-cell metadata and the command log — the same slots R's Seurat S4 class holds, as ordinary Python classes with __slots__.

The one structural difference worth knowing up front: R's Assay5 inherits from dgCMatrix; here Assay5 wraps a SciPy CSC matrix rather than subclassing it, because subclassing scipy.sparse is a well-known trap. Everything you reach through the generics behaves the same either way.

The object model is checked against Seurat anchor by anchor in The Object Model Itself — 91 of 91 exact, no tolerance.

The top-level object

Truecell

Truecell(assays: dict[str, AnyAssay], meta_data: DataFrame, active_assay: str, active_ident: Optional[Categorical] = None, graphs: Optional[dict[str, Graph]] = None, neighbors: Optional[dict[str, Neighbor]] = None, reductions: Optional[dict[str, DimReduc]] = None, images: Optional[dict[str, FOV]] = None, project_name: str = 'SeuratProject', misc: Optional[dict] = None, version: Optional[Version] = None, commands: Optional[list[TruecellCommand]] = None, tools: Optional[dict] = None)

Top-level Truecell single-cell data object.

Mirrors R's Seurat class from seurat.R.

Slots
  • assays (dict[str, AnyAssay])
  • meta_data (pd.DataFrame) — cells × metadata columns
  • active_assay (str)
  • active_ident (pd.Categorical)
  • graphs (dict[str, Graph])
  • neighbors (dict[str, Neighbor])
  • reductions (dict[str, DimReduc])
  • images (dict[str, FOV])
  • project_name (str)
  • misc (dict)
  • version (packaging.version.Version)
  • commands (list[TruecellCommand])
  • tools (dict)
Source code in truecell/truecell.py
def __init__(
    self,
    assays: dict[str, AnyAssay],
    meta_data: pd.DataFrame,
    active_assay: str,
    active_ident: Optional[pd.Categorical] = None,
    graphs: Optional[dict[str, Graph]] = None,
    neighbors: Optional[dict[str, Neighbor]] = None,
    reductions: Optional[dict[str, DimReduc]] = None,
    images: Optional[dict[str, FOV]] = None,
    project_name: str = "SeuratProject",
    misc: Optional[dict] = None,
    version: Optional[Version] = None,
    commands: Optional[list[TruecellCommand]] = None,
    tools: Optional[dict] = None,
) -> None:
    self.assays = assays
    self.meta_data = meta_data
    self.active_assay = active_assay
    self._active_ident = active_ident if active_ident is not None else pd.Categorical(
        meta_data.index.tolist()
    )
    self.graphs = graphs or {}
    self.neighbors = neighbors or {}
    self.reductions = reductions or {}
    self.images = images or {}
    self.project_name = project_name
    self.misc = misc or {}
    self.version = version or _VERSION
    self.commands = commands or []
    self.tools = tools or {}

image_names

image_names() -> list[str]

Names of the spatial images/FOVs (mirrors Images()).

Source code in truecell/truecell.py
def image_names(self) -> list[str]:
    """Names of the spatial images/FOVs (mirrors ``Images()``)."""
    return list(self.images)

get_tissue_coordinates

get_tissue_coordinates(image: Optional[str] = None) -> DataFrame

Centroid coordinates across images (mirrors GetTissueCoordinates).

Returns a DataFrame with columns x, y, cell, image.

Source code in truecell/truecell.py
def get_tissue_coordinates(self, image: Optional[str] = None) -> pd.DataFrame:
    """Centroid coordinates across images (mirrors ``GetTissueCoordinates``).

    Returns a DataFrame with columns ``x, y, cell, image``.
    """
    from .spatial.analysis import get_tissue_coordinates as _gtc
    return _gtc(self, image)

reorder_ident

reorder_ident(var: str, reverse: bool = False, afxn=mean) -> 'Truecell'

Reorder the identity levels by a per-ident summary of var.

Mirrors R's ReorderIdent(object, var, reverse = FALSE, afxn = mean): fetch var for every cell (a gene or a metadata column — anything fetch_data accepts), summarise it within each identity with afxn, and sort the levels by that summary, ascending.

Divergence, deliberate: R's reverse does nothing. It applies max(x) + 1 - x to the values of an already-sorted named vector and then reads names() off the result — which leaves the element order untouched, so the levels come back identical. Verified on Seurat 5.5.1: the same D,B,A,C with and without it. Here reverse=True genuinely reverses, because the alternative is shipping another argument that silently does nothing.

R's reorder.numeric is not ported. It renames every identity to a rank, and on 5.5.1 it warns Cannot find cells provided and leaves the levels unchanged, so there is no working behaviour to match.

Source code in truecell/truecell.py
def reorder_ident(
    self,
    var: str,
    reverse: bool = False,
    afxn=np.mean,
) -> "Truecell":
    """Reorder the identity levels by a per-ident summary of ``var``.

    Mirrors R's ``ReorderIdent(object, var, reverse = FALSE, afxn = mean)``:
    fetch ``var`` for every cell (a gene or a metadata column — anything
    ``fetch_data`` accepts), summarise it within each identity with
    ``afxn``, and sort the levels by that summary, ascending.

    Divergence, deliberate: **R's ``reverse`` does nothing.** It applies
    ``max(x) + 1 - x`` to the *values* of an already-sorted named vector and
    then reads ``names()`` off the result — which leaves the element order
    untouched, so the levels come back identical. Verified on Seurat 5.5.1:
    the same ``D,B,A,C`` with and without it. Here ``reverse=True``
    genuinely reverses, because the alternative is shipping another argument
    that silently does nothing.

    R's ``reorder.numeric`` is not ported. It renames every identity to a
    rank, and on 5.5.1 it warns ``Cannot find cells provided`` and leaves the
    levels unchanged, so there is no working behaviour to match.
    """
    values = self.fetch_data([var]).iloc[:, 0]
    idents = pd.Series([str(i) for i in self._active_ident],
                       index=self.cell_names())
    summary = values.groupby(idents.reindex(values.index)).agg(afxn).sort_values()
    levels = list(summary.index)
    if reverse:
        levels = levels[::-1]
    # Levels the summary never saw (an identity with no cells in `values`)
    # would be dropped by `categories=`, turning their cells into NaN.
    levels += [lv for lv in pd.unique(idents) if lv not in levels]
    self._active_ident = pd.Categorical(
        list(self._active_ident), categories=levels, ordered=True
    )
    return self

subset

subset(cells: Optional[list[str]] = None, features: Optional[list[str]] = None, idents: Optional[Union[str, list[str]]] = None) -> 'Truecell'

Restrict the object to cells and/or features.

Mirrors R's subset(x, cells = , features = , idents = ).

The result keeps the object's cell order whatever order cells arrives in, as Seurat's intersect(colnames(x), cells) does. Every slot is read by position against cell_names(), so the order is settled once, here, before any slot is subset. Taking the caller's order used to reach only some of them — the metadata, the assay's cell axis, the reductions and the graphs followed the request, while every layer, the identities and the image coordinates stayed in object order — so a reordered request paired nCount with another cell's counts and Moran's I with another cell's coordinates.

Identities are carried by cell name, keep their level order, and drop the levels no retained cell carries (Idents(x, drop = TRUE)).

A name in cells that the object does not have raises KeyError. Seurat drops it silently; a misspelt barcode is better reported.

Source code in truecell/truecell.py
def subset(
    self,
    cells: Optional[list[str]] = None,
    features: Optional[list[str]] = None,
    idents: Optional[Union[str, list[str]]] = None,
) -> "Truecell":
    """Restrict the object to ``cells`` and/or ``features``.

    Mirrors R's ``subset(x, cells = , features = , idents = )``.

    The result keeps the **object's** cell order whatever order ``cells``
    arrives in, as Seurat's ``intersect(colnames(x), cells)`` does. Every slot
    is read by position against ``cell_names()``, so the order is settled once,
    here, before any slot is subset. Taking the caller's order used to reach
    only some of them — the metadata, the assay's cell axis, the reductions and
    the graphs followed the request, while every layer, the identities and the
    image coordinates stayed in object order — so a reordered request paired
    ``nCount`` with another cell's counts and Moran's I with another cell's
    coordinates.

    Identities are carried by cell name, keep their level order, and drop the
    levels no retained cell carries (``Idents(x, drop = TRUE)``).

    A name in ``cells`` that the object does not have raises ``KeyError``.
    Seurat drops it silently; a misspelt barcode is better reported.
    """
    if idents is not None:
        cells = self.which_cells(ident=idents, cells=cells)
    index = self.meta_data.index
    if cells is None:
        positions = np.arange(len(index))
    else:
        wanted = set(cells)
        keep = index.isin(wanted)
        if int(keep.sum()) < len(wanted):
            known = set(index)
            missing = [c for c in dict.fromkeys(cells) if c not in known]
            raise KeyError(f"{len(missing)} cell(s) not in the object: {missing[:5]}")
        positions = np.flatnonzero(keep)
    cells = index[positions].tolist()

    new_meta = self.meta_data.iloc[positions].copy()
    new_assays = {name: a.subset(cells=cells, features=features) for name, a in self.assays.items()}
    new_ident = self._subset_idents(self._active_ident, positions)
    new_reductions = {name: r.subset(cells=cells) for name, r in self.reductions.items()}
    new_images = {name: img.subset(cells) for name, img in self.images.items()}

    # Subset each cell×cell graph to the retained cells. Mirrors Seurat,
    # which subsets graphs rather than carrying the full-size matrix.
    new_graphs = {name: g.subset(cells) for name, g in self.graphs.items()}
    # Neighbor objects store integer KNN indices into the original cell
    # ordering; those indices are invalidated by subsetting, so (as Seurat
    # does) drop them — re-run find_neighbors() on the subset.
    new_neighbors: dict[str, Neighbor] = {}

    return Truecell(
        assays=new_assays,
        meta_data=new_meta,
        active_assay=self.active_assay,
        active_ident=new_ident,
        graphs=new_graphs,
        neighbors=new_neighbors,
        reductions=new_reductions,
        images=new_images,
        project_name=self.project_name,
        misc=dict(self.misc),
        version=self.version,
        commands=list(self.commands),
        tools=dict(self.tools),
    )

create_truecell_object

create_truecell_object(counts, assay: str = 'RNA', min_cells: int = 0, min_features: int = 0, project: str = 'SeuratProject', feature_names: Optional[list[str]] = None, cell_names: Optional[list[str]] = None, meta_data: Optional[DataFrame] = None, use_v5: bool = True) -> Truecell

Create a Truecell object from a counts matrix.

Mirrors R's CreateSeuratObject().

Parameters:

  • counts –

    sparse or dense matrix (features × cells)

  • assay (str, default: 'RNA' ) –

    assay name (default "RNA")

  • min_cells (int, default: 0 ) –

    min cells a feature must be detected in to be kept

  • min_features (int, default: 0 ) –

    min features a cell must have to be kept

  • project (str, default: 'SeuratProject' ) –

    project name

  • feature_names (Optional[list[str]], default: None ) –

    optional list of feature (gene) names

  • cell_names (Optional[list[str]], default: None ) –

    optional list of cell barcodes

  • meta_data (Optional[DataFrame], default: None ) –

    optional per-cell metadata DataFrame

  • use_v5 (bool, default: True ) –

    if True, create Assay5 (v5); else Assay (v3)

Source code in truecell/truecell.py
def create_truecell_object(
    counts,
    assay: str = "RNA",
    min_cells: int = 0,
    min_features: int = 0,
    project: str = "SeuratProject",
    feature_names: Optional[list[str]] = None,
    cell_names: Optional[list[str]] = None,
    meta_data: Optional[pd.DataFrame] = None,
    use_v5: bool = True,
) -> Truecell:
    """Create a Truecell object from a counts matrix.

    Mirrors R's CreateSeuratObject().

    Parameters
    ----------
    counts       : sparse or dense matrix (features × cells)
    assay        : assay name (default "RNA")
    min_cells    : min cells a feature must be detected in to be kept
    min_features : min features a cell must have to be kept
    project      : project name
    feature_names: optional list of feature (gene) names
    cell_names   : optional list of cell barcodes
    meta_data    : optional per-cell metadata DataFrame
    use_v5       : if True, create Assay5 (v5); else Assay (v3)
    """
    key = f"{assay.lower()}_"

    # Each branch keeps its own narrowly-typed name and widens once, at the
    # end: the two classes hold their cells in differently-named slots, so a
    # single `assay_obj` reused across both branches has no type under which
    # both reads are valid.
    assay_obj: AnyAssay
    if use_v5:
        v5 = create_assay5_object(
            counts=counts,
            min_cells=min_cells,
            min_features=min_features,
            feature_names=feature_names,
            cell_names=cell_names,
            key=key,
        )
        cells = v5._all_cell_names
        assay_obj = v5
    else:
        v3 = create_assay_object(
            counts=counts,
            min_cells=min_cells,
            min_features=min_features,
            feature_names=feature_names,
            cell_names=cell_names,
            key=key,
        )
        cells = v3._cell_names
        assay_obj = v3

    # Build metadata. Both assay classes define `calc_n`, so the
    # `hasattr(assay_obj, "calc_n")` fallback that used to stand here was
    # unreachable — a second implementation of the same naming that nothing
    # exercised, deriving the suffix from the assay's *key* rather than from
    # the `assay` argument, and hardcoding `nCount_RNA` when the assay had no
    # default layer. The two agree for an ordinary RNA object, which is why
    # neither the tests nor the type checker had reason to look at it.
    raw_meta = assay_obj.calc_n()
    base_meta = raw_meta.rename(columns={"nCount": f"nCount_{assay}", "nFeature": f"nFeature_{assay}"})
    # `orig.ident` is the first column of every Seurat object's metadata and the
    # default identity class; scripts group and split on it. Seeded with the
    # project name, as `CreateSeuratObject` does.
    base_meta.insert(0, "orig.ident",
                     pd.Categorical([project] * len(cells), categories=[project]))

    if meta_data is not None:
        # Align user-supplied metadata to filtered cells
        supplied = meta_data.reindex(cells)
        for col in supplied.columns:
            base_meta[col] = supplied[col].values

    active_ident = pd.Categorical([project] * len(cells), categories=[project])

    obj = Truecell(
        assays={assay: assay_obj},
        meta_data=base_meta,
        active_assay=assay,
        active_ident=active_ident,
        project_name=project,
    )
    return obj

diet_truecell strips an object down before saving or sharing it. Note that dimreducs and graphs are keep-lists: calling it with no arguments removes every reduction and graph, which is Seurat's behaviour too.

diet_truecell

diet_truecell(seurat, layers: Optional[Union[str, list[str], dict[str, Union[str, list[str]]]]] = None, features: Optional[list[str]] = None, assays: Optional[Union[str, list[str]]] = None, dimreducs: Optional[Union[str, list[str]]] = None, graphs: Optional[Union[str, list[str]]] = None, misc: bool = True)

Slim an object down to the pieces you name.

Mirrors R's DietSeurat(object). Returns a new object; the one passed in is left alone, and the layers that survive are shared rather than copied, so this frees memory rather than doubling it.

Parameters:

  • layers (Optional[Union[str, list[str], dict[str, Union[str, list[str]]]]], default: None ) –

    layers to keep. A name or list applies to every assay; a dict ({"RNA": "counts"}) names them per assay. None keeps all.

  • features (Optional[list[str]], default: None ) –

    keep only these features. An assay left with none of them is dropped, with a warning.

  • assays (Optional[Union[str, list[str]]], default: None ) –

    assays to keep. None keeps all.

  • dimreducs (Optional[Union[str, list[str]]], default: None ) –

    reductions to keep. None keeps none — see below.

  • graphs (Optional[Union[str, list[str]]], default: None ) –

    graphs to keep. None keeps none — see below.

  • misc (bool, default: True ) –

    False empties the object-level misc.

Returns:

  • A new ``Truecell``. –
Warning

diet_truecell(obj) with no arguments deletes every reduction and every graph. That is R's behaviour, not a translation slip — dimreducs and graphs are keep-lists, and an unset keep-list keeps nothing. Verified against Seurat 5.5.1: a pbmc3k object with a pca and two graphs comes back with zero of each, and all three layers untouched. Name what you want kept::

slim = diet_truecell(obj, layers="counts", dimreducs="pca")
Notes
  • Neighbor objects are left alone, as in R: DietSeurat filters only DimReduc and Graph.
  • A v3 Assay cannot give up both counts and data — Seurat raises rather than leave the assay with no expression matrix, and so does this.
  • Cells are re-intersected against the surviving assays at the end, which only bites when features empties an assay and removes it.
Source code in truecell/diet.py
def diet_truecell(
    seurat,
    layers: Optional[Union[str, list[str], dict[str, Union[str, list[str]]]]] = None,
    features: Optional[list[str]] = None,
    assays: Optional[Union[str, list[str]]] = None,
    dimreducs: Optional[Union[str, list[str]]] = None,
    graphs: Optional[Union[str, list[str]]] = None,
    misc: bool = True,
):
    """Slim an object down to the pieces you name.

    Mirrors R's ``DietSeurat(object)``. Returns a **new** object; the one passed
    in is left alone, and the layers that survive are shared rather than copied,
    so this frees memory rather than doubling it.

    Parameters
    ----------
    layers    : layers to keep. A name or list applies to every assay; a dict
                (``{"RNA": "counts"}``) names them per assay. ``None`` keeps all.
    features  : keep only these features. An assay left with none of them is
                dropped, with a warning.
    assays    : assays to keep. ``None`` keeps all.
    dimreducs : reductions to keep. **``None`` keeps none** — see below.
    graphs    : graphs to keep. **``None`` keeps none** — see below.
    misc      : ``False`` empties the object-level ``misc``.

    Returns
    -------
    A new ``Truecell``.

    Warning
    -------
    **``diet_truecell(obj)`` with no arguments deletes every reduction and every
    graph.** That is R's behaviour, not a translation slip — ``dimreducs`` and
    ``graphs`` are keep-lists, and an unset keep-list keeps nothing. Verified
    against Seurat 5.5.1: a pbmc3k object with a `pca` and two graphs comes back
    with zero of each, and all three layers untouched. Name what you want kept::

        slim = diet_truecell(obj, layers="counts", dimreducs="pca")

    Notes
    -----
    - **Neighbor objects are left alone**, as in R: ``DietSeurat`` filters only
      ``DimReduc`` and ``Graph``.
    - A v3 ``Assay`` cannot give up both ``counts`` and ``data`` — Seurat raises
      rather than leave the assay with no expression matrix, and so does this.
    - Cells are re-intersected against the surviving assays at the end, which
      only bites when ``features`` empties an assay and removes it.
    """
    available = list(seurat.assays)
    if assays is None:
        keep_assays = list(available)
    else:
        want = [assays] if isinstance(assays, str) else list(assays)
        keep_assays = [a for a in available if a in want]
    if not keep_assays:
        raise ValueError("No assays provided were found in the Truecell object")
    if seurat.active_assay not in keep_assays:
        raise ValueError(
            f"The default assay ({seurat.active_assay!r}) is slated to be "
            f"removed, please change the default assay"
        )

    keep_layers = _resolve_layers(layers, keep_assays, seurat.assays)
    if not keep_layers:
        raise ValueError("None of the requested layers found")

    new_assays = {}
    for name in keep_assays:
        assay = seurat.assays[name]
        wanted = keep_layers.get(name)
        if not wanted:
            continue
        if isinstance(assay, Assay):
            # v3 keeps its matrices in fixed slots, so "removing" a layer means
            # emptying it; losing both counts and data would leave nothing to
            # express the assay with, which R refuses rather than allows.
            if "counts" not in wanted and "data" not in wanted:
                raise ValueError("Cannot remove both 'counts' and 'data' from v3 Assays")
            slim = copy.copy(assay)
            slim.misc = dict(assay.misc)
            for slot, attr in (("counts", "counts"), ("data", "data"),
                               ("scale.data", "scale_data")):
                if slot not in wanted:
                    setattr(slim, attr, _empty_like(getattr(assay, attr)))
        else:
            slim = _slim_assay(assay, wanted)

        if features is not None:
            present = [f for f in features if f in set(slim.features())]
            if not present:
                warnings.warn(
                    f"No features found in assay {name!r}, removing...",
                    stacklevel=2,
                )
                continue
            slim = slim.subset(features=present)
        new_assays[name] = slim

    if not new_assays:
        raise ValueError("No assays survived; nothing left to diet")
    if seurat.active_assay not in new_assays:
        raise ValueError(
            f"The default assay ({seurat.active_assay!r}) was removed by the "
            f"`features` filter, please change the default assay"
        )

    def _keep(requested, have):
        if requested is None:
            return {}
        want = [requested] if isinstance(requested, str) else list(requested)
        return {k: v for k, v in have.items() if k in want}

    out = seurat.__class__(
        assays=new_assays,
        meta_data=seurat.meta_data.copy(),
        active_assay=seurat.active_assay,
        active_ident=seurat._active_ident,
        graphs=_keep(graphs, seurat.graphs),
        neighbors=dict(seurat.neighbors),
        reductions=_keep(dimreducs, seurat.reductions),
        images=dict(seurat.images),
        project_name=seurat.project_name,
        misc=dict(seurat.misc) if misc else {},
        version=seurat.version,
        commands=list(seurat.commands),
        tools=dict(seurat.tools),
    )

    # R re-intersects the object's cells against what the assays still hold.
    # Only `features` emptying an assay can move this, but when it does the
    # metadata and idents have to follow the assays rather than lead them.
    surviving = set()
    for assay in new_assays.values():
        surviving.update(assay.cells())
    kept_cells = [c for c in out.cell_names() if c in surviving]
    if len(kept_cells) < len(out.cell_names()):
        out = out.subset(cells=kept_cells)
    return out

Assays

Assay5

Assay5(layers: dict[str, Union[ndarray, spmatrix]], feature_names: list[str], cell_names: list[str], assay_orig: Optional[str] = None, meta_data: Optional[DataFrame] = None, misc: Optional[dict] = None, key: str = 'rna_', default: int = 0, layer_features: Optional[dict[str, list[str]]] = None, layer_cells: Optional[dict[str, list[str]]] = None)

Bases: StdAssay

Modern layered assay (v5).

Mirrors R's Assay5 class from assay5.R. Extends StdAssay with no additional slots.

Source code in truecell/assay5.py
def __init__(
    self,
    layers: dict[str, Union[np.ndarray, sp.spmatrix]],
    feature_names: list[str],
    cell_names: list[str],
    assay_orig: Optional[str] = None,
    meta_data: Optional[pd.DataFrame] = None,
    misc: Optional[dict] = None,
    key: str = "rna_",
    default: int = 0,
    layer_features: Optional[dict[str, list[str]]] = None,
    layer_cells: Optional[dict[str, list[str]]] = None,
) -> None:
    self._key = key
    self.assay_orig = assay_orig
    self.misc = misc or {}
    self.default = default

    validate_feature_names(feature_names)
    validate_cell_names(cell_names)
    self._all_feature_names = list(feature_names)
    self._all_cell_names = list(cell_names)

    self.layers: dict[str, Union[np.ndarray, sp.spmatrix]] = {}
    self._cells = LogMap()
    self._features = LogMap()
    # Per-layer *ordered* feature / cell names. A layer may legitimately
    # span only a subset of the assay's features (e.g. scale.data holds
    # only the variable features, as in Seurat) or cells.
    self._layer_features: dict[str, list[str]] = {}
    self._layer_cells: dict[str, list[str]] = {}
    self._scaled_features: list[str] = []
    self._var_features: list[str] = []
    # Layer name -> the layer it was split from. See `_stem_of`.
    self._split_stems: dict[str, str] = {}

    layer_features = layer_features or {}
    layer_cells = layer_cells or {}
    for name, mat in layers.items():
        self._add_layer(
            name, mat,
            feature_names=layer_features.get(name),
            cell_names=layer_cells.get(name),
        )

    self.meta_data = (
        meta_data
        if meta_data is not None
        else pd.DataFrame(index=self._all_feature_names)
    )

create_assay5_object

create_assay5_object(counts=None, data=None, min_cells: int = 0, min_features: int = 0, feature_names: Optional[list[str]] = None, cell_names: Optional[list[str]] = None, key: str = 'rna_') -> Assay5
Source code in truecell/assay5.py
def create_assay5_object(
    counts=None,
    data=None,
    min_cells: int = 0,
    min_features: int = 0,
    feature_names: Optional[list[str]] = None,
    cell_names: Optional[list[str]] = None,
    key: str = "rna_",
) -> Assay5:
    matrix = counts if counts is not None else data
    if matrix is None:
        raise ValueError("Provide at least one of 'counts' or 'data'.")

    if is_lazy(matrix):
        # Keep the on-disk layer on disk. `np.asarray` below would read the
        # whole store into a dense array here in the constructor, so the
        # obvious way to use the feature -- open a store, build an object
        # around it -- would end the laziness before any analysis began.
        mat = matrix
    elif sp.issparse(matrix):
        mat = matrix.tocsc()
    else:
        mat = sp.csc_matrix(np.asarray(matrix))

    n_features, n_cells = mat.shape

    if feature_names is None:
        feature_names = [f"feature_{i}" for i in range(n_features)]
    else:
        # Only names the caller supplied are rewritten. The generated fallback
        # above is internal and has no Seurat counterpart, so mangling it to
        # `feature-0` would be a change Seurat never makes.
        feature_names = sanitize_feature_names(feature_names)
    if cell_names is None:
        cell_names = [f"cell_{i}" for i in range(n_cells)]


    # Filter features by min_cells. Either filter subsets the lazy store
    # through its own indexer, which yields an in-memory sparse block of just
    # the kept entries -- a filtered assay cannot stay on disk without writing
    # a second store, but it need never be dense to get there.
    if min_cells > 0:
        nnz_per_feat = (mat.nnz_per_row() if is_lazy(mat)
                        else np.diff(mat.T.tocsc().indptr))
        keep_feat = np.where(nnz_per_feat >= min_cells)[0]
        mat = mat[keep_feat, :]
        feature_names = [feature_names[i] for i in keep_feat]

    # Filter cells by min_features
    if min_features > 0:
        nnz_per_cell = (mat.nnz_per_col() if is_lazy(mat)
                        else np.diff(mat.tocsc().indptr))
        keep_cell = np.where(nnz_per_cell >= min_features)[0]
        mat = mat[:, keep_cell]
        cell_names = [cell_names[i] for i in keep_cell]

    layer_name = "counts" if counts is not None else "data"
    return Assay5(
        layers={layer_name: mat},
        feature_names=list(feature_names),
        cell_names=list(cell_names),
        key=key,
    )

StdAssay

StdAssay(layers: dict[str, Union[ndarray, spmatrix]], feature_names: list[str], cell_names: list[str], assay_orig: Optional[str] = None, meta_data: Optional[DataFrame] = None, misc: Optional[dict] = None, key: str = 'rna_', default: int = 0, layer_features: Optional[dict[str, list[str]]] = None, layer_cells: Optional[dict[str, list[str]]] = None)

Bases: KeyMixin, ABC

Abstract base for layered assays (v5 architecture).

Mirrors R's StdAssay virtual class from assay5.R. Unlike the legacy Assay (v3), StdAssay stores arbitrary named layers and uses LogMap to track which cells/features belong to each layer.

Slots
  • layers (dict[str, AnyMatrix]) — named expression matrices (features × cells)
  • cells (LogMap) — per-layer boolean cell membership
  • features (LogMap) — per-layer boolean feature membership
  • default (int) — index of the default layer
  • assay_orig (Optional[str])
  • meta_data (pd.DataFrame) — per-feature metadata
  • misc (dict)
  • _key (str) — inherited from KeyMixin
Source code in truecell/assay5.py
def __init__(
    self,
    layers: dict[str, Union[np.ndarray, sp.spmatrix]],
    feature_names: list[str],
    cell_names: list[str],
    assay_orig: Optional[str] = None,
    meta_data: Optional[pd.DataFrame] = None,
    misc: Optional[dict] = None,
    key: str = "rna_",
    default: int = 0,
    layer_features: Optional[dict[str, list[str]]] = None,
    layer_cells: Optional[dict[str, list[str]]] = None,
) -> None:
    self._key = key
    self.assay_orig = assay_orig
    self.misc = misc or {}
    self.default = default

    validate_feature_names(feature_names)
    validate_cell_names(cell_names)
    self._all_feature_names = list(feature_names)
    self._all_cell_names = list(cell_names)

    self.layers: dict[str, Union[np.ndarray, sp.spmatrix]] = {}
    self._cells = LogMap()
    self._features = LogMap()
    # Per-layer *ordered* feature / cell names. A layer may legitimately
    # span only a subset of the assay's features (e.g. scale.data holds
    # only the variable features, as in Seurat) or cells.
    self._layer_features: dict[str, list[str]] = {}
    self._layer_cells: dict[str, list[str]] = {}
    self._scaled_features: list[str] = []
    self._var_features: list[str] = []
    # Layer name -> the layer it was split from. See `_stem_of`.
    self._split_stems: dict[str, str] = {}

    layer_features = layer_features or {}
    layer_cells = layer_cells or {}
    for name, mat in layers.items():
        self._add_layer(
            name, mat,
            feature_names=layer_features.get(name),
            cell_names=layer_cells.get(name),
        )

    self.meta_data = (
        meta_data
        if meta_data is not None
        else pd.DataFrame(index=self._all_feature_names)
    )

set_layer_data

set_layer_data(layer: str, value: Union[ndarray, spmatrix], cell_names: Optional[list[str]] = None, feature_names: Optional[list[str]] = None) -> None

Store (or replace) a layer.

feature_names / cell_names declare which features / cells the matrix spans, so a layer may legitimately cover only a subset (e.g. scale.data over the variable features). When replacing an existing layer without supplying names, the previous span is reused.

Source code in truecell/assay5.py
def set_layer_data(
    self,
    layer: str,
    value: Union[np.ndarray, sp.spmatrix],
    cell_names: Optional[list[str]] = None,
    feature_names: Optional[list[str]] = None,
) -> None:
    """Store (or replace) a layer.

    ``feature_names`` / ``cell_names`` declare which features / cells the
    matrix spans, so a layer may legitimately cover only a subset (e.g.
    scale.data over the variable features). When replacing an existing
    layer without supplying names, the previous span is reused.
    """
    if layer in self.layers:
        if feature_names is None:
            feature_names = self._layer_features.get(layer)
        if cell_names is None:
            cell_names = self._layer_cells.get(layer)
        del self.layers[layer]
    self._add_layer(layer, value, feature_names=feature_names, cell_names=cell_names)

join_layers

join_layers(layers: Optional[list[str]] = None) -> Self

Rejoin split layers, restoring the name, order and contents.

Mirrors R's JoinLayers. Each split stem is rejoined separately — counts.batch1 and counts.batch2 become counts again — and layers that were never split are left alone, which is what makes the no-argument call safe on a prepared assay that also holds data and a variable-features-only scale.data.

The rejoined columns come back in the assay's cell order, not in the order the split happened to produce. The assay's own cell vector never moved during the split, so anything else would leave the matrix silently transposed against the metadata that indexes it.

Source code in truecell/assay5.py
def join_layers(self, layers: Optional[list[str]] = None) -> Self:
    """Rejoin split layers, restoring the name, order and contents.

    Mirrors R's ``JoinLayers``. Each split *stem* is rejoined separately —
    ``counts.batch1`` and ``counts.batch2`` become ``counts`` again — and
    layers that were never split are left alone, which is what makes the
    no-argument call safe on a prepared assay that also holds ``data`` and a
    variable-features-only ``scale.data``.

    The rejoined columns come back in the **assay's** cell order, not in the
    order the split happened to produce. The assay's own cell vector never
    moved during the split, so anything else would leave the matrix silently
    transposed against the metadata that indexes it.
    """
    if layers is not None:
        # Explicit list: caller names the parts, so take the stem from the
        # recorded provenance and fall back to the shared prefix.
        parts = [n for n in layers if n in self.layers]
        if not parts:
            return self
        stems = {self._stem_of(n) for n in parts}
        stem = stems.pop() if len(stems) == 1 and None not in stems else None
        if stem is None:
            stem = os.path.commonprefix(parts).rstrip(_SPLIT_SEP)
        groups = {stem or parts[0]: parts}
    else:
        groups = {}
        for name in self.layers:
            stem = self._stem_of(name)
            if stem is not None:
                groups.setdefault(stem, []).append(name)
    if not groups:
        return self

    new_obj = self._copy()
    for stem, parts in groups.items():
        features = list(self._layer_features.get(
            parts[0], self._all_feature_names))
        for part in parts[1:]:
            if list(self._layer_features.get(part, self._all_feature_names)) != features:
                raise ValueError(
                    f"Cannot join layers {parts!r}: they do not span the "
                    f"same features."
                )

        mats = [self.layers[p] for p in parts]
        combined = (sp.hstack(mats, format="csc") if sp.issparse(mats[0])
                    else np.hstack(mats))

        # Column j of `combined` belongs to concat_cells[j]; put them back
        # into the assay's order.
        concat_cells = [c for p in parts
                        for c in self._layer_cells.get(p, self._all_cell_names)]
        position = {c: j for j, c in enumerate(concat_cells)}
        ordered = [c for c in self._all_cell_names if c in position]
        combined = combined[:, [position[c] for c in ordered]]

        for part in parts:
            new_obj._drop_layer(part)
        new_obj._add_layer(stem, combined,
                           feature_names=features, cell_names=ordered)
    return new_obj

split_layers

split_layers(f: list[str], layer: Optional[str] = None) -> Self

Split one layer into per-group layers, as R's split() does.

The parts are named <layer>.<group> — Seurat's spelling, which users match on with Layers(obj, pattern = "counts") — and each records the layer it came from so join_layers can put it back.

Source code in truecell/assay5.py
def split_layers(self, f: list[str], layer: Optional[str] = None) -> Self:
    """Split one layer into per-group layers, as R's ``split()`` does.

    The parts are named ``<layer>.<group>`` — Seurat's spelling, which users
    match on with ``Layers(obj, pattern = "counts")`` — and each records the
    layer it came from so :meth:`join_layers` can put it back.
    """
    if layer is None:
        layer = self.default_layer
    if layer is None:
        raise ValueError("No layer to split.")
    mat = self.layers[layer]
    if len(f) != mat.shape[1]:
        raise ValueError("f must have one entry per cell.")

    groups: dict[str, list[int]] = {}
    for i, g in enumerate(f):
        groups.setdefault(str(g), []).append(i)

    new_obj = self._copy()
    base_feats = list(new_obj._layer_features.get(layer, new_obj._all_feature_names))
    base_cells = list(new_obj._layer_cells.get(layer, new_obj._all_cell_names))
    new_obj._drop_layer(layer)
    for g, idxs in groups.items():
        key = f"{layer}{_SPLIT_SEP}{g}"
        new_obj._add_layer(
            key, mat[:, idxs],
            feature_names=base_feats,
            cell_names=[base_cells[i] for i in idxs],
        )
        new_obj._split_stems[key] = layer
    return new_obj

Assay

Assay(counts: Optional[Union[ndarray, spmatrix]] = None, data: Optional[Union[ndarray, spmatrix]] = None, scale_data: Optional[ndarray] = None, scaled_features: Optional[list[str]] = None, feature_names: Optional[list[str]] = None, cell_names: Optional[list[str]] = None, assay_orig: Optional[str] = None, var_features: Optional[list[str]] = None, meta_features: Optional[DataFrame] = None, misc: Optional[dict] = None, key: str = 'rna_')

Bases: KeyMixin

Legacy (v3) Assay object.

Mirrors R's Assay class from assay.R.

Slots
  • counts — raw counts / TPMs (features × cells)
  • data — normalised expression (features × cells)
  • scale_data — scaled expression (features × cells, dense) — a subset of the features, since ScaleData defaults to the variable ones. R's slot is a matrix and carries its own rownames; a bare ndarray does not, so the labels live alongside it in _scaled_features and every read of the layer goes through features("scale_data").
  • assay_orig — name of original assay this was derived from
  • var_features — list of highly variable feature names
  • meta_features — per-feature metadata DataFrame (features × cols)
  • misc (dict) — for miscellaneous storage
  • _key (str) — ing key prefix (inherited from KeyMixin)
Source code in truecell/assay.py
def __init__(
    self,
    counts: Optional[Union[np.ndarray, sp.spmatrix]] = None,
    data: Optional[Union[np.ndarray, sp.spmatrix]] = None,
    scale_data: Optional[np.ndarray] = None,
    scaled_features: Optional[list[str]] = None,
    feature_names: Optional[list[str]] = None,
    cell_names: Optional[list[str]] = None,
    assay_orig: Optional[str] = None,
    var_features: Optional[list[str]] = None,
    meta_features: Optional[pd.DataFrame] = None,
    misc: Optional[dict] = None,
    key: str = "rna_",
) -> None:
    self.key = key  # validated by KeyMixin setter

    # Resolve which matrix to use
    if counts is not None and data is None:
        matrix = counts
    elif data is not None and counts is None:
        matrix = data
    elif counts is not None and data is not None:
        matrix = counts  # counts takes precedence for shape
    else:
        raise ValueError("Provide at least one of 'counts' or 'data'.")

    n_features = matrix.shape[0]
    n_cells = matrix.shape[1]

    if feature_names is None:
        feature_names = [f"feature_{i}" for i in range(n_features)]
    if cell_names is None:
        cell_names = [f"cell_{i}" for i in range(n_cells)]

    validate_feature_names(feature_names)
    validate_cell_names(cell_names)

    if len(feature_names) != n_features:
        raise ValueError(
            f"feature_names length {len(feature_names)} != matrix rows {n_features}."
        )
    if len(cell_names) != n_cells:
        raise ValueError(
            f"cell_names length {len(cell_names)} != matrix cols {n_cells}."
        )

    self._feature_names = list(feature_names)
    self._cell_names = list(cell_names)

    self.counts = counts if counts is not None else empty_sparse(n_features, n_cells)
    self.data = data if data is not None else self.counts
    self.scale_data = scale_data if scale_data is not None else empty_dense(0, n_cells)
    self._scaled_features = self._resolve_scaled_features(scaled_features)
    self.assay_orig = assay_orig
    self.var_features = list(var_features) if var_features else []
    self.meta_features = (
        meta_features
        if meta_features is not None
        else pd.DataFrame(index=self._feature_names)
    )
    self.misc = misc or {}

subset

subset(cells: Optional[list[str]] = None, features: Optional[list[str]] = None) -> 'Assay'

Restrict the assay to cells and/or features, in the order given.

Every slot follows that order together, so the result is consistent whichever order it is. Seurat's subset.Assay keeps the assay's own cell order (and the requested feature order); Truecell.subset hands the cells over in object order already, so the two agree through the object.

Source code in truecell/assay.py
def subset(
    self,
    cells: Optional[list[str]] = None,
    features: Optional[list[str]] = None,
) -> "Assay":
    """Restrict the assay to ``cells`` and/or ``features``, in the order given.

    Every slot follows that order together, so the result is consistent
    whichever order it is. Seurat's ``subset.Assay`` keeps the assay's own cell
    order (and the requested feature order); :meth:`Truecell.subset` hands the
    cells over in object order already, so the two agree through the object.
    """
    col_idx = (
        [self._cell_names.index(c) for c in cells]
        if cells is not None
        else list(range(len(self._cell_names)))
    )
    row_idx = (
        [self._feature_names.index(f) for f in features]
        if features is not None
        else list(range(len(self._feature_names)))
    )

    def _sub(mat):
        if is_matrix_empty(mat):
            return mat
        return mat[row_idx, :][:, col_idx]

    new_features = [self._feature_names[i] for i in row_idx]
    new_cells = [self._cell_names[i] for i in col_idx]
    new_meta = self.meta_features.iloc[row_idx].copy()
    new_var = [f for f in self.var_features if f in set(new_features)]

    # Subset scale_data by *name*, not by the assay's row indices: it holds
    # its own subset of features, so `row_idx` addresses a different matrix.
    # Dropping the layer whenever it was not full-height — which is what
    # stood here, and which is every assay ScaleData has touched — silently
    # cost the subset its scaled values.
    sd = self.scale_data
    if is_matrix_empty(sd):
        new_sd = empty_dense(0, len(new_cells))
        new_scaled = []
    else:
        keep = set(new_features)
        sd_rows = [i for i, f in enumerate(self._scaled_features) if f in keep]
        new_sd = sd[sd_rows, :][:, col_idx]
        new_scaled = [self._scaled_features[i] for i in sd_rows]

    return Assay(
        counts=_sub(self.counts),
        data=_sub(self.data),
        scale_data=new_sd,
        scaled_features=new_scaled,
        feature_names=new_features,
        cell_names=new_cells,
        assay_orig=self.assay_orig,
        var_features=new_var,
        meta_features=new_meta,
        misc=dict(self.misc),
        key=self._key,
    )

create_assay_object

create_assay_object(counts=None, data=None, min_cells: int = 0, min_features: int = 0, feature_names: Optional[list[str]] = None, cell_names: Optional[list[str]] = None, key: str = 'rna_') -> Assay

Create an Assay, optionally filtering by min_cells / min_features.

Source code in truecell/assay.py
def create_assay_object(
    counts=None,
    data=None,
    min_cells: int = 0,
    min_features: int = 0,
    feature_names: Optional[list[str]] = None,
    cell_names: Optional[list[str]] = None,
    key: str = "rna_",
) -> Assay:
    """Create an Assay, optionally filtering by min_cells / min_features."""
    matrix = counts if counts is not None else data

    if matrix is None:
        raise ValueError("Provide at least one of 'counts' or 'data'.")

    if sp.issparse(matrix):
        mat_csc = matrix.tocsc()
    else:
        mat_csc = sp.csc_matrix(np.asarray(matrix))

    n_features, n_cells = mat_csc.shape

    if feature_names is None:
        feature_names = [f"feature_{i}" for i in range(n_features)]
    else:
        # Only names the caller supplied are rewritten. The generated fallback
        # above is internal and has no Seurat counterpart, so mangling it to
        # `feature-0` would be a change Seurat never makes.
        feature_names = sanitize_feature_names(feature_names)
    if cell_names is None:
        cell_names = [f"cell_{i}" for i in range(n_cells)]

    # Filter features by min_cells
    if min_cells > 0:
        cell_counts_per_feature = np.diff(mat_csc.T.tocsc().indptr)
        keep_feat = np.where(cell_counts_per_feature >= min_cells)[0]
        mat_csc = mat_csc[keep_feat, :]
        feature_names = [feature_names[i] for i in keep_feat]

    # Filter cells by min_features
    if min_features > 0:
        feat_counts_per_cell = np.diff(mat_csc.tocsc().indptr)
        keep_cell = np.where(feat_counts_per_cell >= min_features)[0]
        mat_csc = mat_csc[:, keep_cell]
        cell_names = [cell_names[i] for i in keep_cell]

    if counts is not None:
        return Assay(counts=mat_csc, feature_names=feature_names, cell_names=cell_names, key=key)
    return Assay(data=mat_csc, feature_names=feature_names, cell_names=cell_names, key=key)

Reductions, graphs and neighbours

DimReduc

DimReduc(cell_embeddings: ndarray, cell_names: list[str], feature_loadings: Optional[ndarray] = None, feature_names: Optional[list[str]] = None, feature_loadings_projected: Optional[ndarray] = None, feature_names_projected: Optional[list[str]] = None, assay_used: str = '', global_: bool = False, stdev: Optional[ndarray] = None, jackstraw: Optional[JackStrawData] = None, misc: Optional[dict] = None, key: str = 'PC_')

Bases: KeyMixin

Stores a dimensionality reduction (PCA, UMAP, tSNE, …).

Mirrors R's DimReduc class from dimreduc.R.

Slots
  • cell_embeddings (np.ndarray) — (n_cells × n_dims), required
  • feature_loadings (np.ndarray) — (n_features × n_dims), optional
  • feature_loadings_projected (np.ndarray) — projected loadings, optional
  • assay_used (str) — source assay name
  • global_ (bool) — if True, persists when assay is removed
  • stdev (np.ndarray) — per-dimension std devs
  • jackstraw (JackStrawData)
  • misc (dict)
  • _key (str) — prefix, e.g. "PC_"
Source code in truecell/dimreduc.py
def __init__(
    self,
    cell_embeddings: np.ndarray,
    cell_names: list[str],
    feature_loadings: Optional[np.ndarray] = None,
    feature_names: Optional[list[str]] = None,
    feature_loadings_projected: Optional[np.ndarray] = None,
    feature_names_projected: Optional[list[str]] = None,
    assay_used: str = "",
    global_: bool = False,
    stdev: Optional[np.ndarray] = None,
    jackstraw: Optional[JackStrawData] = None,
    misc: Optional[dict] = None,
    key: str = "PC_",
) -> None:
    self._key = key
    self.cell_embeddings = np.asarray(cell_embeddings)
    self._cell_names = list(cell_names)

    if len(self._cell_names) != self.cell_embeddings.shape[0]:
        raise ValueError(
            f"cell_names length {len(self._cell_names)} != "
            f"cell_embeddings rows {self.cell_embeddings.shape[0]}."
        )

    n_dims = self.cell_embeddings.shape[1]

    if feature_loadings is not None:
        self.feature_loadings = np.asarray(feature_loadings)
    else:
        self.feature_loadings = empty_dense(0, n_dims)

    self._feature_names = list(feature_names) if feature_names else []

    if feature_loadings_projected is not None:
        self.feature_loadings_projected = np.asarray(feature_loadings_projected)
    else:
        self.feature_loadings_projected = empty_dense(0, n_dims)

    # Projected loadings are a *separate* feature axis, not the same one.
    # `ProjectDim` scores every gene in the assay, where the unprojected
    # loadings cover only the features the reduction was computed on, so the
    # two lists legitimately differ in both length and order.
    self._feature_names_projected = (
        list(feature_names_projected) if feature_names_projected else []
    )

    self.assay_used = assay_used
    self.global_ = global_
    self.stdev = np.asarray(stdev) if stdev is not None else np.array([])
    self.jackstraw = jackstraw if jackstraw is not None else JackStrawData()
    self.misc = misc or {}

set_loadings

set_loadings(value: ndarray, projected: bool = False, feature_names: Optional[list[str]] = None) -> None

Set one loadings matrix, and optionally the features it is indexed by.

feature_names matters most on the projected side, which starts empty: without it, setting projected loadings would leave features(projected=True) with nothing to report them against.

Source code in truecell/dimreduc.py
def set_loadings(self, value: np.ndarray, projected: bool = False,
                 feature_names: Optional[list[str]] = None) -> None:
    """Set one loadings matrix, and optionally the features it is indexed by.

    ``feature_names`` matters most on the projected side, which starts empty:
    without it, setting projected loadings would leave `features(projected=True)`
    with nothing to report them against.
    """
    if projected:
        self.feature_loadings_projected = np.asarray(value)
        if feature_names is not None:
            self._feature_names_projected = list(feature_names)
    else:
        self.feature_loadings = np.asarray(value)
        if feature_names is not None:
            self._feature_names = list(feature_names)

features

features(projected: bool = False) -> list[str]

Feature names for the requested loadings matrix.

Mirrors Features.DimReduc, which returns rownames(Loadings(object, projected = projected)) and NULL when that matrix is empty. So this reports the features of the matrix you asked for, and [] when there is none — previously it returned the unprojected names either way, which on a reduction with no projected loadings claimed N features for a 0-row matrix.

Source code in truecell/dimreduc.py
def features(self, projected: bool = False) -> list[str]:
    """Feature names for the requested loadings matrix.

    Mirrors ``Features.DimReduc``, which returns
    ``rownames(Loadings(object, projected = projected))`` and ``NULL`` when
    that matrix is empty. So this reports the features of the matrix you
    asked for, and ``[]`` when there is none — previously it returned the
    unprojected names either way, which on a reduction with no projected
    loadings claimed N features for a 0-row matrix.
    """
    if projected:
        if self.feature_loadings_projected.shape[0] == 0:
            return []
        return list(self._feature_names_projected)
    if self.feature_loadings.shape[0] == 0:
        return []
    return list(self._feature_names)

subset

subset(cells: Optional[list[str]] = None, dims: Optional[list[int]] = None) -> 'DimReduc'

Restrict the embedding to cells (in the order given) and dims.

Rows follow cells exactly, which integrate_layers relies on to put a corrected embedding back into object order. Seurat's subset.DimReduc keeps the reduction's own order instead; Truecell.subset passes cells in object order already, so the two agree through the object.

Source code in truecell/dimreduc.py
def subset(
    self,
    cells: Optional[list[str]] = None,
    dims: Optional[list[int]] = None,
) -> "DimReduc":
    """Restrict the embedding to ``cells`` (in the order given) and ``dims``.

    Rows follow ``cells`` exactly, which ``integrate_layers`` relies on to put
    a corrected embedding back into object order. Seurat's ``subset.DimReduc``
    keeps the reduction's own order instead; :meth:`Truecell.subset` passes
    cells in object order already, so the two agree through the object.
    """
    if cells is not None:
        position = {c: i for i, c in enumerate(self._cell_names)}
        missing = [c for c in cells if c not in position]
        if missing:
            raise ValueError(
                f"{len(missing)} cell(s) not in this reduction: {missing[:5]}"
            )
        # One lookup per cell; `list.index` per cell was quadratic.
        cell_idx = [position[c] for c in cells]
    else:
        cell_idx = list(range(len(self._cell_names)))
    dim_idx = dims if dims is not None else list(range(self.cell_embeddings.shape[1]))

    new_emb = self.cell_embeddings[np.ix_(cell_idx, dim_idx)]
    new_load = (
        self.feature_loadings[:, dim_idx]
        if self.feature_loadings.shape[0] > 0
        else self.feature_loadings
    )
    new_stdev = self.stdev[dim_idx] if self.stdev.size > 0 else self.stdev

    return DimReduc(
        cell_embeddings=new_emb,
        cell_names=[self._cell_names[i] for i in cell_idx],
        feature_loadings=new_load,
        feature_names=list(self._feature_names),
        feature_loadings_projected=self.feature_loadings_projected.copy(),
        feature_names_projected=list(self._feature_names_projected),
        assay_used=self.assay_used,
        global_=self.global_,
        stdev=new_stdev,
        misc=dict(self.misc),
        key=self._key,
    )

Graph

Graph(matrix: spmatrix, cell_names: list[str], assay_used: Optional[str] = None)

Sparse graph object for cell-cell relationships (e.g. SNN graph).

Mirrors R's Graph class from graph.R, which extends dgCMatrix. In Python we wrap (not inherit) a scipy CSC matrix to avoid scipy subclassing pitfalls.

Slots
  • _matrix (scipy.sparse.csc_matrix) — underlying adjacency matrix
  • assay_used (Optional[str]) — assay that generated this graph
  • _cell_names (list[str]) — row/col names (cells)
Source code in truecell/graph.py
def __init__(
    self,
    matrix: sp.spmatrix,
    cell_names: list[str],
    assay_used: Optional[str] = None,
) -> None:
    if not sp.issparse(matrix):
        matrix = sp.csc_matrix(matrix)
    else:
        matrix = matrix.tocsc()

    n = len(cell_names)
    if matrix.shape != (n, n):
        raise ValueError(
            f"matrix shape {matrix.shape} does not match "
            f"cell_names length {n} × {n}."
        )
    if len(cell_names) != len(set(cell_names)):
        raise ValueError("cell_names must be unique.")

    self._matrix = matrix
    self._cell_names = list(cell_names)
    self.assay_used = assay_used
    self._validate()

subset

subset(cells: list[str]) -> 'Graph'

Return a new Graph restricted to cells (cell×cell submatrix).

Rows and columns follow cells in the order given, skipping names the graph does not have. Truecell.subset passes them in object order.

Source code in truecell/graph.py
def subset(self, cells: list[str]) -> "Graph":
    """Return a new Graph restricted to ``cells`` (cell×cell submatrix).

    Rows and columns follow ``cells`` in the order given, skipping names the
    graph does not have. :meth:`Truecell.subset` passes them in object order.
    """
    idx_map = {c: i for i, c in enumerate(self._cell_names)}
    keep = [c for c in cells if c in idx_map]
    idx = [idx_map[c] for c in keep]
    sub = self._matrix[np.ix_(idx, idx)]
    return Graph(matrix=sub, cell_names=keep, assay_used=self.assay_used)

as_graph

as_graph(x: Union[ndarray, spmatrix, 'Neighbor'], cell_names: Optional[list[str]] = None, assay_used: Optional[str] = None, weighted: bool = True) -> Graph

Convert a matrix or Neighbor to a Graph. Mirrors R as.Graph().

Source code in truecell/graph.py
def as_graph(
    x: Union[np.ndarray, sp.spmatrix, "Neighbor"],
    cell_names: Optional[list[str]] = None,
    assay_used: Optional[str] = None,
    weighted: bool = True,
) -> Graph:
    """Convert a matrix or Neighbor to a Graph.  Mirrors R as.Graph()."""
    from .neighbor import Neighbor

    if isinstance(x, Graph):
        return x

    if isinstance(x, Neighbor):
        g = x.as_graph(weighted=weighted)
        if assay_used is not None:
            g.assay_used = assay_used
        return g

    if sp.issparse(x) or isinstance(x, np.ndarray):
        if cell_names is None:
            n = x.shape[0]
            cell_names = [str(i) for i in range(n)]
        return Graph(matrix=x, cell_names=cell_names, assay_used=assay_used)

    raise TypeError(f"Cannot convert {type(x).__name__} to Graph.")

Neighbor

Neighbor(nn_idx: ndarray, nn_dist: ndarray, cell_names: list[str], alg_idx: Any = None, alg_info: Optional[dict] = None)

Nearest-neighbor results for a set of cells.

Mirrors R's Neighbor class from neighbor.R.

Slots
  • nn_idx (int matrix) — (n_cells × k), neighbor indices (1-based in R; 0-based here)
  • nn_dist (float matrix) — (n_cells × k), corresponding distances
  • alg_idx (Any) — algorithm index object (e.g. annoy index)
  • alg_info (dict) — metadata about the algorithm used
  • cell_names (list[str]) — cell barcodes, length n_cells
Source code in truecell/neighbor.py
def __init__(
    self,
    nn_idx: np.ndarray,
    nn_dist: np.ndarray,
    cell_names: list[str],
    alg_idx: Any = None,
    alg_info: Optional[dict] = None,
) -> None:
    self.nn_idx = np.asarray(nn_idx, dtype=int)
    self.nn_dist = np.asarray(nn_dist, dtype=float)
    self.cell_names = list(cell_names)
    self.alg_idx = alg_idx
    self.alg_info = alg_info or {}
    self._validate()

Supporting structures

JackStrawData

JackStrawData(empirical_p_values: Optional[ndarray] = None, fake_reduction_scores: Optional[ndarray] = None, overall_p_values: Optional[ndarray] = None, score: Optional[ndarray] = None, method: Optional[str] = None)

Stores JackStraw permutation test results for a DimReduc.

Mirrors R's JackStraw / JackStrawData from jackstraw.R.

Source code in truecell/jackstraw.py
def __init__(
    self,
    empirical_p_values: Optional[np.ndarray] = None,
    fake_reduction_scores: Optional[np.ndarray] = None,
    overall_p_values: Optional[np.ndarray] = None,
    score: Optional[np.ndarray] = None,
    method: Optional[str] = None,
) -> None:
    self.empirical_p_values = empirical_p_values
    self.fake_reduction_scores = fake_reduction_scores
    self.overall_p_values = overall_p_values
    self.score = score
    self.method = method

LogMap

LogMap(data: dict[str, ndarray] | None = None)

Logical mapping: named boolean arrays indicating membership.

Mirrors R's LogMap class from logmap.R. Keys are cell or feature names; values are boolean numpy arrays.

Source code in truecell/logmap.py
def __init__(self, data: dict[str, np.ndarray] | None = None) -> None:
    self._map: dict[str, np.ndarray] = {}
    if data:
        for k, v in data.items():
            self[k] = v

KeyMixin

Mixin providing a validated 'key' slot, mirroring R's KeyMixin from keymixin.R.

TruecellCommand

TruecellCommand(name: str, time_stamp: Optional[datetime] = None, assay_used: Optional[str] = None, call_string: str = '', params: Optional[dict] = None, key: Optional[str] = None)

Logs commands executed on a Truecell object.

Mirrors R's TruecellCommand class from command.R.

Slots
  • name (str) — function/method name
  • time_stamp (datetime) — when the command ran
  • assay_used (Optional[str])
  • call_string (str) — human-readable call representation
  • params (dict) — non-function parameters passed to the command
Source code in truecell/command.py
def __init__(
    self,
    name: str,
    time_stamp: Optional[datetime] = None,
    assay_used: Optional[str] = None,
    call_string: str = "",
    params: Optional[dict] = None,
    key: Optional[str] = None,
) -> None:
    self.name = name
    self.time_stamp = time_stamp or datetime.now()
    self.assay_used = assay_used
    self.call_string = call_string
    self.params = params or {}
    #: How R indexes this entry in ``obj@commands`` — the command name, the
    #: assay, and for reduction-consuming steps the reduction, joined with
    #: dots: ``FindNeighbors.RNA.pca``. Scripts look entries up by this, so
    #: it is data rather than decoration and matches Seurat's spelling.
    self.key = key or name

log_truecell_command

log_truecell_command(object_, func_name: str, params: Optional[dict] = None, assay: Optional[str] = None, reduction: Optional[str] = None) -> 'TruecellCommand'

Capture a command log entry, typically called at the end of a function.

Mirrors R's LogSeuratCommand, including how it names the entry: Seurat's function name, the assay, and the reduction where one was consumed. The names are R's (RunPCA, not run_pca) because the log is a lookup table users query — the same reasoning that keeps layer names scale.data and reduction keys PC_.

Source code in truecell/command.py
def log_truecell_command(
    object_,
    func_name: str,
    params: Optional[dict] = None,
    assay: Optional[str] = None,
    reduction: Optional[str] = None,
) -> "TruecellCommand":
    """Capture a command log entry, typically called at the end of a function.

    Mirrors R's LogSeuratCommand, including how it names the entry: Seurat's
    function name, the assay, and the reduction where one was consumed. The
    names are R's (``RunPCA``, not ``run_pca``) because the log is a lookup
    table users query — the same reasoning that keeps layer names ``scale.data``
    and reduction keys ``PC_``.
    """
    params = params or {}
    # Remove any callable values (functions/lambdas) from params log
    safe_params = {k: v for k, v in params.items() if not callable(v)}

    call_parts = [f"{func_name}("]
    parts = [f"{k}={v!r}" for k, v in safe_params.items()]
    call_parts.append(", ".join(parts))
    call_parts.append(")")
    call_string = "".join(call_parts)

    key = ".".join(p for p in (func_name, assay, reduction) if p)
    cmd = TruecellCommand(
        name=func_name,
        assay_used=assay,
        call_string=call_string,
        params=safe_params,
        key=key,
    )
    # Append to object's command log if it has one
    if hasattr(object_, "commands") and isinstance(object_.commands, list):
        object_.commands.append(cmd)
    return cmd