use flate2::read::MultiGzDecoder;
use rayon::prelude::*;
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::sync_channel;
use std::time::Instant;
use thousands::Separable;
use crate::data_io::{
CellGeneSparseWriter, CellOnFileQuality, INDEX_DROPPED, compress_cell_row, dense_index_map,
};
use crate::prelude::*;
const BUCKET_RECORD_LEN: usize = 12;
const MTX_BLOCK_BYTES: usize = 8 * 1024 * 1024;
const GZ_BLOCKS_PER_THREAD: usize = 2;
const BUCKET_FLUSH_BYTES: usize = 64 * 1024;
const MAX_BUCKETS: usize = 128;
const CELLS_PER_BUCKET: usize = 5_000;
const REAL_INTEGRAL_TOL: f64 = 1e-6;
const GZ_MAGIC: [u8; 2] = [0x1f, 0x8b];
static SPILL_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MtxField {
Integer,
Real,
Pattern,
}
#[derive(Debug, Clone)]
pub struct MtxHeader {
pub total_cells: usize,
pub total_genes: usize,
pub total_entries: usize,
pub field: MtxField,
}
fn parse_banner(line: &[u8]) -> Result<MtxField, ScDataErrors> {
let text = String::from_utf8_lossy(line).to_ascii_lowercase();
let tokens: Vec<&str> = text.split_whitespace().collect();
let [_, object, format, field, symmetry] = tokens[..] else {
return Err(ScDataErrors::MtxHeaderInvalid(
"the banner needs object, format, field and symmetry",
));
};
if object != "matrix" {
return Err(ScDataErrors::MtxUnsupported(format!("object '{object}'")));
}
if format != "coordinate" {
return Err(ScDataErrors::MtxUnsupported(format!(
"format '{format}', only sparse 'coordinate' is read"
)));
}
if symmetry != "general" {
return Err(ScDataErrors::MtxUnsupported(format!(
"symmetry '{symmetry}', only 'general' is read"
)));
}
match field {
"integer" => Ok(MtxField::Integer),
"real" | "double" => Ok(MtxField::Real),
"pattern" => Ok(MtxField::Pattern),
other => Err(ScDataErrors::MtxUnsupported(format!(
"field '{other}', counts must be integer, real or pattern"
))),
}
}
fn parse_preamble(
reader: &mut impl BufRead,
cells_as_rows: bool,
) -> Result<(MtxField, [usize; 3], u64), ScDataErrors> {
let mut line = Vec::new();
let mut consumed = 0u64;
let mut field = MtxField::Integer;
let mut first = true;
loop {
line.clear();
let n = reader.read_until(b'\n', &mut line)?;
if n == 0 {
return Err(ScDataErrors::MtxHeaderInvalid("no shape line"));
}
consumed += n as u64;
if first && line.starts_with(b"%%") {
field = parse_banner(&line)?;
}
first = false;
if line.first() != Some(&b'%') {
break;
}
}
let text = std::str::from_utf8(&line)
.map_err(|_| ScDataErrors::MtxHeaderInvalid("the shape line is not text"))?;
let parts: Vec<&str> = text.split_whitespace().collect();
if parts.len() != 3 {
return Err(ScDataErrors::MtxHeaderInvalid(
"expected three whitespace-separated fields on the shape line",
));
}
let names = if cells_as_rows {
["cell count", "gene count", "entry count"]
} else {
["gene count", "cell count", "entry count"]
};
let mut shape = [0usize; 3];
for (k, (s, field)) in parts.iter().zip(names).enumerate() {
shape[k] = s
.parse()
.map_err(|_| ScDataErrors::MtxParseError { field })?;
}
Ok((field, shape, consumed))
}
pub(crate) struct MtxSource {
path: PathBuf,
gz: bool,
pub(crate) field: MtxField,
pub(crate) shape: [usize; 3],
data_start: u64,
block_bytes: usize,
}
impl MtxSource {
pub(crate) fn open(path: &Path, cells_as_rows: bool) -> Result<Self, ScDataErrors> {
let mut magic = [0u8; 2];
let mut file = File::open(path)?;
let gz = file.read(&mut magic)? == 2 && magic == GZ_MAGIC;
file.rewind()?;
let mut source = Self {
path: path.to_path_buf(),
gz,
field: MtxField::Integer,
shape: [0; 3],
data_start: 0,
block_bytes: MTX_BLOCK_BYTES,
};
let mut reader = BufReader::new(source.stream()?);
let (field, shape, data_start) = parse_preamble(&mut reader, cells_as_rows)?;
source.field = field;
source.shape = shape;
source.data_start = data_start;
Ok(source)
}
fn stream(&self) -> Result<Box<dyn Read + Send>, ScDataErrors> {
let file = File::open(&self.path)?;
Ok(if self.gz {
Box::new(MultiGzDecoder::new(file))
} else {
Box::new(file)
})
}
pub(crate) fn for_each_block_seq(
&self,
mut f: impl FnMut(usize, Vec<u8>) -> Result<(), ScDataErrors>,
) -> Result<(), ScDataErrors> {
let mut reader = self.stream()?;
std::io::copy(
&mut (&mut reader).take(self.data_start),
&mut std::io::sink(),
)?;
let mut carry = Vec::new();
let mut seq = 0;
loop {
let mut block = std::mem::take(&mut carry);
block.reserve(self.block_bytes);
let n = (&mut reader)
.take(self.block_bytes as u64)
.read_to_end(&mut block)?;
if n == 0 {
if !block.is_empty() {
f(seq, block)?;
}
return Ok(());
}
if let Some(p) = block.iter().rposition(|&b| b == b'\n') {
carry = block[p + 1..].to_vec();
block.truncate(p + 1);
f(seq, block)?;
seq += 1;
} else {
carry = block;
}
}
}
fn plain_ranges(&self) -> Result<Vec<(u64, u64)>, ScDataErrors> {
let mut file = File::open(&self.path)?;
let len = file.metadata()?.len();
let mut ranges = Vec::new();
let mut a = self.data_start;
let mut probe = vec![0u8; 64 * 1024];
while a < len {
let mut b = a + self.block_bytes as u64;
if b >= len {
ranges.push((a, len));
break;
}
file.seek(SeekFrom::Start(b))?;
loop {
let k = file.read(&mut probe)?;
if k == 0 {
b = len;
break;
}
if let Some(p) = probe[..k].iter().position(|&c| c == b'\n') {
b += p as u64 + 1;
break;
}
b += k as u64;
}
ranges.push((a, b));
a = b;
}
Ok(ranges)
}
pub(crate) fn par_fold_blocks<A, I, F>(&self, init: I, f: F) -> Result<Vec<A>, ScDataErrors>
where
A: Send,
I: Fn() -> A + Sync,
F: Fn(&mut A, usize, &[u8]) -> Result<(), ScDataErrors> + Sync,
{
let slots: Vec<Mutex<Option<A>>> = (0..rayon::current_num_threads())
.map(|_| Mutex::new(None))
.collect();
let fold = |seq: usize, bytes: &[u8]| -> Result<(), ScDataErrors> {
let k = rayon::current_thread_index().unwrap_or(0) % slots.len();
let mut slot = slots[k]
.lock()
.expect("an accumulator lock is never held across a panic");
f(slot.get_or_insert_with(&init), seq, bytes)
};
if self.gz {
let (tx, rx) = sync_channel::<(usize, Vec<u8>)>(slots.len() * GZ_BLOCKS_PER_THREAD);
std::thread::scope(|s| {
let reader = s.spawn(move || {
self.for_each_block_seq(|seq, block| {
tx.send((seq, block)).map_err(|_| {
ScDataErrors::BinaryIo(std::io::Error::other("mtx parsers stopped"))
})
})
});
let parsed = rx
.into_iter()
.par_bridge()
.try_for_each(|(seq, block)| fold(seq, &block));
let read = reader.join().expect("the gzip reader thread panicked");
parsed.and(read)
})?;
} else {
self.plain_ranges()?
.par_iter()
.enumerate()
.try_for_each_init(
Vec::new,
|buf, (seq, &(a, b))| -> Result<(), ScDataErrors> {
let mut file = File::open(&self.path)?;
file.seek(SeekFrom::Start(a))?;
buf.resize((b - a) as usize, 0);
file.read_exact(buf)?;
fold(seq, buf)
},
)?;
}
Ok(slots
.into_iter()
.filter_map(|m| {
m.into_inner()
.expect("an accumulator lock is never held across a panic")
})
.collect())
}
}
#[inline]
fn parse_index(line: &[u8], i: &mut usize) -> Option<u32> {
let start = *i;
let mut v = 0u32;
while *i < line.len() && line[*i].is_ascii_digit() {
v = v.wrapping_mul(10).wrapping_add((line[*i] - b'0') as u32);
*i += 1;
}
(*i > start).then_some(v)
}
#[inline]
fn skip_blank(line: &[u8], i: &mut usize) {
while *i < line.len() && (line[*i] == b' ' || line[*i] == b'\t') {
*i += 1;
}
}
#[inline]
fn parse_count(token: &[u8]) -> Result<u32, ScDataErrors> {
let not_a_count = || ScDataErrors::MtxNotACount {
value: String::from_utf8_lossy(token).into_owned(),
};
if !token.is_empty() && token.iter().all(u8::is_ascii_digit) {
let mut v = 0u64;
for &b in token {
v = v * 10 + (b - b'0') as u64;
if v > u32::MAX as u64 {
return Err(not_a_count());
}
}
return Ok(v as u32);
}
let v: f64 = std::str::from_utf8(token)
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(not_a_count)?;
let r = v.round();
if !v.is_finite()
|| r < 0.0
|| r > u32::MAX as f64
|| (v - r).abs() > REAL_INTEGRAL_TOL * r.abs().max(1.0)
{
return Err(not_a_count());
}
Ok(r as u32)
}
#[inline]
fn parse_entry(line: &[u8], field: MtxField) -> Result<Option<(u32, u32, u32)>, ScDataErrors> {
let mut i = 0;
let Some(row) = parse_index(line, &mut i) else {
return Ok(None);
};
skip_blank(line, &mut i);
let Some(col) = parse_index(line, &mut i) else {
return Ok(None);
};
if field == MtxField::Pattern {
return Ok(Some((row, col, 1)));
}
skip_blank(line, &mut i);
let start = i;
while i < line.len() && !matches!(line[i], b' ' | b'\t') {
i += 1;
}
if i == start {
return Ok(None);
}
Ok(Some((row, col, parse_count(&line[start..i])?)))
}
pub(crate) fn parse_block<F: FnMut(u32, u32, u32)>(
bytes: &[u8],
field: MtxField,
mut f: F,
) -> Result<(), ScDataErrors> {
for line in bytes.split(|&b| b == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if let Some((row, col, value)) = parse_entry(line, field)? {
f(row, col, value);
}
}
Ok(())
}
struct TempFileGuard(Vec<PathBuf>);
impl Drop for TempFileGuard {
fn drop(&mut self) {
for p in &self.0 {
let _ = std::fs::remove_file(p);
}
}
}
struct BucketFile {
writer: BufWriter<File>,
len: u64,
segments: Vec<(usize, u64, u64)>,
}
struct BlockOrder {
seq: usize,
first: u32,
last: u32,
sorted: bool,
}
struct TextPassAcc {
gene_nnz: Vec<u32>,
rare: Vec<Vec<(u32, u32)>>,
cell_stats: Vec<(u32, u32)>,
bufs: Vec<Vec<u8>>,
blocks: Vec<BlockOrder>,
}
struct Spill {
files: TempFileGuard,
cells_per_bucket: usize,
segments: Vec<Vec<(usize, u64, u64)>>,
cell_major: bool,
quality: CellOnFileQuality,
}
#[derive(Debug, Clone)]
pub struct MtxFinalData {
pub cell_qc: CellQuality,
pub no_genes: usize,
pub no_cells: usize,
}
pub struct MtxReader {
source: MtxSource,
header: MtxHeader,
qc_params: MinCellQuality,
cells_as_rows: bool,
temp_dir: PathBuf,
spill: Option<Spill>,
}
impl MtxReader {
pub fn new<P: AsRef<Path>>(
path: P,
qc_params: MinCellQuality,
cells_as_rows: bool,
) -> Result<Self, ScDataErrors> {
let source = MtxSource::open(path.as_ref(), cells_as_rows)?;
let [rows, cols, entries] = source.shape;
let (total_cells, total_genes) = if cells_as_rows {
(rows, cols)
} else {
(cols, rows)
};
let header = MtxHeader {
total_cells,
total_genes,
total_entries: entries,
field: source.field,
};
Ok(Self {
source,
header,
qc_params,
cells_as_rows,
temp_dir: std::env::temp_dir(),
spill: None,
})
}
pub fn with_temp_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
self.temp_dir = dir.as_ref().to_path_buf();
self
}
pub fn parse_mtx_quality(&mut self, verbose: bool) -> Result<CellOnFileQuality, ScDataErrors> {
if self.spill.is_none() {
self.spill = Some(self.text_pass(verbose)?);
}
Ok(self
.spill
.as_ref()
.expect("spill set above")
.quality
.clone())
}
fn text_pass(&self, verbose: bool) -> Result<Spill, ScDataErrors> {
let start = Instant::now();
let n_cells = self.header.total_cells;
let n_genes = self.header.total_genes;
let min_cells = self.qc_params.min_cells;
let cells_as_rows = self.cells_as_rows;
let field = self.source.field;
let n_buckets = n_cells.div_ceil(CELLS_PER_BUCKET).clamp(1, MAX_BUCKETS);
let cells_per_bucket = n_cells.div_ceil(n_buckets).max(1);
let tag = SPILL_COUNTER.fetch_add(1, Ordering::Relaxed);
let paths: Vec<PathBuf> = (0..n_buckets)
.map(|i| {
self.temp_dir.join(format!(
".bixverse_mtx_{}_{tag}.bucket_{i:04}.tmp",
std::process::id()
))
})
.collect();
let files = TempFileGuard(paths.clone());
let buckets: Vec<Mutex<BucketFile>> = paths
.iter()
.map(|p| {
File::create(p).map(|f| {
Mutex::new(BucketFile {
writer: BufWriter::with_capacity(256 * 1024, f),
len: 0,
segments: Vec::new(),
})
})
})
.collect::<std::io::Result<_>>()?;
if verbose {
let kind = match field {
MtxField::Integer => "integer",
MtxField::Real => "real",
MtxField::Pattern => "pattern",
};
println!(
"Text pass ({}{kind}): {n_buckets} buckets, ~{cells_per_bucket} cells/bucket",
if self.source.gz { "gzip, " } else { "" },
);
}
let flush = |bucket: usize, seq: usize, buf: &mut Vec<u8>| -> Result<(), ScDataErrors> {
let mut b = buckets[bucket]
.lock()
.expect("bucket writer lock is never held across a panic");
let len = buf.len() as u64;
let offset = b.len;
b.writer.write_all(buf)?;
b.segments.push((seq, offset, len));
b.len += len;
buf.clear();
Ok(())
};
let accs = self.source.par_fold_blocks(
|| TextPassAcc {
gene_nnz: vec![0; n_genes],
rare: vec![Vec::new(); n_genes],
cell_stats: vec![(0, 0); n_cells],
bufs: vec![Vec::new(); n_buckets],
blocks: Vec::new(),
},
|acc, seq, bytes| {
let mut order: Option<BlockOrder> = None;
let mut flush_err = Ok(());
parse_block(bytes, field, |row, col, value| {
let (cell, gene) = if cells_as_rows {
(row, col)
} else {
(col, row)
};
let Some(g) = (gene as usize).checked_sub(1).filter(|&g| g < n_genes) else {
return;
};
acc.gene_nnz[g] += 1;
let Some(c) = (cell as usize).checked_sub(1).filter(|&c| c < n_cells) else {
return;
};
let seen = acc.gene_nnz[g] as usize;
if seen < min_cells {
acc.rare[g].push((c as u32, value));
} else if seen == min_cells {
acc.rare[g] = Vec::new();
}
let stats = &mut acc.cell_stats[c];
stats.0 += 1;
stats.1 += value;
let c32 = c as u32;
match &mut order {
None => {
order = Some(BlockOrder {
seq,
first: c32,
last: c32,
sorted: true,
})
}
Some(o) => {
o.sorted &= c32 >= o.last;
o.last = c32;
}
}
let bucket = c / cells_per_bucket;
let buf = &mut acc.bufs[bucket];
buf.extend_from_slice(&c32.to_le_bytes());
buf.extend_from_slice(&(g as u32).to_le_bytes());
buf.extend_from_slice(&value.to_le_bytes());
if buf.len() >= BUCKET_FLUSH_BYTES && flush_err.is_ok() {
flush_err = flush(bucket, seq, buf);
}
})?;
flush_err?;
for (bucket, buf) in acc.bufs.iter_mut().enumerate() {
if !buf.is_empty() {
flush(bucket, seq, buf)?;
}
}
acc.blocks.extend(order);
Ok(())
},
)?;
let mut segments = Vec::with_capacity(n_buckets);
for bucket in buckets {
let mut b = bucket
.into_inner()
.expect("bucket writer lock is never held across a panic");
b.writer.flush()?;
segments.push(b.segments);
}
let mut gene_nnz = vec![0usize; n_genes];
let mut cell_stats = vec![(0u32, 0u32); n_cells];
let mut blocks = Vec::new();
for acc in &accs {
gene_nnz
.iter_mut()
.zip(&acc.gene_nnz)
.for_each(|(t, &n)| *t += n as usize);
cell_stats
.par_iter_mut()
.zip(&acc.cell_stats)
.for_each(|(t, s)| {
t.0 += s.0;
t.1 += s.1;
});
}
let genes_to_keep: Vec<usize> =
(0..n_genes).filter(|&g| gene_nnz[g] >= min_cells).collect();
for mut acc in accs {
for (g, rare) in acc.rare.iter().enumerate() {
if gene_nnz[g] < min_cells {
for &(c, v) in rare {
let s = &mut cell_stats[c as usize];
s.0 -= 1;
s.1 -= v;
}
}
}
blocks.append(&mut acc.blocks);
}
blocks.sort_unstable_by_key(|b| b.seq);
let cell_major =
blocks.iter().all(|b| b.sorted) && blocks.windows(2).all(|w| w[0].last <= w[1].first);
let cells_to_keep: Vec<usize> = (0..n_cells)
.filter(|&c| {
cell_stats[c].0 as usize >= self.qc_params.min_unique_genes
&& cell_stats[c].1 as f32 >= self.qc_params.min_lib_size as f32
})
.collect();
let mut quality = CellOnFileQuality::new(cells_to_keep, genes_to_keep);
quality.generate_maps_sets();
if verbose {
println!(
"Text pass done: {:.2?} ({} order)",
start.elapsed(),
if cell_major { "cell-major" } else { "unsorted" }
);
println!(
"Genes passing QC: {} / {}",
quality.genes_to_keep.len().separate_with_underscores(),
n_genes.separate_with_underscores()
);
println!(
"Cells passing QC: {} / {}",
quality.cells_to_keep.len().separate_with_underscores(),
n_cells.separate_with_underscores()
);
}
Ok(Spill {
files,
cells_per_bucket,
segments,
cell_major,
quality,
})
}
pub fn process_mtx_and_write_bin(
self,
bin_path: &str,
quality: &CellOnFileQuality,
verbose: bool,
) -> Result<MtxFinalData, ScDataErrors> {
self.process_mtx_and_write_bin_streaming(bin_path, quality, verbose)
}
pub fn process_mtx_and_write_bin_streaming(
mut self,
bin_path: &str,
quality: &CellOnFileQuality,
verbose: bool,
) -> Result<MtxFinalData, ScDataErrors> {
let kept = &quality.cells_to_keep;
if kept.windows(2).any(|w| w[0] >= w[1]) {
return Err(ScDataErrors::InvalidArgument(
"cells_to_keep must be strictly ascending".into(),
));
}
let n_kept_cells = kept.len();
let n_kept_genes = quality.genes_to_keep.len();
let target_size = self.qc_params.target_size;
if n_kept_cells == 0 {
let writer = CellGeneSparseWriter::new(bin_path, true, 0, n_kept_genes, target_size)?;
writer.finalise()?;
return Ok(MtxFinalData {
cell_qc: CellQuality {
cell_indices: vec![],
gene_indices: quality.genes_to_keep.to_vec(),
lib_size: vec![],
nnz: vec![],
},
no_genes: n_kept_genes,
no_cells: 0,
});
}
let spill = match self.spill.take() {
Some(s) => s,
None => self.text_pass(verbose)?,
};
let start = Instant::now();
if verbose {
println!(
"Writing pass: {} buckets{}",
spill.files.0.len(),
if spill.cell_major { ", no sort" } else { "" }
);
}
let cell_map = dense_index_map(kept);
let gene_map = dense_index_map(&quality.genes_to_keep);
let lookup = |map: &[u32], i: u32| -> Option<u32> {
map.get(i as usize).copied().filter(|&m| m != INDEX_DROPPED)
};
let mut writer =
CellGeneSparseWriter::new(bin_path, true, n_kept_cells, n_kept_genes, target_size)?;
let mut lib_size = Vec::with_capacity(n_kept_cells);
let mut nnz = Vec::with_capacity(n_kept_cells);
let n_buckets = spill.files.0.len();
for (bucket_idx, temp_path) in spill.files.0.iter().enumerate() {
let raw = std::fs::read(temp_path)?;
let bytes = if spill.cell_major {
let mut segs = spill.segments[bucket_idx].clone();
segs.sort_unstable_by_key(|&(seq, off, _)| (seq, off));
let mut ordered = Vec::with_capacity(raw.len());
for (_, off, len) in segs {
ordered.extend_from_slice(&raw[off as usize..(off + len) as usize]);
}
ordered
} else {
raw
};
let mut entries: Vec<(u32, u32, u32)> = bytes
.par_chunks_exact(BUCKET_RECORD_LEN)
.filter_map(|r| {
let word =
|k: usize| u32::from_le_bytes(r[k..k + 4].try_into().expect("4 bytes"));
Some((
lookup(&cell_map, word(0))?,
lookup(&gene_map, word(4))?,
word(8),
))
})
.collect();
drop(bytes);
if !spill.cell_major {
entries.par_sort_unstable();
}
let first_cell = bucket_idx * spill.cells_per_bucket;
let end_cell = first_cell + spill.cells_per_bucket;
let k0 = kept.partition_point(|&c| c < first_cell);
let k1 = kept.partition_point(|&c| c < end_cell);
let run_starts: Vec<usize> = (k0..=k1)
.map(|k| entries.partition_point(|e| (e.0 as usize) < k))
.collect();
let built = (k0..k1)
.into_par_iter()
.map(|k| {
let j = k - k0;
let mut row: Vec<(u32, u32)> = entries[run_starts[j]..run_starts[j + 1]]
.iter()
.map(|&(_, g, v)| (g, v))
.collect();
compress_cell_row(&mut row, k, target_size, writer.format())
})
.collect::<Result<Vec<_>, ScDataErrors>>()?;
let mut payloads = Vec::with_capacity(built.len());
for (nnz_i, payload) in built {
nnz.push(nnz_i);
lib_size.push(payload.library_size);
payloads.push(payload);
}
writer.write_compressed_cell_chunks(&payloads)?;
let _ = std::fs::remove_file(temp_path);
if verbose
&& ((bucket_idx + 1) % (n_buckets / 10).max(1) == 0 || bucket_idx + 1 == n_buckets)
{
println!(
" Wrote bucket {}/{} ({}%)",
bucket_idx + 1,
n_buckets,
(bucket_idx + 1) * 100 / n_buckets
);
}
}
writer.finalise()?;
if verbose {
println!("Writing pass done: {:.2?}", start.elapsed());
}
Ok(MtxFinalData {
cell_qc: CellQuality {
cell_indices: kept.to_vec(),
gene_indices: quality.genes_to_keep.to_vec(),
lib_size,
nnz,
},
no_genes: n_kept_genes,
no_cells: n_kept_cells,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_io::write_cell_rows;
const CELLS_AS_ROWS_MTX: &str = "%%MatrixMarket matrix coordinate integer general\n\
%\n\
3 4 5\n\
1 1 5\n\
1 3 7\n\
2 2 3\n\
3 1 2\n\
3 4 9\n";
const GENES_AS_ROWS_MTX: &str = "%%MatrixMarket matrix coordinate integer general\n\
%\n\
4 3 5\n\
1 1 5\n\
3 1 7\n\
2 2 3\n\
1 3 2\n\
4 3 9\n";
fn expected_cells() -> Vec<(Vec<u32>, Vec<u32>)> {
vec![
(vec![0, 2], vec![5, 7]),
(vec![1], vec![3]),
(vec![0, 3], vec![2, 9]),
]
}
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_io_{name}.{ext}")))
}
fn path(&self) -> &str {
self.0.to_str().expect("temp path is valid UTF-8")
}
}
fn write_mtx(name: &str, body: &str) -> TempPath {
let temp = TempPath::new(name, "mtx");
std::fs::write(&temp.0, body).expect("mtx file written");
temp
}
fn keep_all_qc() -> MinCellQuality {
MinCellQuality {
min_unique_genes: 1,
min_lib_size: 1,
min_cells: 1,
target_size: 1e4,
}
}
fn read_back(bin_path: &str, n_cells: usize) -> Vec<(Vec<u32>, Vec<u32>)> {
let reader = ParallelSparseReader::new(bin_path).expect("reader opens");
let indices: Vec<usize> = (0..n_cells).collect();
reader
.read_cells_parallel(&indices)
.expect("cells read back")
.into_iter()
.map(|c| (c.indices.clone(), c.data_raw.iter().collect()))
.collect()
}
fn int(line: &[u8]) -> Option<(u32, u32, u32)> {
parse_entry(line, MtxField::Integer).expect("no value error")
}
#[test]
fn test_parse_mtx_line_keeps_full_u32_counts() {
assert_eq!(int(b"1 2 3"), Some((1, 2, 3)));
assert_eq!(int(b"7\t9\t65535"), Some((7, 9, 65_535)));
assert_eq!(int(b"4 5 70000"), Some((4, 5, 70_000)));
assert_eq!(int(b"1 2 4294967295"), Some((1, 2, u32::MAX)));
}
#[test]
fn test_parse_mtx_line_rejects_malformed_input() {
assert_eq!(int(b""), None);
assert_eq!(int(b"abc"), None);
assert_eq!(int(b"1"), None);
assert_eq!(int(b"1 2"), None);
}
#[test]
fn mtx_header_shape_line_is_read_per_orientation() {
let mtx = write_mtx("header_shape", CELLS_AS_ROWS_MTX);
let cells_as_rows = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
assert_eq!(cells_as_rows.header.total_cells, 3);
assert_eq!(cells_as_rows.header.total_genes, 4);
assert_eq!(cells_as_rows.header.total_entries, 5);
let genes_as_rows = MtxReader::new(mtx.path(), keep_all_qc(), false).expect("reader opens");
assert_eq!(genes_as_rows.header.total_cells, 4);
assert_eq!(genes_as_rows.header.total_genes, 3);
assert_eq!(genes_as_rows.header.total_entries, 5);
}
#[test]
fn mtx_header_with_wrong_field_count_is_rejected() {
let mtx = write_mtx(
"header_two_fields",
"%%MatrixMarket matrix coordinate integer general\n3 4\n1 1 5\n",
);
assert!(matches!(
MtxReader::new(mtx.path(), keep_all_qc(), true),
Err(ScDataErrors::MtxHeaderInvalid(_))
));
}
#[test]
fn mtx_header_parse_error_names_the_failing_field() {
let bad_second = write_mtx(
"header_bad_second",
"%%MatrixMarket matrix coordinate integer general\n3 x 5\n",
);
assert!(matches!(
MtxReader::new(bad_second.path(), keep_all_qc(), true),
Err(ScDataErrors::MtxParseError {
field: "gene count"
})
));
assert!(matches!(
MtxReader::new(bad_second.path(), keep_all_qc(), false),
Err(ScDataErrors::MtxParseError {
field: "cell count"
})
));
let bad_third = write_mtx(
"header_bad_third",
"%%MatrixMarket matrix coordinate integer general\n3 4 x\n",
);
assert!(matches!(
MtxReader::new(bad_third.path(), keep_all_qc(), true),
Err(ScDataErrors::MtxParseError {
field: "entry count"
})
));
}
#[test]
fn mtx_reader_reports_a_missing_file() {
let missing = std::env::temp_dir().join("bixverse_mtx_io_does_not_exist.mtx");
assert!(matches!(
MtxReader::new(&missing, keep_all_qc(), true),
Err(ScDataErrors::BinaryIo(_))
));
}
#[test]
fn parse_mtx_quality_filters_genes_then_cells() {
let mtx = write_mtx("quality_filters", CELLS_AS_ROWS_MTX);
let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
assert_eq!(quality.cells_to_keep, vec![0, 1, 2]);
assert_eq!(quality.genes_to_keep, vec![0, 1, 2, 3]);
let qc = MinCellQuality {
min_unique_genes: 1,
min_lib_size: 1,
min_cells: 2,
target_size: 1e4,
};
let mut reader = MtxReader::new(mtx.path(), qc, true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
assert_eq!(quality.genes_to_keep, vec![0]);
assert_eq!(quality.cells_to_keep, vec![0, 2]);
assert_eq!(quality.cell_old_to_new[&0], 0);
assert_eq!(quality.cell_old_to_new[&2], 1);
assert_eq!(quality.gene_old_to_new[&0], 0);
}
#[test]
fn mtx_round_trip_preserves_counts_and_indices() {
let mtx = write_mtx("round_trip", CELLS_AS_ROWS_MTX);
let bin = TempPath::new("round_trip", "bin");
let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
let final_data = reader
.process_mtx_and_write_bin(bin.path(), &quality, false)
.expect("conversion");
assert_eq!(final_data.no_cells, 3);
assert_eq!(final_data.no_genes, 4);
assert_eq!(final_data.cell_qc.lib_size, vec![12, 3, 11]);
assert_eq!(final_data.cell_qc.nnz, vec![2, 1, 2]);
assert_eq!(read_back(bin.path(), 3), expected_cells());
}
#[test]
fn mtx_indices_shift_from_base_one_to_base_zero() {
let mtx = write_mtx(
"base_one",
"%%MatrixMarket matrix coordinate integer general\n2 3 2\n1 1 42\n2 3 7\n",
);
let bin = TempPath::new("base_one", "bin");
let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
reader
.process_mtx_and_write_bin(bin.path(), &quality, false)
.expect("conversion");
assert_eq!(quality.genes_to_keep, vec![0, 2]);
assert_eq!(
read_back(bin.path(), 2),
vec![(vec![0], vec![42]), (vec![1], vec![7])]
);
}
#[test]
fn mtx_gene_as_rows_matches_cell_as_rows() {
let mtx = write_mtx("orientation", GENES_AS_ROWS_MTX);
let bin = TempPath::new("orientation", "bin");
let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), false).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
let final_data = reader
.process_mtx_and_write_bin(bin.path(), &quality, false)
.expect("conversion");
assert_eq!(final_data.no_cells, 3);
assert_eq!(final_data.no_genes, 4);
assert_eq!(final_data.cell_qc.lib_size, vec![12, 3, 11]);
assert_eq!(read_back(bin.path(), 3), expected_cells());
}
#[test]
fn mtx_streaming_matches_non_streaming() {
let mtx = write_mtx("streaming", CELLS_AS_ROWS_MTX);
let bin_plain = TempPath::new("streaming_plain", "bin");
let bin_stream = TempPath::new("streaming_stream", "bin");
let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
let plain = reader
.process_mtx_and_write_bin(bin_plain.path(), &quality, false)
.expect("conversion");
let reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let streamed = reader
.process_mtx_and_write_bin_streaming(bin_stream.path(), &quality, false)
.expect("streaming conversion");
assert_eq!(streamed.no_cells, plain.no_cells);
assert_eq!(streamed.no_genes, plain.no_genes);
assert_eq!(streamed.cell_qc.lib_size, plain.cell_qc.lib_size);
assert_eq!(streamed.cell_qc.nnz, plain.cell_qc.nnz);
assert_eq!(
read_back(bin_stream.path(), 3),
read_back(bin_plain.path(), 3)
);
}
#[derive(Clone, Copy, Debug)]
enum EntryOrder {
CellMajor,
GeneMajor,
Shuffled,
}
fn random_counts(n_cells: usize, n_genes: usize, seed: u64) -> Vec<(u32, u32, u32)> {
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(seed);
let mut out = Vec::new();
for c in 0..n_cells {
let k = rng.random_range(0..40usize);
let mut genes: Vec<u32> = (0..k)
.map(|_| {
let u: f64 = rng.random();
((u * u * u) * n_genes as f64) as u32
})
.collect();
genes.sort_unstable();
genes.dedup();
for g in genes {
let v = if rng.random_range(0..200) == 0 {
70_000
} else {
rng.random_range(1..30)
};
out.push((c as u32, g, v));
}
}
out
}
fn render_mtx(
entries: &[(u32, u32, u32)],
n_cells: usize,
n_genes: usize,
cells_as_rows: bool,
order: EntryOrder,
) -> String {
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
let mut e = entries.to_vec();
match order {
EntryOrder::CellMajor => {}
EntryOrder::GeneMajor => e.sort_by_key(|&(c, g, _)| (g, c)),
EntryOrder::Shuffled => e.shuffle(&mut StdRng::seed_from_u64(7)),
}
let (rows, cols) = if cells_as_rows {
(n_cells, n_genes)
} else {
(n_genes, n_cells)
};
let mut s = format!(
"%%MatrixMarket matrix coordinate integer general\n%\n{rows} {cols} {}\n",
e.len()
);
for (c, g, v) in e {
let (r, k) = if cells_as_rows { (c, g) } else { (g, c) };
s.push_str(&format!("{} {} {v}\n", r + 1, k + 1));
}
s
}
fn reference_store(
entries: &[(u32, u32, u32)],
n_cells: usize,
n_genes: usize,
qc: &MinCellQuality,
bin_path: &str,
) -> (Vec<usize>, Vec<usize>) {
let mut gene_nnz = vec![0usize; n_genes];
for &(_, g, _) in entries {
gene_nnz[g as usize] += 1;
}
let genes: Vec<usize> = (0..n_genes)
.filter(|&g| gene_nnz[g] >= qc.min_cells)
.collect();
let gene_map = dense_index_map(&genes);
let mut stats = vec![(0usize, 0u64); n_cells];
for &(c, g, v) in entries {
if gene_map
.get(g as usize)
.is_some_and(|&m| m != INDEX_DROPPED)
{
stats[c as usize].0 += 1;
stats[c as usize].1 += v as u64;
}
}
let cells: Vec<usize> = (0..n_cells)
.filter(|&c| {
stats[c].0 >= qc.min_unique_genes && stats[c].1 as f32 >= qc.min_lib_size as f32
})
.collect();
let cell_map = dense_index_map(&cells);
let mut rows: Vec<Vec<(u32, u32)>> = vec![Vec::new(); cells.len()];
for &(c, g, v) in entries {
let (Some(&nc), Some(&ng)) = (cell_map.get(c as usize), gene_map.get(g as usize))
else {
continue;
};
if nc != INDEX_DROPPED && ng != INDEX_DROPPED {
rows[nc as usize].push((ng, v));
}
}
let mut writer =
CellGeneSparseWriter::new(bin_path, true, cells.len(), genes.len(), qc.target_size)
.expect("writer");
write_cell_rows(&mut rows, 0, qc.target_size, &mut writer).expect("rows written");
writer.finalise().expect("finalised");
(cells, genes)
}
fn dropping_qc() -> MinCellQuality {
MinCellQuality {
min_unique_genes: 8,
min_lib_size: 60,
min_cells: 3,
target_size: 1e4,
}
}
#[test]
fn mtx_store_matches_reference_for_every_layout() {
let (n_cells, n_genes) = (400, 1_500);
let entries = random_counts(n_cells, n_genes, 42);
let qc = dropping_qc();
let reference = TempPath::new("reference_store", "bin");
let (cells, genes) = reference_store(&entries, n_cells, n_genes, &qc, reference.path());
assert!(
cells.len() < n_cells && genes.len() < n_genes,
"QC drops both"
);
let expected = std::fs::read(&reference.0).expect("reference store");
for cells_as_rows in [true, false] {
for order in [
EntryOrder::CellMajor,
EntryOrder::GeneMajor,
EntryOrder::Shuffled,
] {
let body = render_mtx(&entries, n_cells, n_genes, cells_as_rows, order);
for (gz, block_bytes) in [(false, MTX_BLOCK_BYTES), (false, 101), (true, 101)] {
let name = format!("layout_{cells_as_rows}_{order:?}_{gz}_{block_bytes}");
let mtx = if gz {
write_gz(&name, &body, 2)
} else {
write_mtx(&name, &body)
};
let bin = TempPath::new(&name, "bin");
let mut reader = MtxReader::new(mtx.path(), qc.clone(), cells_as_rows)
.expect("reader opens");
reader.source.block_bytes = block_bytes;
let quality = reader.parse_mtx_quality(false).expect("quality pass");
assert_eq!(quality.cells_to_keep, cells, "{name}: kept cells");
assert_eq!(quality.genes_to_keep, genes, "{name}: kept genes");
let spill = reader.spill.as_ref().expect("spilled");
assert_eq!(
spill.cell_major,
matches!(order, EntryOrder::CellMajor),
"{name}: order detection"
);
reader
.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
.expect("conversion");
let got = std::fs::read(&bin.0).expect("store");
assert!(got == expected, "{name}: store differs from the reference");
}
}
}
}
fn write_gz(name: &str, body: &str, members: usize) -> TempPath {
use flate2::Compression;
use flate2::write::GzEncoder;
let temp = TempPath::new(name, "mtx.gz");
let bytes = body.as_bytes();
let step = bytes.len().div_ceil(members.max(1)).max(1);
let mut out = Vec::new();
for part in bytes.chunks(step) {
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
enc.write_all(part).expect("gzip member");
out.extend(enc.finish().expect("gzip member"));
}
std::fs::write(&temp.0, out).expect("gz file written");
temp
}
#[test]
fn real_values_must_be_integral_counts() {
let real = |l: &[u8]| parse_entry(l, MtxField::Real);
assert_eq!(real(b"1 2 3").unwrap(), Some((1, 2, 3)));
assert_eq!(real(b"1 2 3.0").unwrap(), Some((1, 2, 3)));
assert_eq!(real(b"1 2 1.5e+01").unwrap(), Some((1, 2, 15)));
assert_eq!(real(b"1 2 2.000000e+00").unwrap(), Some((1, 2, 2)));
assert_eq!(real(b"1 2 0").unwrap(), Some((1, 2, 0)));
for bad in [
&b"1 2 1.5"[..],
b"1 2 -1",
b"1 2 abc",
b"1 2 1e300",
b"1 2 nan",
] {
assert!(
matches!(real(bad), Err(ScDataErrors::MtxNotACount { .. })),
"{}",
String::from_utf8_lossy(bad)
);
}
assert!(matches!(
parse_entry(b"1 2 1.5", MtxField::Integer),
Err(ScDataErrors::MtxNotACount { .. })
));
}
#[test]
fn pattern_entries_count_one() {
assert_eq!(
parse_entry(b"3 4", MtxField::Pattern).unwrap(),
Some((3, 4, 1))
);
assert_eq!(
parse_entry(b"3 4 9", MtxField::Pattern).unwrap(),
Some((3, 4, 1))
);
assert_eq!(parse_entry(b"3", MtxField::Pattern).unwrap(), None);
}
#[test]
fn mtx_banner_field_and_symmetry() {
let shape = "3 4 5\n1 1 5\n";
let open = |name: &str, banner: &str| {
let mtx = write_mtx(name, &format!("{banner}\n{shape}"));
MtxReader::new(mtx.path(), keep_all_qc(), true).map(|r| r.header.field)
};
assert_eq!(
open(
"banner_int",
"%%MatrixMarket matrix coordinate integer general"
)
.unwrap(),
MtxField::Integer
);
assert_eq!(
open(
"banner_real",
"%%MatrixMarket Matrix Coordinate Real General"
)
.unwrap(),
MtxField::Real
);
assert_eq!(
open(
"banner_pattern",
"%%MatrixMarket matrix coordinate pattern general"
)
.unwrap(),
MtxField::Pattern
);
assert_eq!(
open("banner_none", "% no banner").unwrap(),
MtxField::Integer
);
for (name, banner) in [
(
"banner_complex",
"%%MatrixMarket matrix coordinate complex general",
),
(
"banner_sym",
"%%MatrixMarket matrix coordinate integer symmetric",
),
(
"banner_array",
"%%MatrixMarket matrix array integer general",
),
] {
assert!(
matches!(open(name, banner), Err(ScDataErrors::MtxUnsupported(_))),
"{name}"
);
}
}
#[test]
fn mtx_real_and_pattern_files_round_trip() {
let real = write_mtx(
"real_file",
"%%MatrixMarket matrix coordinate real general\n2 3 3\n1 1 5.0\n1 3 1.2e+01\n2 2 3\n",
);
let bin = TempPath::new("real_file", "bin");
let mut reader = MtxReader::new(real.path(), keep_all_qc(), true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
let data = reader
.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
.expect("conversion");
assert_eq!(data.cell_qc.lib_size, vec![17, 3]);
assert_eq!(
read_back(bin.path(), 2),
vec![(vec![0, 2], vec![5, 12]), (vec![1], vec![3])]
);
let pattern = write_mtx(
"pattern_file",
"%%MatrixMarket matrix coordinate pattern general\n2 3 3\n1 1\n1 3\n2 2\n",
);
let bin = TempPath::new("pattern_file", "bin");
let mut reader = MtxReader::new(pattern.path(), keep_all_qc(), true).expect("reader opens");
let quality = reader.parse_mtx_quality(false).expect("quality pass");
reader
.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
.expect("conversion");
assert_eq!(
read_back(bin.path(), 2),
vec![(vec![0, 2], vec![1, 1]), (vec![1], vec![1])]
);
let fraction = write_mtx(
"real_fraction",
"%%MatrixMarket matrix coordinate real general\n2 3 2\n1 1 5.0\n2 2 0.25\n",
);
let mut reader =
MtxReader::new(fraction.path(), keep_all_qc(), true).expect("reader opens");
assert!(matches!(
reader.parse_mtx_quality(false),
Err(ScDataErrors::MtxNotACount { .. })
));
}
#[test]
fn mtx_gzip_is_detected_by_magic_bytes() {
let gz = write_gz("gz_magic", CELLS_AS_ROWS_MTX, 1);
let renamed = TempPath::new("gz_magic_renamed", "mtx");
std::fs::copy(&gz.0, &renamed.0).expect("copy");
let bin = TempPath::new("gz_magic", "bin");
let mut reader = MtxReader::new(renamed.path(), keep_all_qc(), true).expect("reader opens");
assert!(reader.source.gz);
let quality = reader.parse_mtx_quality(false).expect("quality pass");
reader
.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
.expect("conversion");
assert_eq!(read_back(bin.path(), 3), expected_cells());
}
#[test]
fn mtx_streaming_writes_a_readable_store_when_nothing_is_kept() {
let bin = TempPath::new("streaming_empty", "bin");
let mtx = write_mtx("streaming_empty", CELLS_AS_ROWS_MTX);
let reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let mut quality = CellOnFileQuality::new(vec![], vec![0, 1, 2, 3]);
quality.generate_maps_sets();
let final_data = reader
.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
.expect("streaming conversion");
assert_eq!(final_data.no_cells, 0);
assert_eq!(final_data.no_genes, 4);
assert!(final_data.cell_qc.lib_size.is_empty());
let reader = ParallelSparseReader::new(bin.path()).expect("reader opens");
assert!(reader.is_cell_based());
assert_eq!(reader.get_header().total_cells, 0);
}
#[test]
fn mtx_kept_cell_without_entries_is_written_empty() {
for streaming in [false, true] {
let name = format!("empty_cell_{streaming}");
let mtx = write_mtx(&name, CELLS_AS_ROWS_MTX);
let bin = TempPath::new(&name, "bin");
let reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
let mut quality = CellOnFileQuality::new(vec![0, 1, 2], vec![0]);
quality.generate_maps_sets();
let final_data = if streaming {
reader.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
} else {
reader.process_mtx_and_write_bin(bin.path(), &quality, false)
}
.expect("conversion");
assert_eq!(final_data.cell_qc.cell_indices.len(), 3);
assert_eq!(final_data.cell_qc.lib_size, vec![5, 0, 2]);
assert_eq!(final_data.cell_qc.nnz, vec![1, 0, 1]);
let store = ParallelSparseReader::new(bin.path()).expect("reader opens");
let cell = store
.read_cells_parallel(&[1])
.expect("empty cell is on disk");
assert!(cell[0].indices.is_empty());
}
}
}