use clap::Args;
use data_beans::hdf5_io::resolve_backend_file;
use data_beans::qc_lib::{
compute_qc, robust_band, write_qc_report, QcArgs, QcConfig, QcReport, RobustBand,
};
use data_beans::sparse_io::{open_sparse_matrix, SparseIo, SparseIoBackend};
use data_beans::sparse_io_vector::SparseIoVec;
use data_beans::zarr_io::{finalize_output, output_target, prepare_output};
use legume_numeric::matrix::common_io::write_lines;
use log::info;
use std::sync::Arc;
#[derive(Args, Debug)]
pub struct CellQcArgs {
pub data_file: Box<str>,
#[command(flatten)]
pub qc: QcArgs,
#[arg(short, long)]
pub output: Option<Box<str>>,
#[arg(long, value_enum, default_value = "zarr")]
pub backend: SparseIoBackend,
#[arg(long = "no-zip", default_value_t = true, action = clap::ArgAction::SetFalse)]
pub zip: bool,
#[arg(long)]
pub block_size: Option<usize>,
}
pub const MITO: &str = "(?i)^MT-";
pub struct CellQc {
pub matrix: Arc<dyn SparseIo<IndexIter = Vec<usize>>>,
pub cfg: QcConfig,
pub report: QcReport,
}
impl CellQc {
pub fn compute(
data_file: &str,
qc: &QcArgs,
block_size: Option<usize>,
) -> anyhow::Result<Self> {
let mut qc = qc.clone();
if qc.qc_mito_pattern.as_deref() == Some("") {
qc.qc_mito_pattern = None;
}
let cfg = qc
.to_config()
.ok_or_else(|| anyhow::anyhow!("--no-qc leaves nothing to do"))?;
let (backend, file) = resolve_backend_file(data_file, None)?;
let matrix: Arc<dyn SparseIo<IndexIter = Vec<usize>>> =
Arc::from(open_sparse_matrix(&file, &backend)?);
let mut data = SparseIoVec::new();
data.push(matrix.clone(), None)?;
let report = compute_qc(&data, &cfg, block_size)?;
Ok(Self {
matrix,
cfg,
report,
})
}
pub fn metrics(&self) -> Vec<Metric<'_>> {
let (r, cfg) = (&self.report, &self.cfg);
let mut v = vec![
Metric::counts("n_genes", "detected genes", &r.n_genes, cfg.mad_on_n_genes),
Metric::counts(
"total_counts",
"total counts",
&r.total_counts,
cfg.mad_on_counts,
),
];
if let Some(f) = &r.mito_frac {
v.push(Metric::fraction(
("mito_frac", "mitochondrial fraction"),
f,
cfg.mad_on_mito,
cfg.mito_max_frac,
));
}
if let Some(f) = &r.ribo_frac {
v.push(Metric::fraction(
("ribo_frac", "ribosomal fraction"),
f,
false,
cfg.ribo_max_frac,
));
}
v
}
pub fn band(&self, m: &Metric) -> Option<RobustBand> {
let consider: Vec<bool> = self.report.near_empty.iter().map(|&e| !e).collect();
robust_band(m.values, self.cfg.n_mads, m.count, Some(&consider)).map(|b| b.raw(m.count))
}
pub fn edge(&self, m: &Metric) -> Option<f32> {
let band = self.band(m).filter(|_| m.banded)?;
Some(if m.count { band.lo } else { band.hi })
}
}
pub struct Metric<'a> {
pub name: &'static str,
#[cfg_attr(not(feature = "plot"), allow(dead_code))]
pub label: &'static str,
pub values: &'a [f32],
pub count: bool,
pub banded: bool,
pub cap: Option<f32>,
}
impl<'a> Metric<'a> {
fn counts(name: &'static str, label: &'static str, values: &'a [f32], banded: bool) -> Self {
Self {
name,
label,
values,
count: true,
banded,
cap: None,
}
}
fn fraction(
(name, label): (&'static str, &'static str),
values: &'a [f32],
banded: bool,
cap: Option<f32>,
) -> Self {
Self {
name,
label,
values,
count: false,
banded,
cap,
}
}
pub fn bound(&self) -> (&'static str, &'static str) {
if self.count {
("min", "below")
} else {
("max", "above")
}
}
pub fn within(&self, v: f32, edge: f32) -> bool {
if self.count {
v >= edge
} else {
v <= edge
}
}
fn shown(&self, v: f32) -> String {
if self.count {
format!("{v:.1}")
} else {
format!("{v:.4}")
}
}
}
pub fn run_cell_qc(args: &CellQcArgs) -> anyhow::Result<()> {
let qc = CellQc::compute(&args.data_file, &args.qc, args.block_size)?;
let (matrix, report) = (&qc.matrix, &qc.report);
let cells = match args.qc.qc_report.is_some() || args.output.is_some() {
true => matrix.column_names()?,
false => vec![],
};
if let Some(path) = &args.qc.qc_report {
write_qc_report(path, &cells, report)?;
info!("wrote the per-cell report: {path}");
}
let keep_cells = report.emit_idx_unmasked();
let keep_rows: Vec<usize> = (0..report.feature_keep.len())
.filter(|&r| report.feature_keep[r])
.collect();
let summary = summary(&qc, keep_cells.len(), keep_rows.len());
write_lines(&summary, "stdout")?;
let Some(output) = &args.output else {
return Ok(());
};
anyhow::ensure!(!keep_cells.is_empty(), "QC keeps no cells; nothing written");
let target = output_target(output, args.backend.clone(), args.zip)?;
for p in [&target.effective, &target.working, &target.found] {
anyhow::ensure!(
!crate::handlers::merging::same_file(&args.data_file, p),
"the output {p} is the input {}: give -o another name",
args.data_file
);
}
let rows = matrix.row_names()?;
let out_rows: Vec<Box<str>> = keep_rows.iter().map(|&r| rows[r].clone()).collect();
let out_cells: Vec<Box<str>> = keep_cells.iter().map(|&c| cells[c].clone()).collect();
let (effective, backend_out, file_out) =
prepare_output(output, args.backend.clone(), args.zip)?;
let row_filter = (keep_rows.len() < rows.len()).then_some(keep_rows.as_slice());
let (_, _, nnz) = data_beans::column_subset::stream_column_selection(
&**matrix,
&keep_cells,
row_filter,
&out_rows,
&out_cells,
&file_out,
&backend_out,
)?;
let written = finalize_output(&file_out, &effective)?;
info!(
"done: {written} ({} features x {} cells, {nnz} non-zeros)",
out_rows.len(),
out_cells.len()
);
Ok(())
}
fn summary(qc: &CellQc, kept_cells: usize, kept_features: usize) -> Vec<Box<str>> {
let report = &qc.report;
let mut lines: Vec<Box<str>> = vec![];
let mut say = |key: &str, value: String| lines.push(format!("{key}:\t{value}").into());
let near_empty = report.near_empty.iter().filter(|&&e| e).count();
say("cells", report.train_keep.len().to_string());
say("cells_kept", kept_cells.to_string());
say("near_empty", near_empty.to_string());
let outliers = (report.near_empty.iter().zip(&report.train_keep))
.filter(|&(&e, &k)| !e && !k)
.count();
say("outliers", outliers.to_string());
say("features", report.feature_keep.len().to_string());
say("features_kept", kept_features.to_string());
let past = |m: &Metric, edge: f32| {
m.values
.iter()
.zip(&report.near_empty)
.filter(|&(&v, &e)| !e && !m.within(v, edge))
.count()
};
for m in qc.metrics() {
let name = m.name;
if let Some(band) = qc.band(&m) {
say(&format!("{name}.median"), m.shown(band.median));
}
if let Some(edge) = qc.edge(&m) {
let (bound, side) = m.bound();
say(&format!("{name}.{bound}"), m.shown(edge));
say(&format!("{name}.{side}"), past(&m, edge).to_string());
}
if let Some(cap) = m.cap {
say(&format!("{name}.cap"), cap.to_string());
say(&format!("{name}.above_cap"), past(&m, cap).to_string());
}
}
lines
}