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 row_names = data.row_names()?;
307
308 let col_stat = collect_column_stat_across_vec(data, None, block_size)?;
311 let n_genes = col_stat.count_positives();
312 let total_counts = col_stat.sum();
313
314 let frac_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<f32>>> {
316 let Some(pat) = pattern else {
317 return Ok(None);
318 };
319 let rows = resolve_rows_by_regex(&row_names, pat)?;
320 if rows.is_empty() {
321 warn!(
322 "QC: pattern `{}` matched no features — metric disabled",
323 pat
324 );
325 return Ok(None);
326 }
327 let sub_tot = collect_column_stat_across_vec(data, Some(&rows), block_size)?.sum();
328 let frac = sub_tot
329 .iter()
330 .zip(total_counts.iter())
331 .map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
332 .collect::<Vec<f32>>();
333 Ok(Some(frac))
334 };
335 let mito_frac = frac_for(&cfg.mito_pattern)?;
336 let ribo_frac = frac_for(&cfg.ribo_pattern)?;
337
338 let feature_n_cells = if cfg.feature_min_cells > 0 {
340 Some(collect_row_stat_across_vec(data, block_size)?.count_positives())
341 } else {
342 None
343 };
344
345 Ok(qc_from_metrics(
346 QcMetrics {
347 n_genes,
348 total_counts,
349 mito_frac,
350 ribo_frac,
351 feature_n_cells,
352 n_rows: data.num_rows(),
353 },
354 cfg,
355 exempt,
356 ))
357}
358
359pub fn compute_qc_stack(
364 stack: &SparseIoStack,
365 cfg: &QcConfig,
366 block_size: Option<usize>,
367) -> anyhow::Result<QcReport> {
368 let n_cols = stack.num_columns()?;
369 let mut n_genes = vec![0f32; n_cols];
370 let mut total_counts = vec![0f32; n_cols];
371 for member in stack.stack.iter() {
372 let cs = collect_column_stat_across_vec(member, None, block_size)?;
373 let ng = cs.count_positives();
374 let ct = cs.sum();
375 for c in 0..n_cols {
376 n_genes[c] += ng[c];
377 total_counts[c] += ct[c];
378 }
379 }
380 if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
381 warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
382 }
383 Ok(qc_from_metrics(
384 QcMetrics {
385 n_genes,
386 total_counts,
387 mito_frac: None,
388 ribo_frac: None,
389 feature_n_cells: None,
390 n_rows: 0, },
392 cfg,
393 None,
394 ))
395}
396
397struct QcMetrics {
400 n_genes: Vec<f32>,
401 total_counts: Vec<f32>,
402 mito_frac: Option<Vec<f32>>,
403 ribo_frac: Option<Vec<f32>>,
404 feature_n_cells: Option<Vec<f32>>,
406 n_rows: usize,
407}
408
409fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
420 let QcMetrics {
421 n_genes,
422 total_counts,
423 mito_frac,
424 ribo_frac,
425 feature_n_cells,
426 n_rows,
427 } = m;
428 let n_cols = n_genes.len();
429
430 let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
431
432 let near_empty: Vec<bool> = n_genes
434 .iter()
435 .enumerate()
436 .map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
437 .collect();
438
439 let not_near_empty: Vec<bool> = near_empty
443 .iter()
444 .enumerate()
445 .map(|(c, &e)| !e && !is_exempt(c))
446 .collect();
447 let consider = Some(not_near_empty.as_slice());
448 let mut outlier = vec![false; n_cols];
449 if cfg.drop_outliers {
450 let mut bands: Vec<Vec<bool>> = Vec::new();
451 if cfg.mad_on_n_genes {
452 bands.push(robust_outlier_keep(
453 &n_genes,
454 cfg.n_mads,
455 Tail::Lower,
456 true,
457 consider,
458 ));
459 }
460 if cfg.mad_on_counts {
461 bands.push(robust_outlier_keep(
462 &total_counts,
463 cfg.n_mads,
464 Tail::Lower,
465 true,
466 consider,
467 ));
468 }
469 if cfg.mad_on_mito {
470 if let Some(mf) = mito_frac.as_ref() {
471 bands.push(robust_outlier_keep(
472 mf,
473 cfg.n_mads,
474 Tail::Upper,
475 false,
476 consider,
477 ));
478 }
479 }
480 for c in 0..n_cols {
481 if near_empty[c] || is_exempt(c) {
482 continue; }
484 let mut fail = total_counts[c] < cfg.min_counts_per_cell;
485 if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
486 fail |= mf[c] > cap;
487 }
488 if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
489 fail |= rf[c] > cap;
490 }
491 for band in bands.iter() {
492 if !band[c] {
493 fail = true;
494 break;
495 }
496 }
497 outlier[c] = fail;
498 }
499
500 if cfg.auto_cell_cutoff || cfg.qc_histogram {
508 let suggested = crate::qc::suggest_nnz_cutoff(&n_genes);
509 let shown = suggested.unwrap_or(cfg.min_cell_nnz);
512 crate::qc::print_nnz_summary("Cell", "nnz", &n_genes, shown, suggested);
513 if cfg.auto_cell_cutoff {
514 if let Some(cut) = suggested {
515 for (c, &g) in n_genes.iter().enumerate() {
516 if (g as usize) < cut {
517 outlier[c] = true;
518 }
519 }
520 }
521 }
522 }
523 }
524
525 let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
526 let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
527
528 if n_cols > 0 && n_cells_dropped >= n_cols {
530 warn!(
531 "QC would drop all {} cells — keeping all (check thresholds)",
532 n_cols
533 );
534 train_keep = vec![true; n_cols];
535 n_cells_dropped = 0;
536 }
537
538 let (feature_keep, n_features_dropped) = match feature_n_cells {
540 Some(n_cells_expr) => {
541 let keep: Vec<bool> = n_cells_expr
542 .iter()
543 .map(|&c| (c as usize) >= cfg.feature_min_cells)
544 .collect();
545 let dropped = keep.iter().filter(|&&k| !k).count();
546 if !keep.is_empty() && dropped >= keep.len() {
547 warn!("QC would drop all features — keeping all");
548 (vec![true; keep.len()], 0)
549 } else {
550 (keep, dropped)
551 }
552 }
553 None => (vec![true; n_rows], 0),
554 };
555
556 QcReport {
557 train_keep,
558 near_empty,
559 feature_keep,
560 n_genes,
561 total_counts,
562 mito_frac,
563 ribo_frac,
564 n_cells_dropped,
565 n_features_dropped,
566 }
567}
568
569pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
572 items
573 .iter()
574 .zip(keep.iter())
575 .filter(|&(_, &k)| k)
576 .map(|(x, _)| x.clone())
577 .collect()
578}
579
580pub fn write_qc_report(
583 path: &str,
584 cell_names: &[Box<str>],
585 report: &QcReport,
586) -> anyhow::Result<()> {
587 use std::fmt::Write as _;
588 let n = report.train_keep.len();
589 anyhow::ensure!(
590 cell_names.len() == n,
591 "write_qc_report: {} names != {} cells",
592 cell_names.len(),
593 n
594 );
595
596 let mut header = String::from("#cell\tn_genes\ttotal_counts");
597 if report.mito_frac.is_some() {
598 header.push_str("\tmito_frac");
599 }
600 if report.ribo_frac.is_some() {
601 header.push_str("\tribo_frac");
602 }
603 header.push_str("\tnear_empty\ttrain_keep");
604
605 let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
606 lines.push(header.into_boxed_str());
607 for c in 0..n {
608 let mut line = String::new();
609 let _ = write!(
610 line,
611 "{}\t{}\t{}",
612 cell_names[c], report.n_genes[c], report.total_counts[c]
613 );
614 if let Some(mf) = report.mito_frac.as_ref() {
615 let _ = write!(line, "\t{}", mf[c]);
616 }
617 if let Some(rf) = report.ribo_frac.as_ref() {
618 let _ = write!(line, "\t{}", rf[c]);
619 }
620 let _ = write!(
621 line,
622 "\t{}\t{}",
623 report.near_empty[c] as u8, report.train_keep[c] as u8
624 );
625 lines.push(line.into_boxed_str());
626 }
627 write_lines(&lines, path)
628}
629
630#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
643#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
644pub struct QcArgs {
645 #[arg(
647 long = "no-qc",
648 default_value_t = false,
649 long_help = "Disable cell quality control entirely and keep every input cell.\n\
650 \n\
651 By DEFAULT (without this flag) cell QC is:\n \
652 - a near-empty nnz floor (--qc-min-cell-nnz), and\n \
653 - MAD-outlier drops on detected features and total counts\n \
654 (--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
655 \n\
656 Empty barcodes are dropped earlier, per input file, by the loader.\n\
657 A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
658 \n\
659 Outputs may therefore have FEWER ROWS than the input.\n\
660 Join by the cell/barcode name column, never by position.\n\
661 \n\
662 Use --qc-report to see exactly what was dropped.\n\
663 For the older near-empty-floor-only gate,\n\
664 pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
665 )]
666 pub no_qc: bool,
667
668 #[arg(long = "qc-mads", default_value_t = 5.0)]
670 pub qc_mads: f32,
671
672 #[arg(
673 long = "qc-min-cell-nnz",
674 default_value_t = 2,
675 help = "Near-empty floor on a cell's detected-feature count",
676 long_help = "Near-empty floor on the detected-feature count.\n\
677 Cells below it are dropped from the per-cell outputs.\n\
678 They are still kept in training."
679 )]
680 pub qc_min_cell_nnz: usize,
681
682 #[arg(
683 long = "qc-min-counts",
684 hide = true,
685 default_value_t = 0.0,
686 help = "Hard floor on total counts per cell",
687 long_help = "Hard floor on total counts per cell.\n\
688 Cells below it are dropped from training. 0 disables the floor."
689 )]
690 pub qc_min_counts: f32,
691
692 #[arg(
693 long = "qc-mito-pattern",
694 hide = true,
695 help = "Regex over feature names selecting mitochondrial genes",
696 long_help = "Regex over feature names selecting mitochondrial genes.\n\
697 It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
698 )]
699 pub qc_mito_pattern: Option<String>,
700
701 #[arg(long = "qc-mito-max-frac", hide = true)]
703 pub qc_mito_max_frac: Option<f32>,
704
705 #[arg(long = "qc-ribo-pattern", hide = true)]
707 pub qc_ribo_pattern: Option<String>,
708
709 #[arg(long = "qc-ribo-max-frac", hide = true)]
711 pub qc_ribo_max_frac: Option<f32>,
712
713 #[arg(
714 long = "qc-feature-min-cells",
715 hide = true,
716 default_value_t = 0,
717 help = "Feature/row QC: drop genes expressed in too few cells",
718 long_help = "Feature/row QC; off by default.\n\
719 It DROPS gene rows expressed in fewer than this many cells.\n\
720 \n\
721 Not every consumer applies it.\n\
722 `bge` does not, since QC there is cell-only.\n\
723 `pinto` does not either: it reads only the cell verdict,\n\
724 so setting this costs a stats pass and changes nothing."
725 )]
726 pub qc_feature_min_cells: usize,
727
728 #[arg(
729 long = "qc-report",
730 help = "Write a per-cell QC table (.tsv)",
731 long_help = "Write a per-cell QC table, as .tsv.\n\
732 It carries the metrics plus near_empty and train_keep flags.\n\
733 You can then see exactly which cells were dropped."
734 )]
735 pub qc_report: Option<Box<str>>,
736
737 #[arg(
738 long = "qc-histogram",
739 hide = true,
740 default_value_t = false,
741 help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
742 long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
743 The suggested trough cutoff is marked.\n\
744 It is the same summary as `data-beans squeeze --show-histogram`.\n\
745 \n\
746 This is purely diagnostic. The cutoff is shown, not applied.\n\
747 The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
748 Use the histogram to pick --qc-min-cell-nnz by hand."
749 )]
750 pub qc_histogram: bool,
751
752 #[arg(
753 long = "qc-mad-on-genes",
754 hide = true,
755 action = clap::ArgAction::Set,
757 num_args = 0..=1,
758 require_equals = true,
759 default_missing_value = "true",
760 default_value_t = true,
761 help = "MAD-outlier drop on the per-cell detected-feature count",
762 long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
763 \n\
764 ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
765 with no way to enable them. That also made --qc-mads inert.\n\
766 Only a set --qc-mito-pattern revived it.\n\
767 \n\
768 Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
769 That is the conservative near-empty nnz gate alone."
770 )]
771 pub qc_mad_on_genes: bool,
772
773 #[arg(
774 long = "qc-mad-on-counts",
775 hide = true,
776 action = clap::ArgAction::Set,
778 num_args = 0..=1,
779 require_equals = true,
780 default_missing_value = "true",
781 default_value_t = true,
782 help = "MAD-outlier drop on per-cell total counts",
783 long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
784 ON by default; see --qc-mad-on-genes."
785 )]
786 pub qc_mad_on_counts: bool,
787
788 #[arg(
789 long = "qc-mad-on-mito",
790 hide = true,
791 action = clap::ArgAction::Set,
793 num_args = 0..=1,
794 require_equals = true,
795 default_missing_value = "true",
796 default_value_t = true,
797 help = "MAD-outlier drop on the mito fraction, given --qc-mito-pattern",
798 long_help = "Drop cells whose mito fraction is above `median + --qc-mads * MAD * 1.4826`.\n\
799 On whenever --qc-mito-pattern is given. `--qc-mad-on-mito=false` leaves\n\
800 only the hard cap, --qc-mito-max-frac, when one is given."
801 )]
802 pub qc_mad_on_mito: bool,
803
804 #[arg(
805 long = "qc-auto-cutoff",
806 hide = true,
807 default_value_t = false,
808 help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
809 long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
810 It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
811 Without this flag it is only reported, via --qc-histogram.\n\
812 \n\
813 OFF by default. The intended gate is the conservative near-empty floor,\n\
814 plus the model's own empty-call.\n\
815 This flag was referenced in the docs before it existed."
816 )]
817 pub qc_auto_cutoff: bool,
818}
819
820impl QcArgs {
821 pub fn to_config(&self) -> Option<QcConfig> {
823 (!self.no_qc).then(|| QcConfig {
824 n_mads: self.qc_mads,
825 min_cell_nnz: self.qc_min_cell_nnz,
826 min_counts_per_cell: self.qc_min_counts,
827 mito_pattern: self.qc_mito_pattern.clone(),
828 mito_max_frac: self.qc_mito_max_frac,
829 ribo_pattern: self.qc_ribo_pattern.clone(),
830 ribo_max_frac: self.qc_ribo_max_frac,
831 mad_on_n_genes: self.qc_mad_on_genes,
837 mad_on_counts: self.qc_mad_on_counts,
838 mad_on_mito: self.qc_mito_pattern.is_some() && self.qc_mad_on_mito,
839 feature_min_cells: self.qc_feature_min_cells,
840 drop_outliers: true,
841 auto_cell_cutoff: self.qc_auto_cutoff,
845 qc_histogram: self.qc_histogram,
846 })
847 }
848}
849
850#[cfg(test)]
851mod qc_tests {
852 use super::*;
853
854 #[test]
855 fn robust_lower_flags_low_outlier() {
856 let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
857 let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
858 assert!(!keep[5], "the value 1.0 should be a lower outlier");
859 assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
860 let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
862 assert!(keep_up[5], "lower outlier kept under Tail::Upper");
863 }
864
865 #[test]
866 fn no_spread_flags_no_one() {
867 let mut v = vec![0.0f32; 60];
870 v.extend([0.01, 0.02, 0.05, 0.3]);
871 assert!(robust_band(&v, 5.0, false, None).is_none());
872 let keep = robust_outlier_keep(&v, 5.0, Tail::Upper, false, None);
873 assert!(keep.iter().all(|&k| k));
874 }
875
876 #[test]
877 fn robust_uniform_keeps_all() {
878 let v = vec![7.0; 20];
879 let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
880 assert!(keep.iter().all(|&k| k));
881 }
882
883 #[test]
884 fn auto_cutoff_train_drops_ambient() {
885 let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
889 let cfg = QcConfig {
890 auto_cell_cutoff: true,
891 qc_histogram: false,
892 drop_outliers: true,
893 mad_on_n_genes: false,
894 mad_on_counts: false,
895 mad_on_mito: false,
896 min_cell_nnz: 0,
897 ..QcConfig::default()
898 };
899 let report = qc_from_metrics(
900 QcMetrics {
901 n_genes: n_genes.clone(),
902 total_counts: n_genes,
903 mito_frac: None,
904 ribo_frac: None,
905 feature_n_cells: None,
906 n_rows: 0,
907 },
908 &cfg,
909 None,
910 );
911 assert_eq!(
912 report.train_keep,
913 [vec![false; 30], vec![true; 30]].concat()
914 );
915 assert_eq!(report.n_cells_dropped, 30);
916 }
917
918 #[test]
919 fn robust_consider_excludes_contaminants_from_band() {
920 let mut v = vec![0.0; 12];
924 v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
925 let mut consider = vec![false; 12];
926 consider.extend([true; 6]);
927 let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
928 assert!(
929 !keep[17],
930 "40 is a lower outlier of the real cluster (~100)"
931 );
932 let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
934 assert!(
935 keep_naive[17],
936 "naive band (contaminated by zeros) keeps 40"
937 );
938 }
939
940 #[test]
941 fn output_keep_idx_skips_dropped_and_near_empty() {
942 let report = QcReport {
944 train_keep: vec![true, true, false, true],
945 near_empty: vec![false, true, false, false],
946 feature_keep: vec![],
947 n_genes: vec![],
948 total_counts: vec![],
949 mito_frac: None,
950 ribo_frac: None,
951 n_cells_dropped: 1,
952 n_features_dropped: 0,
953 };
954 assert_eq!(report.output_keep_idx(), vec![0, 2]);
957 }
958}
959
960#[cfg(test)]
961#[path = "qc_lib_tests.rs"]
962mod tests;