1use crate::qc::{collect_column_stat_across_vec, collect_row_stat_across_vec};
8use crate::sparse_io_stack::SparseIoStack;
9use crate::sparse_io_vector::SparseIoVec;
10use legume_numeric::matrix::common_io::write_lines;
11use legume_numeric::matrix::traits::RunningStatOps;
12use log::warn;
13use regex::Regex;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Tail {
34 Lower,
36 Upper,
38 Both,
40}
41
42#[derive(Clone, Debug)]
45pub struct QcConfig {
46 pub n_mads: f32,
48 pub min_cell_nnz: usize,
52 pub min_counts_per_cell: f32,
54 pub mito_pattern: Option<String>,
57 pub mito_max_frac: Option<f32>,
59 pub ribo_pattern: Option<String>,
61 pub ribo_max_frac: Option<f32>,
63 pub mad_on_n_genes: bool,
65 pub mad_on_counts: bool,
66 pub mad_on_mito: bool,
67 pub feature_min_cells: usize,
70 pub drop_outliers: bool,
74 pub auto_cell_cutoff: bool,
84 pub qc_histogram: bool,
87}
88
89impl Default for QcConfig {
90 fn default() -> Self {
91 Self {
92 n_mads: 5.0,
93 min_cell_nnz: 2,
94 min_counts_per_cell: 0.0,
95 mito_pattern: None,
96 mito_max_frac: None,
97 ribo_pattern: None,
98 ribo_max_frac: None,
99 mad_on_n_genes: true,
100 mad_on_counts: true,
101 mad_on_mito: false,
102 feature_min_cells: 0,
103 drop_outliers: true,
104 auto_cell_cutoff: false,
105 qc_histogram: false,
106 }
107 }
108}
109
110fn median_in_place(xs: &mut [f32]) -> f32 {
112 let mid = xs.len() / 2;
113 xs.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
114 xs[mid]
115}
116
117#[derive(Clone, Copy, Debug)]
120pub struct RobustBand {
121 pub median: f32,
122 pub lo: f32,
123 pub hi: f32,
124}
125
126impl RobustBand {
127 pub fn raw(self, log1p: bool) -> Self {
129 let back = |v: f32| if log1p { v.exp_m1() } else { v };
130 Self {
131 median: back(self.median),
132 lo: back(self.lo),
133 hi: back(self.hi),
134 }
135 }
136}
137
138fn band_scale(v: f32, log1p: bool) -> f32 {
140 if log1p {
141 v.max(0.0).ln_1p()
142 } else {
143 v
144 }
145}
146
147pub fn robust_band(
150 values: &[f32],
151 n_mads: f32,
152 log1p: bool,
153 consider: Option<&[bool]>,
154) -> Option<RobustBand> {
155 let xform = |v: f32| band_scale(v, log1p);
156
157 let mut xs: Vec<f32> = values
159 .iter()
160 .enumerate()
161 .filter(|(i, _)| consider.is_none_or(|c| c[*i]))
162 .map(|(_, &v)| xform(v))
163 .filter(|v| v.is_finite())
164 .collect();
165 if xs.is_empty() {
166 return None;
167 }
168 let median = median_in_place(&mut xs);
170
171 let mut dev: Vec<f32> = xs.iter().map(|&v| (v - median).abs()).collect();
172 let mad = median_in_place(&mut dev) * 1.4826;
173 if mad <= 0.0 {
177 return None;
178 }
179
180 Some(RobustBand {
181 median,
182 lo: median - n_mads * mad,
183 hi: median + n_mads * mad,
184 })
185}
186
187pub fn robust_outlier_keep(
196 values: &[f32],
197 n_mads: f32,
198 tail: Tail,
199 log1p: bool,
200 consider: Option<&[bool]>,
201) -> Vec<bool> {
202 let n = values.len();
203 let Some(band) = robust_band(values, n_mads, log1p, consider) else {
204 return vec![true; n];
205 };
206 let xform = |v: f32| band_scale(v, log1p);
207 let (lo, hi) = (band.lo, band.hi);
208 values
209 .iter()
210 .map(|&raw| {
211 let v = xform(raw);
212 if !v.is_finite() {
213 return false;
214 }
215 match tail {
216 Tail::Lower => v >= lo,
217 Tail::Upper => v <= hi,
218 Tail::Both => v >= lo && v <= hi,
219 }
220 })
221 .collect()
222}
223
224pub fn resolve_rows_by_regex(row_names: &[Box<str>], pattern: &str) -> anyhow::Result<Vec<usize>> {
228 let re = Regex::new(pattern)?;
229 Ok(row_names
230 .iter()
231 .enumerate()
232 .filter(|(_, name)| re.is_match(name))
233 .map(|(i, _)| i)
234 .collect())
235}
236
237pub struct QcReport {
239 pub train_keep: Vec<bool>,
242 pub near_empty: Vec<bool>,
245 pub feature_keep: Vec<bool>,
248 pub n_genes: Vec<f32>,
249 pub total_counts: Vec<f32>,
250 pub mito_frac: Option<Vec<f32>>,
251 pub ribo_frac: Option<Vec<f32>>,
252 pub n_cells_dropped: usize,
253 pub n_features_dropped: usize,
254}
255
256impl QcReport {
257 pub fn output_keep_idx(&self) -> Vec<usize> {
262 let mut idx = Vec::new();
263 let mut new_pos = 0usize;
264 for c in 0..self.train_keep.len() {
265 if !self.train_keep[c] {
266 continue; }
268 if !self.near_empty[c] {
269 idx.push(new_pos); }
271 new_pos += 1;
272 }
273 idx
274 }
275
276 pub fn emit_idx_unmasked(&self) -> Vec<usize> {
281 (0..self.train_keep.len())
282 .filter(|&c| self.train_keep[c] && !self.near_empty[c])
283 .collect()
284 }
285}
286
287pub fn compute_qc(
290 data: &SparseIoVec,
291 cfg: &QcConfig,
292 block_size: Option<usize>,
293) -> anyhow::Result<QcReport> {
294 compute_qc_exempting(data, cfg, block_size, None)
295}
296
297pub fn compute_qc_exempting(
301 data: &SparseIoVec,
302 cfg: &QcConfig,
303 block_size: Option<usize>,
304 exempt: Option<&[bool]>,
305) -> anyhow::Result<QcReport> {
306 let m = compute_qc_metrics(data, cfg, block_size, cfg.feature_min_cells > 0)?;
307 Ok(qc_from_metrics(m, cfg, exempt))
308}
309
310pub fn compute_qc_metrics(
316 data: &SparseIoVec,
317 cfg: &QcConfig,
318 block_size: Option<usize>,
319 feature_cells: bool,
320) -> anyhow::Result<QcMetrics> {
321 let row_names = data.row_names()?;
322
323 let col_stat = collect_column_stat_across_vec(data, None, block_size)?;
326 let n_genes = col_stat.count_positives();
327 let total_counts = col_stat.sum();
328
329 let frac_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<f32>>> {
331 let Some(pat) = pattern else {
332 return Ok(None);
333 };
334 let rows = resolve_rows_by_regex(&row_names, pat)?;
335 if rows.is_empty() {
336 warn!(
337 "QC: pattern `{}` matched no features — metric disabled",
338 pat
339 );
340 return Ok(None);
341 }
342 let sub_tot = collect_column_stat_across_vec(data, Some(&rows), block_size)?.sum();
343 let frac = sub_tot
344 .iter()
345 .zip(total_counts.iter())
346 .map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
347 .collect::<Vec<f32>>();
348 Ok(Some(frac))
349 };
350 let mito_frac = frac_for(&cfg.mito_pattern)?;
351 let ribo_frac = frac_for(&cfg.ribo_pattern)?;
352
353 let feature_n_cells = if feature_cells {
355 Some(collect_row_stat_across_vec(data, block_size)?.count_positives())
356 } else {
357 None
358 };
359
360 Ok(QcMetrics {
361 n_genes,
362 total_counts,
363 mito_frac,
364 ribo_frac,
365 feature_n_cells,
366 n_rows: data.num_rows(),
367 })
368}
369
370pub fn compute_qc_stack(
375 stack: &SparseIoStack,
376 cfg: &QcConfig,
377 block_size: Option<usize>,
378) -> anyhow::Result<QcReport> {
379 let n_cols = stack.num_columns()?;
380 let mut n_genes = vec![0f32; n_cols];
381 let mut total_counts = vec![0f32; n_cols];
382 for member in stack.stack.iter() {
383 let cs = collect_column_stat_across_vec(member, None, block_size)?;
384 let ng = cs.count_positives();
385 let ct = cs.sum();
386 for c in 0..n_cols {
387 n_genes[c] += ng[c];
388 total_counts[c] += ct[c];
389 }
390 }
391 if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
392 warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
393 }
394 Ok(qc_from_metrics(
395 QcMetrics {
396 n_genes,
397 total_counts,
398 mito_frac: None,
399 ribo_frac: None,
400 feature_n_cells: None,
401 n_rows: 0, },
403 cfg,
404 None,
405 ))
406}
407
408#[derive(Clone, Debug)]
411pub struct QcMetrics {
412 pub n_genes: Vec<f32>,
413 pub total_counts: Vec<f32>,
414 pub mito_frac: Option<Vec<f32>>,
415 pub ribo_frac: Option<Vec<f32>>,
416 pub feature_n_cells: Option<Vec<f32>>,
418 pub n_rows: usize,
419}
420
421#[derive(Clone, Debug, PartialEq, Eq)]
424pub struct Verdicts {
425 pub train_keep: Vec<bool>,
426 pub near_empty: Vec<bool>,
427 pub feature_keep: Vec<bool>,
428 pub n_cells_dropped: usize,
429 pub n_features_dropped: usize,
430 pub guarded: bool,
433}
434
435impl Verdicts {
436 pub fn emitted(&self, c: usize) -> bool {
439 self.train_keep[c] && !self.near_empty[c]
440 }
441}
442
443fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
454 if cfg.drop_outliers && (cfg.auto_cell_cutoff || cfg.qc_histogram) {
456 let suggested = crate::qc::suggest_nnz_cutoff(&m.n_genes);
457 let shown = suggested.unwrap_or(cfg.min_cell_nnz);
460 crate::qc::eprint_nnz_summary("Cell", "nnz", &m.n_genes, shown, suggested);
461 }
462 let v = cell_verdicts(&m, cfg, exempt);
463 let QcMetrics {
464 n_genes,
465 total_counts,
466 mito_frac,
467 ribo_frac,
468 ..
469 } = m;
470 QcReport {
471 train_keep: v.train_keep,
472 near_empty: v.near_empty,
473 feature_keep: v.feature_keep,
474 n_genes,
475 total_counts,
476 mito_frac,
477 ribo_frac,
478 n_cells_dropped: v.n_cells_dropped,
479 n_features_dropped: v.n_features_dropped,
480 }
481}
482
483pub fn qc_report(m: QcMetrics, cfg: &QcConfig) -> QcReport {
486 qc_from_metrics(m, cfg, None)
487}
488
489pub fn cell_verdicts(m: &QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> Verdicts {
494 verdicts(m, cfg, exempt, true)
495}
496
497pub(crate) fn cell_verdicts_quiet(m: &QcMetrics, cfg: &QcConfig) -> Verdicts {
500 verdicts(m, cfg, None, false)
501}
502
503fn verdicts(m: &QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>, warn: bool) -> Verdicts {
504 let QcMetrics {
505 n_genes,
506 total_counts,
507 mito_frac,
508 ribo_frac,
509 feature_n_cells,
510 n_rows,
511 } = m;
512 let n_rows = *n_rows;
513 let n_cols = n_genes.len();
514
515 let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
516
517 let near_empty: Vec<bool> = n_genes
519 .iter()
520 .enumerate()
521 .map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
522 .collect();
523
524 let not_near_empty: Vec<bool> = near_empty
528 .iter()
529 .enumerate()
530 .map(|(c, &e)| !e && !is_exempt(c))
531 .collect();
532 let consider = Some(not_near_empty.as_slice());
533 let mut outlier = vec![false; n_cols];
534 if cfg.drop_outliers {
535 let mut bands: Vec<Vec<bool>> = Vec::new();
536 if cfg.mad_on_n_genes {
537 bands.push(robust_outlier_keep(
538 n_genes,
539 cfg.n_mads,
540 Tail::Lower,
541 true,
542 consider,
543 ));
544 }
545 if cfg.mad_on_counts {
546 bands.push(robust_outlier_keep(
547 total_counts,
548 cfg.n_mads,
549 Tail::Lower,
550 true,
551 consider,
552 ));
553 }
554 if cfg.mad_on_mito {
555 if let Some(mf) = mito_frac.as_ref() {
556 bands.push(robust_outlier_keep(
557 mf,
558 cfg.n_mads,
559 Tail::Upper,
560 false,
561 consider,
562 ));
563 }
564 }
565 for c in 0..n_cols {
566 if near_empty[c] || is_exempt(c) {
567 continue; }
569 let mut fail = total_counts[c] < cfg.min_counts_per_cell;
570 if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
571 fail |= mf[c] > cap;
572 }
573 if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
574 fail |= rf[c] > cap;
575 }
576 for band in bands.iter() {
577 if !band[c] {
578 fail = true;
579 break;
580 }
581 }
582 outlier[c] = fail;
583 }
584
585 if cfg.auto_cell_cutoff {
592 if let Some(cut) = crate::qc::suggest_nnz_cutoff(n_genes) {
593 for (c, &g) in n_genes.iter().enumerate() {
594 if (g as usize) < cut {
595 outlier[c] = true;
596 }
597 }
598 }
599 }
600 }
601
602 let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
603 let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
604
605 let mut guarded = false;
607 if n_cols > 0 && n_cells_dropped >= n_cols {
608 if warn {
609 warn!(
610 "QC would drop all {} cells — keeping all (check thresholds)",
611 n_cols
612 );
613 }
614 train_keep = vec![true; n_cols];
615 n_cells_dropped = 0;
616 guarded = true;
617 }
618
619 let (feature_keep, n_features_dropped) = match feature_n_cells {
621 Some(n_cells_expr) => {
622 let keep: Vec<bool> = n_cells_expr
623 .iter()
624 .map(|&c| (c as usize) >= cfg.feature_min_cells)
625 .collect();
626 let dropped = keep.iter().filter(|&&k| !k).count();
627 if !keep.is_empty() && dropped >= keep.len() {
628 if warn {
629 warn!("QC would drop all features — keeping all");
630 }
631 guarded = true;
632 (vec![true; keep.len()], 0)
633 } else {
634 (keep, dropped)
635 }
636 }
637 None => (vec![true; n_rows], 0),
638 };
639
640 Verdicts {
641 train_keep,
642 near_empty,
643 feature_keep,
644 n_cells_dropped,
645 n_features_dropped,
646 guarded,
647 }
648}
649
650pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
653 items
654 .iter()
655 .zip(keep.iter())
656 .filter(|&(_, &k)| k)
657 .map(|(x, _)| x.clone())
658 .collect()
659}
660
661pub fn write_qc_report(
664 path: &str,
665 cell_names: &[Box<str>],
666 report: &QcReport,
667) -> anyhow::Result<()> {
668 use std::fmt::Write as _;
669 let n = report.train_keep.len();
670 anyhow::ensure!(
671 cell_names.len() == n,
672 "write_qc_report: {} names != {} cells",
673 cell_names.len(),
674 n
675 );
676
677 let mut header = String::from("#cell\tn_genes\ttotal_counts");
678 if report.mito_frac.is_some() {
679 header.push_str("\tmito_frac");
680 }
681 if report.ribo_frac.is_some() {
682 header.push_str("\tribo_frac");
683 }
684 header.push_str("\tnear_empty\ttrain_keep");
685
686 let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
687 lines.push(header.into_boxed_str());
688 for c in 0..n {
689 let mut line = String::new();
690 let _ = write!(
691 line,
692 "{}\t{}\t{}",
693 cell_names[c], report.n_genes[c], report.total_counts[c]
694 );
695 if let Some(mf) = report.mito_frac.as_ref() {
696 let _ = write!(line, "\t{}", mf[c]);
697 }
698 if let Some(rf) = report.ribo_frac.as_ref() {
699 let _ = write!(line, "\t{}", rf[c]);
700 }
701 let _ = write!(
702 line,
703 "\t{}\t{}",
704 report.near_empty[c] as u8, report.train_keep[c] as u8
705 );
706 lines.push(line.into_boxed_str());
707 }
708 write_lines(&lines, path)
709}
710
711#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
724#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
725pub struct QcArgs {
726 #[arg(
728 long = "no-qc",
729 default_value_t = false,
730 long_help = "Disable cell quality control entirely and keep every input cell.\n\
731 \n\
732 By DEFAULT (without this flag) cell QC is:\n \
733 - a near-empty nnz floor (--qc-min-cell-nnz), and\n \
734 - MAD-outlier drops on detected features and total counts\n \
735 (--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
736 \n\
737 Empty barcodes are dropped earlier, per input file, by the loader.\n\
738 A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
739 \n\
740 Outputs may therefore have FEWER ROWS than the input.\n\
741 Join by the cell/barcode name column, never by position.\n\
742 \n\
743 Use --qc-report to see exactly what was dropped.\n\
744 For the older near-empty-floor-only gate,\n\
745 pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
746 )]
747 pub no_qc: bool,
748
749 #[arg(long = "qc-mads", default_value_t = 5.0)]
751 pub qc_mads: f32,
752
753 #[arg(
754 long = "qc-min-cell-nnz",
755 default_value_t = 2,
756 help = "Near-empty floor on a cell's detected-feature count",
757 long_help = "Near-empty floor on the detected-feature count.\n\
758 Cells below it are dropped from the per-cell outputs.\n\
759 They are still kept in training."
760 )]
761 pub qc_min_cell_nnz: usize,
762
763 #[arg(
764 long = "qc-min-counts",
765 hide = true,
766 default_value_t = 0.0,
767 help = "Hard floor on total counts per cell",
768 long_help = "Hard floor on total counts per cell.\n\
769 Cells below it are dropped from training. 0 disables the floor."
770 )]
771 pub qc_min_counts: f32,
772
773 #[arg(
774 long = "qc-mito-pattern",
775 hide = true,
776 help = "Regex over feature names selecting mitochondrial genes",
777 long_help = "Regex over feature names selecting mitochondrial genes.\n\
778 It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
779 )]
780 pub qc_mito_pattern: Option<String>,
781
782 #[arg(long = "qc-mito-max-frac", hide = true)]
784 pub qc_mito_max_frac: Option<f32>,
785
786 #[arg(long = "qc-ribo-pattern", hide = true)]
788 pub qc_ribo_pattern: Option<String>,
789
790 #[arg(long = "qc-ribo-max-frac", hide = true)]
792 pub qc_ribo_max_frac: Option<f32>,
793
794 #[arg(
795 long = "qc-feature-min-cells",
796 hide = true,
797 default_value_t = 0,
798 help = "Feature/row QC: drop genes expressed in too few cells",
799 long_help = "Feature/row QC; off by default.\n\
800 It DROPS gene rows expressed in fewer than this many cells.\n\
801 \n\
802 Not every consumer applies it.\n\
803 `bge` does not, since QC there is cell-only.\n\
804 `pinto` does not either: it reads only the cell verdict,\n\
805 so setting this costs a stats pass and changes nothing."
806 )]
807 pub qc_feature_min_cells: usize,
808
809 #[arg(
810 long = "qc-report",
811 help = "Write a per-cell QC table (.tsv)",
812 long_help = "Write a per-cell QC table, as .tsv.\n\
813 It carries the metrics plus near_empty and train_keep flags.\n\
814 You can then see exactly which cells were dropped."
815 )]
816 pub qc_report: Option<Box<str>>,
817
818 #[arg(
819 long = "qc-histogram",
820 hide = true,
821 default_value_t = false,
822 help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
823 long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
824 The suggested trough cutoff is marked.\n\
825 It is the same summary as `data-beans squeeze --show-histogram`.\n\
826 \n\
827 This is purely diagnostic. The cutoff is shown, not applied.\n\
828 The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
829 Use the histogram to pick --qc-min-cell-nnz by hand."
830 )]
831 pub qc_histogram: bool,
832
833 #[arg(
834 long = "qc-mad-on-genes",
835 hide = true,
836 action = clap::ArgAction::Set,
838 num_args = 0..=1,
839 require_equals = true,
840 default_missing_value = "true",
841 default_value_t = true,
842 help = "MAD-outlier drop on the per-cell detected-feature count",
843 long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
844 \n\
845 ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
846 with no way to enable them. That also made --qc-mads inert.\n\
847 Only a set --qc-mito-pattern revived it.\n\
848 \n\
849 Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
850 That is the conservative near-empty nnz gate alone."
851 )]
852 pub qc_mad_on_genes: bool,
853
854 #[arg(
855 long = "qc-mad-on-counts",
856 hide = true,
857 action = clap::ArgAction::Set,
859 num_args = 0..=1,
860 require_equals = true,
861 default_missing_value = "true",
862 default_value_t = true,
863 help = "MAD-outlier drop on per-cell total counts",
864 long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
865 ON by default; see --qc-mad-on-genes."
866 )]
867 pub qc_mad_on_counts: bool,
868
869 #[arg(
870 long = "qc-mad-on-mito",
871 hide = true,
872 action = clap::ArgAction::Set,
874 num_args = 0..=1,
875 require_equals = true,
876 default_missing_value = "true",
877 default_value_t = true,
878 help = "MAD-outlier drop on the mito fraction, given --qc-mito-pattern",
879 long_help = "Drop cells whose mito fraction is above `median + --qc-mads * MAD * 1.4826`.\n\
880 On whenever --qc-mito-pattern is given. `--qc-mad-on-mito=false` leaves\n\
881 only the hard cap, --qc-mito-max-frac, when one is given."
882 )]
883 pub qc_mad_on_mito: bool,
884
885 #[arg(
886 long = "qc-auto-cutoff",
887 hide = true,
888 default_value_t = false,
889 help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
890 long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
891 It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
892 Without this flag it is only reported, via --qc-histogram.\n\
893 \n\
894 OFF by default. The intended gate is the conservative near-empty floor,\n\
895 plus the model's own empty-call.\n\
896 This flag was referenced in the docs before it existed."
897 )]
898 pub qc_auto_cutoff: bool,
899}
900
901impl QcArgs {
902 pub fn to_config(&self) -> Option<QcConfig> {
904 (!self.no_qc).then(|| QcConfig {
905 n_mads: self.qc_mads,
906 min_cell_nnz: self.qc_min_cell_nnz,
907 min_counts_per_cell: self.qc_min_counts,
908 mito_pattern: self.qc_mito_pattern.clone().filter(|p| !p.is_empty()),
911 mito_max_frac: self.qc_mito_max_frac,
912 ribo_pattern: self.qc_ribo_pattern.clone().filter(|p| !p.is_empty()),
913 ribo_max_frac: self.qc_ribo_max_frac,
914 mad_on_n_genes: self.qc_mad_on_genes,
920 mad_on_counts: self.qc_mad_on_counts,
921 mad_on_mito: self
922 .qc_mito_pattern
923 .as_deref()
924 .is_some_and(|p| !p.is_empty())
925 && self.qc_mad_on_mito,
926 feature_min_cells: self.qc_feature_min_cells,
927 drop_outliers: true,
928 auto_cell_cutoff: self.qc_auto_cutoff,
932 qc_histogram: self.qc_histogram,
933 })
934 }
935}
936
937#[cfg(test)]
938mod qc_tests {
939 use super::*;
940
941 #[test]
942 fn robust_lower_flags_low_outlier() {
943 let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
944 let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
945 assert!(!keep[5], "the value 1.0 should be a lower outlier");
946 assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
947 let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
949 assert!(keep_up[5], "lower outlier kept under Tail::Upper");
950 }
951
952 #[test]
953 fn no_spread_flags_no_one() {
954 let mut v = vec![0.0f32; 60];
957 v.extend([0.01, 0.02, 0.05, 0.3]);
958 assert!(robust_band(&v, 5.0, false, None).is_none());
959 let keep = robust_outlier_keep(&v, 5.0, Tail::Upper, false, None);
960 assert!(keep.iter().all(|&k| k));
961 }
962
963 #[test]
964 fn robust_uniform_keeps_all() {
965 let v = vec![7.0; 20];
966 let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
967 assert!(keep.iter().all(|&k| k));
968 }
969
970 #[test]
971 fn auto_cutoff_train_drops_ambient() {
972 let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
976 let cfg = QcConfig {
977 auto_cell_cutoff: true,
978 qc_histogram: false,
979 drop_outliers: true,
980 mad_on_n_genes: false,
981 mad_on_counts: false,
982 mad_on_mito: false,
983 min_cell_nnz: 0,
984 ..QcConfig::default()
985 };
986 let report = qc_from_metrics(
987 QcMetrics {
988 n_genes: n_genes.clone(),
989 total_counts: n_genes,
990 mito_frac: None,
991 ribo_frac: None,
992 feature_n_cells: None,
993 n_rows: 0,
994 },
995 &cfg,
996 None,
997 );
998 assert_eq!(
999 report.train_keep,
1000 [vec![false; 30], vec![true; 30]].concat()
1001 );
1002 assert_eq!(report.n_cells_dropped, 30);
1003 }
1004
1005 #[test]
1006 fn robust_consider_excludes_contaminants_from_band() {
1007 let mut v = vec![0.0; 12];
1011 v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
1012 let mut consider = vec![false; 12];
1013 consider.extend([true; 6]);
1014 let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
1015 assert!(
1016 !keep[17],
1017 "40 is a lower outlier of the real cluster (~100)"
1018 );
1019 let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
1021 assert!(
1022 keep_naive[17],
1023 "naive band (contaminated by zeros) keeps 40"
1024 );
1025 }
1026
1027 #[test]
1028 fn output_keep_idx_skips_dropped_and_near_empty() {
1029 let report = QcReport {
1031 train_keep: vec![true, true, false, true],
1032 near_empty: vec![false, true, false, false],
1033 feature_keep: vec![],
1034 n_genes: vec![],
1035 total_counts: vec![],
1036 mito_frac: None,
1037 ribo_frac: None,
1038 n_cells_dropped: 1,
1039 n_features_dropped: 0,
1040 };
1041 assert_eq!(report.output_keep_idx(), vec![0, 2]);
1044 }
1045}
1046
1047#[cfg(test)]
1048#[path = "qc_lib_tests.rs"]
1049mod tests;