use rayon::prelude::*;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use thousands::Separable;
use crate::data_io::*;
use crate::mtx_io::{MtxSource, parse_block};
use crate::prelude::*;
pub struct MtxFileTask {
pub exp_id: String,
pub mtx_path: String,
pub gene_local_to_universe: Vec<Option<usize>>,
pub cells_as_rows: bool,
}
pub struct MtxFileQcResult {
pub exp_id: String,
pub cells_to_keep: Vec<usize>,
pub lib_size: Vec<usize>,
pub nnz: Vec<usize>,
}
pub struct MultiMtxResult {
pub global_gene_indices: Vec<usize>,
pub total_cells: usize,
pub total_genes: usize,
pub per_file: Vec<MtxFileQcResult>,
}
fn open_task(task: &MtxFileTask) -> Result<(MtxSource, usize, usize), ScDataErrors> {
let src = MtxSource::open(Path::new(&task.mtx_path), task.cells_as_rows)?;
let [rows, cols, _] = src.shape;
let (no_cells, no_genes) = if task.cells_as_rows {
(rows, cols)
} else {
(cols, rows)
};
Ok((src, no_cells, no_genes))
}
#[inline]
fn cell_gene(row: u32, col: u32, cells_as_rows: bool) -> Option<(usize, usize)> {
let (cell, gene) = if cells_as_rows {
(row, col)
} else {
(col, row)
};
Some((
(cell as usize).checked_sub(1)?,
(gene as usize).checked_sub(1)?,
))
}
fn scan_gene_nnz(task: &MtxFileTask, universe_size: usize) -> Result<Vec<usize>, ScDataErrors> {
let (src, _, _) = open_task(task)?;
let field = src.field;
let per_worker = src.par_fold_blocks(
|| vec![0u32; universe_size],
|acc, _, bytes| {
parse_block(bytes, field, |row, col, _| {
if let Some((_, gene)) = cell_gene(row, col, task.cells_as_rows)
&& let Some(&Some(u)) = task.gene_local_to_universe.get(gene)
{
acc[u] += 1;
}
})
},
)?;
let mut total = vec![0usize; universe_size];
for acc in per_worker {
total
.iter_mut()
.zip(acc)
.for_each(|(t, n)| *t += n as usize);
}
Ok(total)
}
fn scan_cell_stats(
task: &MtxFileTask,
gene_local_to_final: &[Option<usize>],
) -> Result<Vec<(usize, u64)>, ScDataErrors> {
let (src, no_cells, _) = open_task(task)?;
let field = src.field;
let per_worker = src.par_fold_blocks(
|| vec![(0u32, 0u64); no_cells],
|acc, _, bytes| {
parse_block(bytes, field, |row, col, value| {
if let Some((cell, gene)) = cell_gene(row, col, task.cells_as_rows)
&& cell < no_cells
&& let Some(&Some(_)) = gene_local_to_final.get(gene)
{
acc[cell].0 += 1;
acc[cell].1 += value as u64;
}
})
},
)?;
let mut stats = vec![(0usize, 0u64); no_cells];
for acc in per_worker {
stats.iter_mut().zip(acc).for_each(|(t, (u, l))| {
t.0 += u as usize;
t.1 += l;
});
}
Ok(stats)
}
fn write_mtx_file_cells(
task: &MtxFileTask,
cells_to_keep: &[usize],
gene_local_to_final: &[Option<usize>],
target_size: f32,
cell_offset: usize,
writer: &mut CellGeneSparseWriter,
verbose: bool,
) -> Result<MtxFileQcResult, ScDataErrors> {
if verbose {
println!(
" Writing {} ({} cells)...",
task.exp_id,
cells_to_keep.len().separate_with_underscores()
);
}
let (src, _, _) = open_task(task)?;
let field = src.field;
let cell_map = dense_index_map(cells_to_keep);
let gene_map = dense_gene_map_from_options(gene_local_to_final);
let mut cell_data: Vec<Vec<(u32, u32)>> = vec![Vec::new(); cells_to_keep.len()];
src.for_each_block_seq(|_, block| {
parse_block(&block, field, |row, col, value| {
let Some((cell, gene)) = cell_gene(row, col, task.cells_as_rows) else {
return;
};
let new_cell = cell_map.get(cell).copied().unwrap_or(INDEX_DROPPED);
let final_gene = gene_map.get(gene).copied().unwrap_or(INDEX_DROPPED);
if new_cell != INDEX_DROPPED && final_gene != INDEX_DROPPED {
cell_data[new_cell as usize].push((final_gene, value));
}
})
})?;
let (nnz, lib_size) = write_cell_rows(&mut cell_data, cell_offset, target_size, writer)?;
Ok(MtxFileQcResult {
exp_id: task.exp_id.clone(),
cells_to_keep: cells_to_keep.to_vec(),
lib_size,
nnz,
})
}
pub fn multi_mtx_to_file<P: AsRef<Path>>(
tasks: &[MtxFileTask],
bin_path: P,
universe_size: usize,
cell_qc: &MinCellQuality,
verbose: bool,
) -> Result<MultiMtxResult, ScDataErrors> {
let total_start = Instant::now();
if verbose {
println!("Scan 1/2: gene NNZ across {} mtx files...", tasks.len());
}
let completed = Arc::new(AtomicUsize::new(0));
let report_interval = (tasks.len() / 10).max(1);
let per_file_nnz: Vec<Vec<usize>> = tasks
.par_iter()
.map(|task| {
let res = scan_gene_nnz(task, universe_size);
if verbose {
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
if done.is_multiple_of(report_interval) || done == tasks.len() {
println!(" Scanned {}/{} files", done, tasks.len());
}
}
res
})
.collect::<Result<Vec<_>, _>>()?;
let mut global_gene_nnz = vec![0usize; universe_size];
for f in &per_file_nnz {
for (i, &c) in f.iter().enumerate() {
global_gene_nnz[i] += c;
}
}
let mut universe_to_final = vec![None; universe_size];
let mut final_idx = 0usize;
for (u, &nnz) in global_gene_nnz.iter().enumerate() {
if nnz >= cell_qc.min_cells {
universe_to_final[u] = Some(final_idx);
final_idx += 1;
}
}
let global_gene_indices: Vec<usize> = (0..universe_size)
.filter(|&i| universe_to_final[i].is_some())
.collect();
let total_genes = global_gene_indices.len();
if verbose {
println!(
" Genes passing global QC: {} / {}",
total_genes.separate_with_underscores(),
universe_size.separate_with_underscores()
);
}
let composed: Vec<Vec<Option<usize>>> = tasks
.iter()
.map(|t| {
t.gene_local_to_universe
.iter()
.map(|opt| opt.and_then(|u| universe_to_final[u]))
.collect()
})
.collect();
if verbose {
println!("Scan 2/2: cell stats with final gene set...");
}
let completed = Arc::new(AtomicUsize::new(0));
let per_file_cell_stats: Vec<Vec<(usize, u64)>> = tasks
.par_iter()
.zip(composed.par_iter())
.map(|(task, mapping)| {
let res = scan_cell_stats(task, mapping);
if verbose {
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
if done.is_multiple_of(report_interval) || done == tasks.len() {
println!(" Scanned {}/{} files", done, tasks.len());
}
}
res
})
.collect::<Result<Vec<_>, _>>()?;
let per_file_cells: Vec<Vec<usize>> = per_file_cell_stats
.iter()
.map(|stats| {
stats
.iter()
.enumerate()
.filter(|(_, (u, l))| {
*u >= cell_qc.min_unique_genes && *l as f32 >= cell_qc.min_lib_size as f32
})
.map(|(i, _)| i)
.collect()
})
.collect();
let total_cells: usize = per_file_cells.iter().map(|v| v.len()).sum();
if verbose {
for (i, t) in tasks.iter().enumerate() {
println!(
" {}: {} cells passing QC",
t.exp_id,
per_file_cells[i].len().separate_with_underscores()
);
}
println!(" Total cells: {}", total_cells.separate_with_underscores());
}
if verbose {
println!("Writing cells to binary...");
}
let mut writer = CellGeneSparseWriter::new(
&bin_path,
true,
total_cells,
total_genes,
cell_qc.target_size,
)?;
let mut cell_offset = 0usize;
let mut per_file_results = Vec::with_capacity(tasks.len());
for (idx, task) in tasks.iter().enumerate() {
let res = write_mtx_file_cells(
task,
&per_file_cells[idx],
&composed[idx],
cell_qc.target_size,
cell_offset,
&mut writer,
verbose,
)?;
cell_offset += per_file_cells[idx].len();
per_file_results.push(res);
}
writer.finalise()?;
if verbose {
println!("Multi-mtx loading complete: {:.2?}", total_start.elapsed());
}
Ok(MultiMtxResult {
global_gene_indices,
total_cells,
total_genes,
per_file: per_file_results,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
struct TempPath(PathBuf);
impl Drop for TempPath {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
impl TempPath {
fn new(name: &str, ext: &str) -> Self {
Self(std::env::temp_dir().join(format!("bixverse_mtx_multi_{name}.{ext}")))
}
fn string(&self) -> String {
self.0.to_str().expect("utf-8 temp path").to_string()
}
}
const INTEGER_CELLS_AS_ROWS: &str = "%%MatrixMarket matrix coordinate integer general\n\
3 4 5\n1 1 5\n1 3 7\n2 2 3\n3 1 2\n3 4 9\n";
const REAL_GENES_AS_ROWS: &str = "%%MatrixMarket matrix coordinate real general\n\
4 2 3\n1 1 4.0\n2 1 1.1e+01\n4 2 6\n";
const PATTERN_CELLS_AS_ROWS: &str = "%%MatrixMarket matrix coordinate pattern general\n\
1 4 2\n1 2\n1 4\n";
fn task(name: &str, path: &TempPath, cells_as_rows: bool) -> MtxFileTask {
MtxFileTask {
exp_id: name.to_string(),
mtx_path: path.string(),
gene_local_to_universe: (0..4).map(Some).collect(),
cells_as_rows,
}
}
fn keep_all() -> MinCellQuality {
MinCellQuality {
min_unique_genes: 1,
min_lib_size: 1,
min_cells: 1,
target_size: 1e4,
}
}
#[test]
fn multi_mtx_reads_every_field_type_and_gzip() {
let integer = TempPath::new("integer", "mtx");
std::fs::write(&integer.0, INTEGER_CELLS_AS_ROWS).expect("written");
let real = TempPath::new("real", "mtx.gz");
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
enc.write_all(REAL_GENES_AS_ROWS.as_bytes())
.expect("gzipped");
std::fs::write(&real.0, enc.finish().expect("gzipped")).expect("written");
let pattern = TempPath::new("pattern", "mtx");
std::fs::write(&pattern.0, PATTERN_CELLS_AS_ROWS).expect("written");
let bin = TempPath::new("store", "bin");
let tasks = [
task("integer", &integer, true),
task("real", &real, false),
task("pattern", &pattern, true),
];
let res = multi_mtx_to_file(&tasks, &bin.0, 4, &keep_all(), false).expect("loaded");
assert_eq!(res.total_cells, 6);
assert_eq!(res.per_file[1].lib_size, vec![15, 6]);
assert_eq!(res.per_file[2].lib_size, vec![2]);
let reader = ParallelSparseReader::new(&bin.string()).expect("reader opens");
let cells: Vec<(Vec<u32>, Vec<u32>)> = reader
.read_cells_parallel(&[3, 4, 5])
.expect("cells read back")
.into_iter()
.map(|c| (c.indices.clone(), c.data_raw.iter().collect()))
.collect();
assert_eq!(
cells,
vec![
(vec![0, 1], vec![4, 11]),
(vec![3], vec![6]),
(vec![1, 3], vec![1, 1]),
]
);
}
#[test]
fn multi_mtx_rejects_a_fractional_real() {
let real = TempPath::new("fraction", "mtx");
std::fs::write(
&real.0,
"%%MatrixMarket matrix coordinate real general\n1 4 1\n1 1 2.5\n",
)
.expect("written");
let bin = TempPath::new("fraction_store", "bin");
assert!(matches!(
multi_mtx_to_file(
&[task("fraction", &real, true)],
&bin.0,
4,
&keep_all(),
false
),
Err(ScDataErrors::MtxNotACount { .. })
));
}
}