use faer::{Mat, MatRef};
use rayon::prelude::*;
use crate::ml::clustering::k_means::*;
use crate::prelude::*;
#[derive(Clone, Copy, Debug, Default)]
pub enum ScaleFactor {
#[default]
Standardise,
MeanSubtract,
}
pub fn parse_scale_factor(s: &str) -> Option<ScaleFactor> {
match s.to_lowercase().as_str() {
"standardize" | "standardise" => Some(ScaleFactor::Standardise),
"mean.subtract" | "mean_subtract" | "meansubtract" => Some(ScaleFactor::MeanSubtract),
_ => None,
}
}
pub enum BackgroundSource<'a> {
EmptyDroplets {
empty_counts: MatRef<'a, f32>,
scale_factor: ScaleFactor,
},
ModelNegative,
}
pub fn prepare_background_source<'a>(
empty_counts: Option<MatRef<'a, f32>>,
scale_factor: String,
) -> BackgroundSource<'a> {
if let Some(empty_counts) = empty_counts {
let scale_factor = parse_scale_factor(&scale_factor).unwrap_or_default();
BackgroundSource::EmptyDroplets {
empty_counts,
scale_factor,
}
} else {
BackgroundSource::ModelNegative
}
}
pub struct DsbResult {
pub normalised: Mat<f32>,
pub protein_background_mean: Vec<f64>,
pub protein_background_sd: Option<Vec<f64>>,
pub technical_component: Option<Vec<f64>>,
pub cellwise_background_mean: Option<Vec<f64>>,
}
#[derive(Clone, Debug)]
pub struct DsbParams {
pub denoise_counts: bool,
pub use_isotype_controls: bool,
pub isotype_indices: Vec<usize>,
pub pseudocount: f64,
pub kmeans_iters: usize,
pub quantile_clip: Option<(f64, f64)>,
}
impl Default for DsbParams {
fn default() -> Self {
Self {
denoise_counts: true,
use_isotype_controls: true,
isotype_indices: Vec::new(),
pseudocount: 10.0,
kmeans_iters: 50,
quantile_clip: None,
}
}
}
fn log_transform(mat: MatRef<f32>, pseudocount: f64) -> Vec<Vec<f64>> {
let n_rows = mat.nrows();
let n_cols = mat.ncols();
(0..n_rows)
.into_par_iter()
.map(|i| {
(0..n_cols)
.map(|j| (mat[(i, j)] as f64 + pseudocount).ln())
.collect()
})
.collect()
}
fn col_means(rows: &[Vec<f64>]) -> Vec<f64> {
if rows.is_empty() {
return Vec::new();
}
let n_cols = rows[0].len();
let n_rows = rows.len() as f64;
(0..n_cols)
.into_par_iter()
.map(|j| rows.iter().map(|r| r[j]).sum::<f64>() / n_rows)
.collect()
}
fn col_sds(rows: &[Vec<f64>], means: &[f64]) -> Vec<f64> {
if rows.len() < 2 {
return vec![0.0; means.len()];
}
let n_cols = means.len();
let denom = (rows.len() - 1) as f64;
(0..n_cols)
.into_par_iter()
.map(|j| {
let s: f64 = rows.iter().map(|r| (r[j] - means[j]).powi(2)).sum();
(s / denom).sqrt()
})
.collect()
}
fn lower_centroid_kmeans(
values: &[f64],
n_iters: usize,
seed: usize,
) -> Result<f64, BixverseErrors> {
let n = values.len();
if n < 2 {
return Ok(values.first().copied().unwrap_or(0.0));
}
let m = Mat::<f32>::from_fn(n, 1, |i, _| values[i] as f32);
let k_means_params = KMeansParamsWrappers::new(n_iters, None, None);
let (centroids, _assignments) = k_means_clusters(
m.as_ref(),
"euclidean",
2,
Some(k_means_params),
seed,
false,
)?;
let c0 = centroids[(0, 0)] as f64;
let c1 = centroids[(1, 0)] as f64;
Ok(c0.min(c1))
}
fn col_quantile(rows: &[Vec<f64>], j: usize, q: f64) -> f64 {
let mut vals: Vec<f64> = rows.iter().map(|r| r[j]).collect();
let n = vals.len();
if n == 0 {
return 0.0;
}
if n == 1 {
return vals[0];
}
vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let h = (n as f64 - 1.0) * q;
let lo = h.floor() as usize;
let hi = (lo + 1).min(n - 1);
let frac = h - lo as f64;
vals[lo] + frac * (vals[hi] - vals[lo])
}
fn cellwise_background_means(
cells: &[Vec<f64>],
n_iters: usize,
seed: usize,
) -> Result<Vec<f64>, BixverseErrors> {
cells
.par_iter()
.enumerate()
.map(|(i, row)| lower_centroid_kmeans(row, n_iters, seed.wrapping_add(i)))
.collect()
}
fn build_noise_matrix(
cells: &[Vec<f64>],
isotype_indices: &[usize],
cellwise_bg: &[f64],
) -> Vec<Vec<f64>> {
let n_cells = cells.len();
let mut rows: Vec<Vec<f64>> = Vec::with_capacity(isotype_indices.len() + 1);
for &j in isotype_indices {
let r: Vec<f64> = cells.iter().map(|c| c[j]).collect();
rows.push(r);
}
rows.push(cellwise_bg.to_vec());
for row in rows.iter_mut() {
let mean = row.iter().sum::<f64>() / n_cells as f64;
let var = row.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n_cells - 1) as f64;
let sd = var.sqrt();
if sd > 0.0 {
for v in row.iter_mut() {
*v = (*v - mean) / sd;
}
} else {
for v in row.iter_mut() {
*v -= mean;
}
}
}
rows
}
fn pc1_scores(noise_rows: &[Vec<f64>]) -> Result<Vec<f64>, BixverseErrors> {
let n_cov = noise_rows.len();
let n_cells = noise_rows[0].len();
let mat = Mat::<f64>::from_fn(n_cells, n_cov, |i, j| noise_rows[j][i]);
let svd = mat
.thin_svd()
.map_err(|e| BixverseErrors::FaerSvdError(format!("{e:?}")))?;
let u = svd.U();
let s = svd.S();
let s0 = s[0];
let scores: Vec<f64> = (0..n_cells).map(|i| u[(i, 0)] * s0).collect();
Ok(scores)
}
fn regress_out_covariate(cells: &mut [Vec<f64>], z: &[f64]) {
let n_cells = cells.len();
if n_cells < 2 {
return;
}
let n_cols = cells[0].len();
let mean_z: f64 = z.iter().sum::<f64>() / n_cells as f64;
let centred_z: Vec<f64> = z.iter().map(|v| v - mean_z).collect();
let var_z: f64 = centred_z.iter().map(|v| v * v).sum::<f64>() / (n_cells - 1) as f64;
if var_z <= 0.0 {
return;
}
let denom = var_z * (n_cells - 1) as f64;
let betas: Vec<f64> = (0..n_cols)
.into_par_iter()
.map(|j| {
let mean_y: f64 = cells.iter().map(|r| r[j]).sum::<f64>() / n_cells as f64;
let num: f64 = cells
.iter()
.zip(centred_z.iter())
.map(|(r, &zc)| (r[j] - mean_y) * zc)
.sum();
num / denom
})
.collect();
cells.par_iter_mut().enumerate().for_each(|(i, row)| {
let zc = centred_z[i];
for j in 0..n_cols {
row[j] -= betas[j] * zc;
}
});
}
pub fn dsb_normalise(
cells: MatRef<f32>,
background: BackgroundSource,
params: &DsbParams,
verbose: usize,
seed: usize,
) -> Result<DsbResult, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let n_cells = cells.nrows();
let n_proteins = cells.ncols();
if n_cells == 0 || n_proteins == 0 {
return Err(BixverseErrors::InvalidArgument(
"cells matrix is empty".to_string(),
));
}
for &j in ¶ms.isotype_indices {
if j >= n_proteins {
return Err(BixverseErrors::InvalidArgument(format!(
"isotype index {j} >= n_proteins {n_proteins}"
)));
}
}
let mut cell_log = log_transform(cells, params.pseudocount);
let (bg_mean, bg_sd): (Vec<f64>, Option<Vec<f64>>) = match &background {
BackgroundSource::EmptyDroplets {
empty_counts,
scale_factor,
} => {
if empty_counts.ncols() != n_proteins {
return Err(BixverseErrors::InvalidArgument(format!(
"empty_counts has {} proteins, cells have {}",
empty_counts.ncols(),
n_proteins
)));
}
let empty_log = log_transform(*empty_counts, params.pseudocount);
let mu = col_means(&empty_log);
let sd = col_sds(&empty_log, &mu);
match scale_factor {
ScaleFactor::Standardise => {
cell_log.par_iter_mut().for_each(|row| {
for j in 0..n_proteins {
let denom = if sd[j] > 0.0 { sd[j] } else { 1.0 };
row[j] = (row[j] - mu[j]) / denom;
}
});
(mu, Some(sd))
}
ScaleFactor::MeanSubtract => {
cell_log.par_iter_mut().for_each(|row| {
for j in 0..n_proteins {
row[j] -= mu[j];
}
});
(mu, Some(sd))
}
}
}
BackgroundSource::ModelNegative => {
if verbosity.normal_verbosity() {
println!("DSB: estimating per-protein background via k-means (no empty drops)");
}
let mu: Vec<f64> = (0..n_proteins)
.into_par_iter()
.map(|j| {
let col: Vec<f64> = cell_log.iter().map(|r| r[j]).collect();
lower_centroid_kmeans(&col, params.kmeans_iters, seed.wrapping_add(j))
})
.collect::<Result<Vec<f64>, _>>()?;
cell_log.par_iter_mut().for_each(|row| {
for j in 0..n_proteins {
row[j] -= mu[j];
}
});
(mu, None)
}
};
let mut technical_component: Option<Vec<f64>> = None;
let mut cellwise_bg: Option<Vec<f64>> = None;
if params.denoise_counts {
if verbosity.normal_verbosity() {
println!("DSB: Step II - estimating per-cell background");
}
let bg = cellwise_background_means(&cell_log, params.kmeans_iters, seed)?;
let noise_vec = if params.use_isotype_controls && !params.isotype_indices.is_empty() {
let noise_rows = build_noise_matrix(&cell_log, ¶ms.isotype_indices, &bg);
pc1_scores(&noise_rows)?
} else {
bg.clone()
};
regress_out_covariate(&mut cell_log, &noise_vec);
technical_component = Some(noise_vec);
cellwise_bg = Some(bg);
}
if let Some((q_low, q_high)) = params.quantile_clip {
if !(0.0..=1.0).contains(&q_low) || !(0.0..=1.0).contains(&q_high) || q_low >= q_high {
return Err(BixverseErrors::InvalidArgument(format!(
"invalid quantile_clip ({q_low}, {q_high})"
)));
}
for j in 0..n_proteins {
let ql = col_quantile(&cell_log, j, q_low);
let qh = col_quantile(&cell_log, j, q_high);
cell_log.par_iter_mut().for_each(|row| {
if row[j] < ql {
row[j] = ql;
} else if row[j] > qh {
row[j] = qh;
}
});
}
}
let normalised = Mat::<f32>::from_fn(n_cells, n_proteins, |i, j| cell_log[i][j] as f32);
Ok(DsbResult {
normalised,
protein_background_mean: bg_mean,
protein_background_sd: bg_sd,
technical_component,
cellwise_background_mean: cellwise_bg,
})
}
#[cfg(test)]
mod tests {
use super::*;
use faer::Mat;
const EPS: f32 = 1e-5;
const EPS64: f64 = 1e-8;
fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() < eps
}
fn bimodal_values() -> Vec<f64> {
let mut v = Vec::with_capacity(100);
for i in 0..50 {
v.push(0.0 + (i as f64) * 1e-4);
}
for i in 0..50 {
v.push(10.0 + (i as f64) * 1e-4);
}
v
}
fn small_fixture() -> (Mat<f32>, Mat<f32>) {
let n_cells = 20;
let n_proteins = 5;
let cells = Mat::<f32>::from_fn(n_cells, n_proteins, |i, j| {
let jitter = ((i * 13 + j * 7) % 11) as f32 * 0.01;
let hi = i < 10;
match j {
0..=2 => (if hi { 5.0 } else { 100.0 }) + jitter,
_ => (if hi { 100.0 } else { 5.0 }) + jitter,
}
});
let n_empty = 50;
let empty = Mat::<f32>::from_fn(n_empty, n_proteins, |i, j| {
2.0 + ((i + j) % 7) as f32 * 0.5
});
(cells, empty)
}
#[test]
fn parse_scale_factor_known_and_unknown() {
assert!(matches!(
parse_scale_factor("standardize"),
Some(ScaleFactor::Standardise)
));
assert!(matches!(
parse_scale_factor("STANDARDISE"),
Some(ScaleFactor::Standardise)
));
assert!(matches!(
parse_scale_factor("mean.subtract"),
Some(ScaleFactor::MeanSubtract)
));
assert!(matches!(
parse_scale_factor("mean_subtract"),
Some(ScaleFactor::MeanSubtract)
));
assert!(matches!(
parse_scale_factor("MEANSUBTRACT"),
Some(ScaleFactor::MeanSubtract)
));
assert!(parse_scale_factor("nope").is_none());
}
#[test]
fn prepare_background_source_branches() {
let empty = Mat::<f32>::from_fn(3, 2, |i, j| (i + j) as f32);
match prepare_background_source(Some(empty.as_ref()), "standardize".into()) {
BackgroundSource::EmptyDroplets { scale_factor, .. } => {
assert!(matches!(scale_factor, ScaleFactor::Standardise));
}
_ => panic!("expected EmptyDroplets"),
}
match prepare_background_source(None, "ignored".into()) {
BackgroundSource::ModelNegative => {}
_ => panic!("expected ModelNegative"),
}
}
#[test]
fn log_transform_applies_pseudocount() {
let mat = Mat::<f32>::from_fn(2, 3, |i, j| (i * 3 + j) as f32); let logged = log_transform(mat.as_ref(), 1.0);
assert_eq!(logged.len(), 2);
assert_eq!(logged[0].len(), 3);
assert!(approx_eq(logged[0][0], 0.0, EPS64));
assert!(approx_eq(logged[1][2], 6.0_f64.ln(), EPS64));
}
#[test]
fn col_means_known_values() {
let rows = vec![
vec![1.0, 2.0, 3.0],
vec![4.0, 5.0, 6.0],
vec![7.0, 8.0, 9.0],
];
let m = col_means(&rows);
assert_eq!(m, vec![4.0, 5.0, 6.0]);
}
#[test]
fn col_means_empty_input() {
let rows: Vec<Vec<f64>> = Vec::new();
assert!(col_means(&rows).is_empty());
}
#[test]
fn col_sds_known_values() {
let rows = vec![vec![1.0, 2.0], vec![4.0, 2.0], vec![7.0, 2.0]];
let means = col_means(&rows);
let sds = col_sds(&rows, &means);
assert!(approx_eq(sds[0], 3.0, EPS64));
assert!(approx_eq(sds[1], 0.0, EPS64));
}
#[test]
fn col_sds_single_row_returns_zero() {
let rows = vec![vec![1.0, 2.0, 3.0]];
let means = col_means(&rows);
let sds = col_sds(&rows, &means);
assert_eq!(sds, vec![0.0, 0.0, 0.0]);
}
#[test]
fn col_quantile_interpolates() {
let rows = vec![vec![3.0], vec![1.0], vec![5.0], vec![2.0], vec![4.0]];
assert!(approx_eq(col_quantile(&rows, 0, 0.0), 1.0, EPS64));
assert!(approx_eq(col_quantile(&rows, 0, 1.0), 5.0, EPS64));
assert!(approx_eq(col_quantile(&rows, 0, 0.5), 3.0, EPS64));
assert!(approx_eq(col_quantile(&rows, 0, 0.25), 2.0, EPS64));
}
#[test]
fn col_quantile_handles_short_inputs() {
let empty: Vec<Vec<f64>> = vec![];
assert!(approx_eq(col_quantile(&empty, 0, 0.5), 0.0, EPS64));
let single = vec![vec![7.0]];
assert!(approx_eq(col_quantile(&single, 0, 0.5), 7.0, EPS64));
}
#[test]
fn lower_centroid_kmeans_picks_lower_mode() {
let v = bimodal_values();
let lower = lower_centroid_kmeans(&v, 50, 42).unwrap();
assert!(lower < 1.0, "expected lower centroid near 0, got {lower}");
}
#[test]
fn lower_centroid_kmeans_handles_short_input() {
let v: Vec<f64> = vec![7.5];
assert!(approx_eq(
lower_centroid_kmeans(&v, 10, 0).unwrap(),
7.5,
EPS64
));
let v: Vec<f64> = vec![];
assert!(approx_eq(
lower_centroid_kmeans(&v, 10, 0).unwrap(),
0.0,
EPS64
));
}
#[test]
fn build_noise_matrix_zscores_each_row() {
let cells = vec![
vec![1.0, 9.0, 9.0],
vec![2.0, 9.0, 9.0],
vec![3.0, 9.0, 9.0],
vec![4.0, 9.0, 9.0],
];
let bg = vec![0.0, 1.0, 2.0, 3.0];
let noise = build_noise_matrix(&cells, &[0], &bg);
assert_eq!(noise.len(), 2);
for row in &noise {
let mean: f64 = row.iter().sum::<f64>() / row.len() as f64;
let var: f64 =
row.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (row.len() - 1) as f64;
assert!(approx_eq(mean, 0.0, 1e-10), "row mean {mean} not zero");
assert!(approx_eq(var.sqrt(), 1.0, 1e-10), "row sd not unit");
}
}
#[test]
fn build_noise_matrix_constant_row_safe() {
let cells = vec![vec![5.0, 1.0], vec![5.0, 2.0], vec![5.0, 3.0]];
let bg = vec![1.0, 2.0, 3.0];
let noise = build_noise_matrix(&cells, &[0], &bg);
for v in &noise[0] {
assert!(approx_eq(*v, 0.0, 1e-10));
}
}
#[test]
fn regress_out_covariate_removes_linear_effect() {
let z = vec![0.0_f64, 1.0, 2.0, 3.0, 4.0];
let mut cells: Vec<Vec<f64>> = z.iter().map(|&zi| vec![2.0 * zi, 5.0]).collect();
let before_var: f64 = {
let mean = cells.iter().map(|r| r[0]).sum::<f64>() / 5.0;
cells.iter().map(|r| (r[0] - mean).powi(2)).sum::<f64>() / 4.0
};
regress_out_covariate(&mut cells, &z);
let after_var: f64 = {
let mean = cells.iter().map(|r| r[0]).sum::<f64>() / 5.0;
cells.iter().map(|r| (r[0] - mean).powi(2)).sum::<f64>() / 4.0
};
assert!(
after_var < before_var * 0.01,
"linear effect not removed: before={before_var}, after={after_var}"
);
for r in &cells {
assert!(approx_eq(r[1], 5.0, 1e-10));
}
}
#[test]
fn regress_out_covariate_constant_z_is_noop() {
let z = vec![3.0_f64; 5];
let mut cells = vec![vec![1.0, 2.0]; 5];
let before = cells.clone();
regress_out_covariate(&mut cells, &z);
assert_eq!(cells, before);
}
#[test]
fn dsb_normalise_empty_droplets_shape_and_finite() {
let (cells, empty) = small_fixture();
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams {
denoise_counts: false,
use_isotype_controls: false,
isotype_indices: vec![],
pseudocount: 10.0,
kmeans_iters: 50,
quantile_clip: None,
};
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 42).expect("DSB failed");
assert_eq!(res.normalised.nrows(), 20);
assert_eq!(res.normalised.ncols(), 5);
assert_eq!(res.protein_background_mean.len(), 5);
assert!(res.protein_background_sd.is_some());
assert!(res.technical_component.is_none());
assert!(res.cellwise_background_mean.is_none());
for i in 0..20 {
for j in 0..5 {
assert!(
res.normalised[(i, j)].is_finite(),
"non-finite at [{i}, {j}]: {}",
res.normalised[(i, j)]
);
}
}
}
#[test]
fn dsb_normalise_separates_high_low_proteins() {
let (cells, empty) = small_fixture();
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams {
denoise_counts: false,
use_isotype_controls: false,
isotype_indices: vec![],
pseudocount: 10.0,
kmeans_iters: 50,
quantile_clip: None,
};
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 42).unwrap();
for j in 0..5 {
let low_half: f32 = (0..10).map(|i| res.normalised[(i, j)]).sum::<f32>() / 10.0;
let high_half: f32 = (10..20).map(|i| res.normalised[(i, j)]).sum::<f32>() / 10.0;
let high_is_high = if j < 3 {
high_half > low_half
} else {
low_half > high_half
};
assert!(
high_is_high,
"protein {j}: low={low_half}, high={high_half}, expected separation"
);
}
}
#[test]
fn dsb_normalise_model_negative_runs_end_to_end() {
let (cells, _) = small_fixture();
let params = DsbParams {
denoise_counts: true,
use_isotype_controls: false,
isotype_indices: vec![],
pseudocount: 1.0,
kmeans_iters: 50,
quantile_clip: None,
};
let res = dsb_normalise(
cells.as_ref(),
BackgroundSource::ModelNegative,
¶ms,
0,
42,
)
.expect("ModelNegative DSB failed");
assert_eq!(res.normalised.nrows(), 20);
assert!(res.protein_background_sd.is_none()); assert!(res.technical_component.is_some());
assert!(res.cellwise_background_mean.is_some());
for i in 0..20 {
for j in 0..5 {
assert!(res.normalised[(i, j)].is_finite());
}
}
}
#[test]
fn dsb_normalise_with_denoising_runs_end_to_end() {
let (cells, empty) = small_fixture();
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams {
denoise_counts: true,
use_isotype_controls: true,
isotype_indices: vec![3, 4],
pseudocount: 10.0,
kmeans_iters: 50,
quantile_clip: None,
};
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 42).unwrap();
assert!(res.technical_component.is_some());
let tc = res.technical_component.unwrap();
assert_eq!(tc.len(), 20);
assert!(tc.iter().all(|v| v.is_finite()));
for i in 0..20 {
for j in 0..5 {
assert!(res.normalised[(i, j)].is_finite());
}
}
}
#[test]
fn dsb_normalise_quantile_clipping_bounds_output() {
let (cells, empty) = small_fixture();
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams {
denoise_counts: false,
use_isotype_controls: false,
isotype_indices: vec![],
pseudocount: 10.0,
kmeans_iters: 50,
quantile_clip: Some((0.1, 0.9)),
};
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 42).unwrap();
for j in 0..5 {
let mut col: Vec<f32> = (0..20).map(|i| res.normalised[(i, j)]).collect();
col.sort_by(|a, b| a.partial_cmp(b).unwrap());
let lo = col[0];
let hi = col[19];
for v in &col {
assert!(*v >= lo - EPS && *v <= hi + EPS);
}
}
}
#[test]
fn dsb_normalise_rejects_empty_input() {
let cells = Mat::<f32>::from_fn(0, 5, |_, _| 0.0_f32);
let params = DsbParams::default();
let res = dsb_normalise(
cells.as_ref(),
BackgroundSource::ModelNegative,
¶ms,
0,
0,
);
assert!(matches!(res, Err(BixverseErrors::InvalidArgument(_))));
}
#[test]
fn dsb_normalise_rejects_isotype_index_out_of_range() {
let (cells, empty) = small_fixture();
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams {
denoise_counts: true,
use_isotype_controls: true,
isotype_indices: vec![99],
..Default::default()
};
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 0);
assert!(matches!(res, Err(BixverseErrors::InvalidArgument(_))));
}
#[test]
fn dsb_normalise_rejects_empty_droplet_protein_mismatch() {
let (cells, _) = small_fixture(); let empty_bad = Mat::<f32>::from_fn(50, 7, |_, _| 1.0);
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty_bad.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams::default();
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 0);
assert!(matches!(res, Err(BixverseErrors::InvalidArgument(_))));
}
#[test]
fn dsb_normalise_rejects_invalid_quantile_clip() {
let (cells, empty) = small_fixture();
let bg = BackgroundSource::EmptyDroplets {
empty_counts: empty.as_ref(),
scale_factor: ScaleFactor::Standardise,
};
let params = DsbParams {
denoise_counts: false,
quantile_clip: Some((0.9, 0.1)), ..Default::default()
};
let res = dsb_normalise(cells.as_ref(), bg, ¶ms, 0, 0);
assert!(matches!(res, Err(BixverseErrors::InvalidArgument(_))));
}
}