Source code for mantispy.pp._well_qc

"""Well-level quality control."""

from __future__ import annotations

import numpy as np
import pandas as pd
from anndata import AnnData

from mantispy._core._reduce import get_matrix, group_codes
from mantispy._core._utils import feature_mask, get_logger, inplace_or_copy, reference_mask


[docs] @inplace_or_copy() def well_qc( adata: AnnData, min_cells: int = 50, max_nan_fraction: float = 0.1, max_control_cv: float | None = None, key: str | None = "selected", copy: bool = False, ) -> AnnData | None: """Flag wells with too few cells, too much missing data, or unstable controls. Args: adata: Single-cell object to summarize per well. min_cells: Wells with fewer cells than this fail. max_nan_fraction: Wells missing more than this fraction of their values fail. max_control_cv: For control wells only, the largest acceptable median coefficient of variation across features. ``None`` skips the check, which is the default because a sensible value depends on the assay. key: Restrict the statistics to features flagged by this boolean ``var`` column. copy: Return a modified copy instead of mutating in place. Returns: ``None``, or the modified copy. Writes ``uns["mantispy"]["well_qc"]``, a frame with the columns ``Metadata_Plate``, ``Metadata_Well``, ``n_cells``, ``nan_fraction``, ``control_cv`` and ``qc_well_pass``, and broadcasts ``obs["qc_well_pass"]``. Notes: The table keeps plate and well as columns because a MultiIndex in ``uns`` cannot be written to h5ad. """ selected = feature_mask(adata, key) X = get_matrix(adata)[:, selected] codes, keys = group_codes(adata, ["Metadata_Plate", "Metadata_Well"]) is_control = ( reference_mask(adata, "negcon") if "Metadata_Control" in adata.obs else np.zeros(adata.n_obs, dtype=bool) ) records = [] for group in range(len(keys)): rows = codes == group block = X[rows] control_cv = np.nan if is_control[rows].any() and block.shape[0] > 1: with np.errstate(invalid="ignore", divide="ignore"): mean = np.nanmean(block, axis=0) deviation = np.nanstd(block, axis=0, ddof=1) control_cv = float(np.nanmedian(np.abs(deviation / np.where(mean == 0, np.nan, mean)))) records.append( { "n_cells": int(rows.sum()), "nan_fraction": float(np.isnan(block).mean()) if block.size else 1.0, "control_cv": control_cv, } ) table = pd.DataFrame(records) table.insert(0, "Metadata_Well", keys.get_level_values(1)) table.insert(0, "Metadata_Plate", keys.get_level_values(0)) passed = (table["n_cells"] >= min_cells) & (table["nan_fraction"] <= max_nan_fraction) if max_control_cv is not None: passed &= table["control_cv"].isna() | (table["control_cv"] <= max_control_cv) table["qc_well_pass"] = passed adata.uns.setdefault("mantispy", {})["well_qc"] = table adata.obs["qc_well_pass"] = passed.to_numpy()[codes] get_logger().info("well_qc failed %d of %d wells", int((~passed).sum()), len(table)) return None