use bincode::{Decode, Encode, config, decode_from_slice, serde::encode_to_vec};
use indexmap::IndexSet;
use lz4_flex::{compress_prepend_size, decompress_size_prepended};
use memmap2::MmapOptions;
use num_traits::FromPrimitive;
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use std::{
fs::File,
io::{BufWriter, Seek, SeekFrom, Write},
marker::Sync,
path::Path,
sync::Arc,
};
use crate::prelude::*;
#[derive(Encode, Decode, Serialize, Deserialize, Debug, Clone)]
pub enum RawCounts {
U16(Vec<u16>),
U32(Vec<u32>),
}
pub(crate) const RAW_ELEM_U16: u8 = 2;
pub(crate) const RAW_ELEM_U32: u8 = 4;
const CSR_CHUNK_HEADER_LEN: usize = 32;
const CSC_CHUNK_HEADER_LEN: usize = 36;
const GENE_FLUSH_FREQUENCY: usize = 1_000;
const CELL_FLUSH_FREQUENCY: usize = 100_000;
impl RawCounts {
pub fn get(&self, i: usize) -> u32 {
match self {
RawCounts::U16(v) => v[i] as u32,
RawCounts::U32(v) => v[i],
}
}
pub fn len(&self) -> usize {
match self {
RawCounts::U16(v) => v.len(),
RawCounts::U32(v) => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn iter(&self) -> RawCountsIter<'_> {
RawCountsIter {
inner: self,
pos: 0,
}
}
pub fn elem_size(&self) -> u8 {
match self {
RawCounts::U16(_) => RAW_ELEM_U16,
RawCounts::U32(_) => RAW_ELEM_U32,
}
}
pub fn write_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
match self {
RawCounts::U16(v) => {
let mut bytes = Vec::with_capacity(v.len() * 2);
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
writer.write_all(&bytes)
}
RawCounts::U32(v) => {
let mut bytes = Vec::with_capacity(v.len() * 4);
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
writer.write_all(&bytes)
}
}
}
pub fn read_from_buffer(
buffer: &[u8],
count: usize,
elem_size: u8,
) -> Result<Self, BixverseErrors> {
let width = match elem_size {
RAW_ELEM_U32 => 4_usize,
RAW_ELEM_U16 | 0 => 2_usize,
other => return Err(BixverseErrors::RawElemSizeInvalid(other)),
};
let needed = count * width;
if buffer.len() < needed {
return Err(BixverseErrors::ChunkPayloadTruncated {
expected: needed,
found: buffer.len(),
});
}
Ok(if width == 4 {
RawCounts::U32(
buffer[..needed]
.chunks_exact(4)
.map(|c| {
u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction"))
})
.collect(),
)
} else {
RawCounts::U16(
buffer[..needed]
.chunks_exact(2)
.map(|c| {
u16::from_le_bytes(c.try_into().expect("2-byte chunk by construction"))
})
.collect(),
)
})
}
pub fn from_u32_auto(data: &[u32]) -> Self {
if data.iter().all(|&v| v <= u16::MAX as u32) {
RawCounts::U16(data.iter().map(|&v| v as u16).collect())
} else {
RawCounts::U32(data.to_vec())
}
}
}
pub struct RawCountsIter<'a> {
inner: &'a RawCounts,
pos: usize,
}
impl<'a> Iterator for RawCountsIter<'a> {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.pos < self.inner.len() {
let val = self.inner.get(self.pos);
self.pos += 1;
Some(val)
} else {
None
}
}
}
#[derive(Clone, Debug)]
pub struct CellQuality {
pub cell_indices: Vec<usize>,
pub gene_indices: Vec<usize>,
pub lib_size: Vec<usize>,
pub nnz: Vec<usize>,
}
impl CellQuality {
pub fn set_cell_indices(&mut self, cell_indices: &[usize]) {
self.cell_indices = cell_indices.to_vec();
}
pub fn set_gene_indices(&mut self, gene_indices: &[usize]) {
self.gene_indices = gene_indices.to_vec();
}
}
#[derive(Clone, Debug)]
pub struct MinCellQuality {
pub min_unique_genes: usize,
pub min_lib_size: usize,
pub min_cells: usize,
pub target_size: f32,
}
#[derive(Debug, Clone)]
pub struct CellOnFileQuality {
pub cells_to_keep: Vec<usize>,
pub genes_to_keep: Vec<usize>,
pub cells_to_keep_set: FxHashSet<usize>,
pub genes_to_keep_set: FxHashSet<usize>,
pub cell_old_to_new: FxHashMap<usize, usize>,
pub gene_old_to_new: FxHashMap<usize, usize>,
}
impl CellOnFileQuality {
pub fn new(cells_to_keep: Vec<usize>, genes_to_keep: Vec<usize>) -> Self {
Self {
cells_to_keep,
genes_to_keep,
cells_to_keep_set: FxHashSet::default(),
genes_to_keep_set: FxHashSet::default(),
cell_old_to_new: FxHashMap::default(),
gene_old_to_new: FxHashMap::default(),
}
}
pub fn generate_maps_sets(&mut self) {
let cells_to_keep_set: FxHashSet<usize> = self.cells_to_keep.iter().cloned().collect();
let genes_to_keep_set: FxHashSet<usize> = self.genes_to_keep.iter().cloned().collect();
let cell_old_to_new: FxHashMap<usize, usize> = self
.cells_to_keep
.iter()
.enumerate()
.map(|(new_idx, &old_idx)| (old_idx, new_idx))
.collect();
let gene_old_to_new: FxHashMap<usize, usize> = self
.genes_to_keep
.iter()
.enumerate()
.map(|(new_idx, &old_idx)| (old_idx, new_idx))
.collect();
self.cells_to_keep_set = cells_to_keep_set;
self.genes_to_keep_set = genes_to_keep_set;
self.cell_old_to_new = cell_old_to_new;
self.gene_old_to_new = gene_old_to_new
}
}
pub fn norm_value(x: f32, lib_size: f32, target_size: f32) -> F16 {
F16::from_f32((x / lib_size * target_size).ln_1p())
}
#[derive(Debug)]
pub struct CsrCellChunk {
pub data_raw: RawCounts,
pub data_norm: Vec<F16>,
pub library_size: usize,
pub indices: Vec<u32>,
pub original_index: usize,
pub to_keep: bool,
}
impl CsrCellChunk {
pub fn from_data<T, U>(
data: &[T],
col_idx: &[U],
original_index: usize,
size_factor: f32,
to_keep: bool,
) -> Self
where
T: FloatAndUInt,
U: FloatAndUInt,
{
let sum = data.iter().map(|&x| x.to_f32()).sum::<f32>();
let data_norm: Vec<F16> = data
.iter()
.map(|&x| norm_value(x.to_f32(), sum, size_factor))
.collect();
let data_raw = if data.iter().all(|&x| x.to_u32() <= u16::MAX as u32) {
RawCounts::U16(data.iter().map(|&x| x.to_u32() as u16).collect())
} else {
RawCounts::U32(data.iter().map(|&x| x.to_u32()).collect())
};
Self {
data_raw,
data_norm,
library_size: sum as usize,
indices: col_idx.iter().map(|&x| x.to_u32()).collect::<Vec<u32>>(),
original_index,
to_keep,
}
}
pub fn write_to_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
writer.write_all(&self.to_bytes())
}
fn to_bytes(&self) -> Vec<u8> {
let raw_bytes = self.data_raw.len() * self.data_raw.elem_size() as usize;
let mut buffer = Vec::with_capacity(
CSR_CHUNK_HEADER_LEN + raw_bytes + self.data_norm.len() * 2 + self.indices.len() * 4,
);
buffer.extend_from_slice(&(self.data_raw.len() as u32).to_le_bytes());
buffer.extend_from_slice(&(self.data_norm.len() as u32).to_le_bytes());
buffer.extend_from_slice(&(self.indices.len() as u32).to_le_bytes());
buffer.extend_from_slice(&(self.library_size as u64).to_le_bytes());
buffer.extend_from_slice(&(self.original_index as u64).to_le_bytes());
buffer.extend_from_slice(&[self.to_keep as u8, self.data_raw.elem_size(), 0, 0]);
match &self.data_raw {
RawCounts::U16(v) => {
for x in v {
buffer.extend_from_slice(&x.to_le_bytes());
}
}
RawCounts::U32(v) => {
for x in v {
buffer.extend_from_slice(&x.to_le_bytes());
}
}
}
for v in &self.data_norm {
buffer.extend_from_slice(&v.to_bits().to_le_bytes());
}
for i in &self.indices {
buffer.extend_from_slice(&i.to_le_bytes());
}
buffer
}
pub fn to_compressed_bytes(&self) -> std::io::Result<Vec<u8>> {
Ok(compress_prepend_size(&self.to_bytes()))
}
pub fn read_from_buffer(buffer: &[u8]) -> Result<Self, BixverseErrors> {
if buffer.len() < CSR_CHUNK_HEADER_LEN {
return Err(BixverseErrors::ChunkBufferTooSmall {
expected: CSR_CHUNK_HEADER_LEN,
found: buffer.len(),
});
}
let header = &buffer[0..CSR_CHUNK_HEADER_LEN];
let data_raw_len =
u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
let data_norm_len =
u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
let col_indices_len =
u32::from_le_bytes([header[8], header[9], header[10], header[11]]) as usize;
let library_size = u64::from_le_bytes([
header[12], header[13], header[14], header[15], header[16], header[17], header[18],
header[19],
]) as usize;
let original_index = u64::from_le_bytes([
header[20], header[21], header[22], header[23], header[24], header[25], header[26],
header[27],
]) as usize;
let to_keep = header[28] != 0;
let raw_elem_size = header[29];
let data_start = CSR_CHUNK_HEADER_LEN;
let elem_size = if raw_elem_size == RAW_ELEM_U32 {
4usize
} else {
2usize
};
let data_end = data_start + data_raw_len * elem_size;
let norm_end = data_end + data_norm_len * 2;
let total_end = norm_end + col_indices_len * 4;
if buffer.len() < total_end {
return Err(BixverseErrors::ChunkPayloadTruncated {
expected: total_end,
found: buffer.len(),
});
}
let data_raw = RawCounts::read_from_buffer(
&buffer[data_start..data_end],
data_raw_len,
raw_elem_size,
)?;
let data_norm: Vec<F16> = buffer[data_end..norm_end]
.chunks_exact(2)
.map(|c| F16::from_le_bytes(c.try_into().expect("2-byte chunk by construction")))
.collect();
let indices: Vec<u32> = buffer[norm_end..total_end]
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction")))
.collect();
Ok(Self {
data_raw,
data_norm,
library_size,
indices,
original_index,
to_keep,
})
}
pub fn generate_chunks_sparse_data<T, U>(
sparse_data: CompressedSparseData2<T, U>,
cell_qc: MinCellQuality,
) -> (Vec<CsrCellChunk>, CellQuality)
where
T: Clone + Default + Into<u32> + Sync,
U: Clone + Default,
{
let n_cells = sparse_data.indptr.len() - 1;
let n_genes = sparse_data.shape.1;
let mut no_cells_exp_gene = vec![0usize; n_genes];
for i in 0..n_cells {
let start_i = sparse_data.indptr[i] as usize;
let end_i = sparse_data.indptr[i + 1] as usize;
for &gene_idx in &sparse_data.indices[start_i..end_i] {
no_cells_exp_gene[gene_idx as usize] += 1;
}
}
let genes_to_keep: Vec<usize> = (0..n_genes)
.filter(|&i| no_cells_exp_gene[i] >= cell_qc.min_cells)
.collect();
let mut gene_index_map = vec![None; n_genes];
for (new_idx, &old_idx) in genes_to_keep.iter().enumerate() {
gene_index_map[old_idx] = Some(new_idx);
}
let results: Vec<_> = (0..n_cells)
.into_par_iter()
.map(|i| {
let start_i = sparse_data.indptr[i] as usize;
let end_i = sparse_data.indptr[i + 1] as usize;
let mut filtered_data = Vec::new();
let mut filtered_indices = Vec::new();
for idx in start_i..end_i {
let old_gene_idx = sparse_data.indices[idx] as usize;
if let Some(new_gene_idx) = gene_index_map[old_gene_idx] {
filtered_data.push(sparse_data.data[idx].clone().into());
filtered_indices.push(new_gene_idx);
}
}
let sum_data_i = filtered_data.iter().sum::<u32>() as usize;
let nnz_i = filtered_indices.len();
let to_keep_i =
(nnz_i >= cell_qc.min_unique_genes) && (sum_data_i >= cell_qc.min_lib_size);
let chunk_i = CsrCellChunk::from_data(
&filtered_data,
&filtered_indices,
i,
cell_qc.target_size,
to_keep_i,
);
(chunk_i, sum_data_i, nnz_i, to_keep_i, i)
})
.collect();
let mut res = Vec::with_capacity(n_cells);
let mut lib_size = Vec::new();
let mut nnz = Vec::new();
let mut cells_to_keep = Vec::new();
for (chunk, lib, n, keep, idx) in results {
res.push(chunk);
if keep {
lib_size.push(lib);
nnz.push(n);
cells_to_keep.push(idx);
}
}
let qc_data = CellQuality {
cell_indices: cells_to_keep,
gene_indices: genes_to_keep,
lib_size,
nnz,
};
(res, qc_data)
}
pub fn get_qc_info(&self) -> (usize, usize) {
(self.indices.len(), self.library_size)
}
pub fn filter_genes(&mut self, genes_to_keep: &[bool]) {
let mut index_map = vec![None; genes_to_keep.len()];
let mut new_idx = 0;
for (old_idx, &keep) in genes_to_keep.iter().enumerate() {
if keep {
index_map[old_idx] = Some(new_idx);
new_idx += 1;
}
}
let mut new_indices = Vec::new();
let mut new_norm = Vec::new();
let mut keep_positions = Vec::new();
for (i, &gene_idx) in self.indices.iter().enumerate() {
if let Some(new_gene_idx) = index_map[gene_idx as usize] {
new_indices.push(new_gene_idx);
new_norm.push(self.data_norm[i]);
keep_positions.push(i);
}
}
let new_raw = match &self.data_raw {
RawCounts::U16(v) => RawCounts::U16(keep_positions.iter().map(|&p| v[p]).collect()),
RawCounts::U32(v) => RawCounts::U32(keep_positions.iter().map(|&p| v[p]).collect()),
};
self.indices = new_indices;
self.data_raw = new_raw;
self.data_norm = new_norm;
}
}
#[derive(Encode, Decode, Serialize, Deserialize, Debug)]
pub struct CscGeneChunk {
pub data_raw: RawCounts,
pub data_norm: Vec<F16>,
pub avg_exp: F16,
pub nnz: usize,
pub indices: Vec<u32>,
pub original_index: usize,
pub to_keep: bool,
}
impl CscGeneChunk {
pub fn from_conversion(
data_raw: RawCounts,
data_norm: &[F16],
col_idx: &[usize],
original_index: usize,
to_keep: bool,
) -> Self {
let avg_exp = F16::from_f32(data_norm.iter().map(|v| v.to_f32()).sum::<f32>());
let nnz = data_raw.len();
Self {
data_raw,
data_norm: data_norm.to_vec(),
avg_exp,
nnz,
indices: col_idx.iter().map(|x| *x as u32).collect::<Vec<u32>>(),
original_index,
to_keep,
}
}
pub fn write_to_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
writer.write_all(&(self.data_raw.len() as u32).to_le_bytes())?;
writer.write_all(&(self.data_norm.len() as u32).to_le_bytes())?;
writer.write_all(&(self.indices.len() as u32).to_le_bytes())?;
writer.write_all(&self.avg_exp.to_le_bytes())?;
writer.write_all(&[0, 0])?;
writer.write_all(&(self.nnz as u64).to_le_bytes())?;
writer.write_all(&(self.original_index as u64).to_le_bytes())?;
writer.write_all(&[self.to_keep as u8, self.data_raw.elem_size(), 0, 0])?;
self.data_raw.write_bytes(writer)?;
let mut data_norm_bytes = Vec::with_capacity(self.data_norm.len() * 2);
for v in &self.data_norm {
data_norm_bytes.extend_from_slice(&v.to_bits().to_le_bytes());
}
writer.write_all(&data_norm_bytes)?;
let mut row_indices_bytes = Vec::with_capacity(self.indices.len() * 4);
for i in &self.indices {
row_indices_bytes.extend_from_slice(&i.to_le_bytes());
}
writer.write_all(&row_indices_bytes)?;
Ok(())
}
pub fn to_compressed_bytes(&self) -> std::io::Result<Vec<u8>> {
let mut buffer = Vec::new();
self.write_to_bytes(&mut buffer)?;
Ok(compress_prepend_size(&buffer))
}
pub fn read_from_buffer(buffer: &[u8]) -> Result<Self, BixverseErrors> {
if buffer.len() < CSC_CHUNK_HEADER_LEN {
return Err(BixverseErrors::ChunkBufferTooSmall {
expected: CSC_CHUNK_HEADER_LEN,
found: buffer.len(),
});
}
let header = &buffer[0..CSC_CHUNK_HEADER_LEN];
let data_raw_len =
u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
let data_norm_len =
u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
let row_indices_len =
u32::from_le_bytes([header[8], header[9], header[10], header[11]]) as usize;
let avg_exp = F16::from_le_bytes([header[12], header[13]]);
let nnz = u64::from_le_bytes([
header[16], header[17], header[18], header[19], header[20], header[21], header[22],
header[23],
]) as usize;
let original_index = u64::from_le_bytes([
header[24], header[25], header[26], header[27], header[28], header[29], header[30],
header[31],
]) as usize;
let to_keep = header[32] != 0;
let raw_elem_size = header[33];
let data_start = CSC_CHUNK_HEADER_LEN;
let elem_size = if raw_elem_size == RAW_ELEM_U32 {
4usize
} else {
2usize
};
let data_end = data_start + data_raw_len * elem_size;
let norm_end = data_end + data_norm_len * 2;
let total_end = norm_end + row_indices_len * 4;
if buffer.len() < total_end {
return Err(BixverseErrors::ChunkPayloadTruncated {
expected: total_end,
found: buffer.len(),
});
}
let data_raw = RawCounts::read_from_buffer(
&buffer[data_start..data_end],
data_raw_len,
raw_elem_size,
)?;
let data_norm: Vec<F16> = buffer[data_end..norm_end]
.chunks_exact(2)
.map(|c| F16::from_le_bytes(c.try_into().expect("2-byte chunk by construction")))
.collect();
let indices: Vec<u32> = buffer[norm_end..total_end]
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction")))
.collect();
Ok(Self {
data_raw,
data_norm,
avg_exp,
nnz,
indices,
original_index,
to_keep,
})
}
pub fn filter_selected_cells(&mut self, cells_to_keep: &IndexSet<u32>) {
let cell_positions: FxHashMap<u32, usize> = self
.indices
.iter()
.enumerate()
.map(|(pos, &cell_id)| (cell_id, pos))
.collect();
let mut new_data_norm = Vec::with_capacity(cells_to_keep.len());
let mut new_row_indices = Vec::with_capacity(cells_to_keep.len());
let mut keep_positions = Vec::new();
for (new_row_idx, &cell_index) in cells_to_keep.iter().enumerate() {
if let Some(&pos) = cell_positions.get(&cell_index) {
keep_positions.push(pos);
new_data_norm.push(self.data_norm[pos]);
new_row_indices.push(new_row_idx as u32);
}
}
let new_raw = match &self.data_raw {
RawCounts::U16(v) => RawCounts::U16(keep_positions.iter().map(|&p| v[p]).collect()),
RawCounts::U32(v) => RawCounts::U32(keep_positions.iter().map(|&p| v[p]).collect()),
};
let nnz = new_row_indices.len();
self.data_raw = new_raw;
self.data_norm = new_data_norm;
self.indices = new_row_indices;
self.nnz = nnz;
}
pub fn calculate_avg_exp(&self, cells_to_keep: &IndexSet<u32>) -> (usize, f32) {
let sum: f32 = self
.indices
.iter()
.zip(self.data_norm.iter())
.filter(|(cell_idx, _)| cells_to_keep.contains(*cell_idx))
.map(|(_, val)| val.to_f32())
.sum();
let avg = sum / cells_to_keep.len() as f32;
(self.original_index, avg)
}
pub fn to_sparse_axis(&self, n_cells: usize) -> SparseAxis<u32, f32> {
let raw_u32: Vec<u32> = self.data_raw.iter().collect();
SparseAxis::new_csc(
self.indices.iter().map(|x| *x as usize).collect(),
raw_u32,
Some(self.data_norm.iter().map(|x| x.to_f32()).collect()),
n_cells,
)
}
}
#[derive(Encode, Decode, Serialize, Deserialize, Clone)]
pub struct SparseDataHeader {
pub total_cells: usize,
pub total_genes: usize,
pub cell_based: bool,
pub no_chunks: usize,
pub chunk_offsets: Vec<u64>,
pub index_map: FxHashMap<usize, usize>,
}
const FILE_MAGIC: &[u8; 8] = b"SCRNASEQ";
fn le_u64_at(bytes: &[u8], at: usize) -> u64 {
u64::from_le_bytes(
bytes[at..at + 8]
.try_into()
.expect("an 8-byte slice converts to [u8; 8]"),
)
}
#[repr(C)]
#[derive(Encode, Decode, Serialize, Deserialize)]
struct FileHeader {
magic: [u8; 8],
version: u32,
main_header_offset: u64,
cell_based: bool,
target_size: f32,
library_index_offset: u64,
_reserved_1: [u8; 20],
_reserved_2: [u8; 3],
}
impl FileHeader {
fn new(cell_based: bool, target_size: f32) -> Self {
Self {
magic: *FILE_MAGIC,
version: SC_FILE_VERSION,
main_header_offset: 0,
cell_based,
target_size,
library_index_offset: 0,
_reserved_1: [0; 20],
_reserved_2: [0; 3],
}
}
}
pub struct CellPayload {
pub original_index: usize,
pub library_size: usize,
pub bytes: Vec<u8>,
}
pub struct CellGeneSparseWriter {
header: SparseDataHeader,
writer: BufWriter<File>,
chunks_start_pos: u64,
cell_based: bool,
chunks_since_flush: usize,
flush_frequency: usize,
target_size: f32,
library_sizes: Vec<u64>,
}
impl CellGeneSparseWriter {
pub fn new<P: AsRef<Path>>(
path_f: P,
cell_based: bool,
total_cells: usize,
total_genes: usize,
target_size: f32,
) -> Result<Self, BixverseErrors> {
let file = File::create(path_f)?;
let mut writer = BufWriter::with_capacity(128 * 1024 * 1024, file);
let file_header = FileHeader::new(cell_based, target_size);
let file_header_enc = encode_to_vec(&file_header, config::standard())
.map_err(|_| BixverseErrors::HeaderEncodeFailed)?;
if file_header_enc.len() < 64 {
writer.write_all(&file_header_enc)?;
writer.write_all(&vec![0u8; 64 - file_header_enc.len()])?;
} else {
writer.write_all(&file_header_enc[..64])?;
}
writer.flush()?;
let chunks_start_pos = 64;
let flush_frequency = if cell_based {
CELL_FLUSH_FREQUENCY
} else {
GENE_FLUSH_FREQUENCY
};
let header = SparseDataHeader {
total_cells,
total_genes,
cell_based,
no_chunks: 0,
chunk_offsets: Vec::new(),
index_map: FxHashMap::default(),
};
Ok(Self {
header,
writer,
chunks_start_pos,
cell_based,
chunks_since_flush: 0_usize,
flush_frequency,
target_size,
library_sizes: Vec::new(),
})
}
fn check_mode(&self, want_cell_based: bool) -> Result<(), BixverseErrors> {
if self.cell_based == want_cell_based {
return Ok(());
}
let label = |b: bool| if b { "cell-based" } else { "gene-based" };
Err(BixverseErrors::ReaderModeMismatch {
actual: label(self.cell_based),
requested: label(want_cell_based),
})
}
fn append_chunk(&mut self, buffer: &[u8], original_index: usize) -> Result<(), BixverseErrors> {
self.append_compressed(&compress_prepend_size(buffer), original_index)
}
fn append_compressed(
&mut self,
compressed: &[u8],
original_index: usize,
) -> Result<(), BixverseErrors> {
let current_pos = self.writer.stream_position()?;
let chunk_offset = current_pos - self.chunks_start_pos;
self.header.chunk_offsets.push(chunk_offset);
self.header
.index_map
.insert(original_index, self.header.no_chunks);
self.writer
.write_all(&(compressed.len() as u64).to_le_bytes())?;
self.writer.write_all(compressed)?;
self.header.no_chunks += 1;
self.chunks_since_flush += 1;
if self.chunks_since_flush >= self.flush_frequency {
self.writer.flush()?;
self.chunks_since_flush = 0;
}
Ok(())
}
fn append_cell(
&mut self,
compressed: &[u8],
original_index: usize,
library_size: usize,
) -> Result<(), BixverseErrors> {
self.append_compressed(compressed, original_index)?;
self.library_sizes.push(library_size as u64);
Ok(())
}
pub fn write_cell_chunk(&mut self, cell_chunk: CsrCellChunk) -> Result<(), BixverseErrors> {
self.check_mode(true)?;
self.append_cell(
&compress_prepend_size(&cell_chunk.to_bytes()),
cell_chunk.original_index,
cell_chunk.library_size,
)
}
pub fn write_gene_chunk(&mut self, gene_chunk: CscGeneChunk) -> Result<(), BixverseErrors> {
self.check_mode(false)?;
let mut buffer = Vec::new();
gene_chunk.write_to_bytes(&mut buffer)?;
self.append_chunk(&buffer, gene_chunk.original_index)
}
pub fn write_compressed_gene_chunks(
&mut self,
chunks: &[(usize, Vec<u8>)],
) -> Result<(), BixverseErrors> {
self.check_mode(false)?;
self.append_compressed_batch(chunks)
}
pub fn write_compressed_cell_chunks(
&mut self,
chunks: &[CellPayload],
) -> Result<(), BixverseErrors> {
self.check_mode(true)?;
for chunk in chunks {
self.append_cell(&chunk.bytes, chunk.original_index, chunk.library_size)?;
}
Ok(())
}
fn append_compressed_batch(
&mut self,
chunks: &[(usize, Vec<u8>)],
) -> Result<(), BixverseErrors> {
for (original_index, payload) in chunks {
self.append_compressed(payload, *original_index)?;
}
Ok(())
}
pub fn finalise(mut self) -> Result<(), BixverseErrors> {
let main_header_offset = self.writer.stream_position()?;
let header_data = encode_to_vec(&self.header, config::standard())
.map_err(|_| BixverseErrors::HeaderEncodeFailed)?;
let header_size = header_data.len() as u64;
self.writer.write_all(&header_size.to_le_bytes())?;
self.writer.write_all(&header_data)?;
let mut file_header = FileHeader::new(self.cell_based, self.target_size);
file_header.main_header_offset = main_header_offset;
if self.cell_based {
file_header.library_index_offset = self.write_library_index()?;
}
self.writer.seek(SeekFrom::Start(0))?;
let file_header_enc = encode_to_vec(&file_header, config::standard())
.map_err(|_| BixverseErrors::HeaderEncodeFailed)?;
if file_header_enc.len() < 64 {
self.writer.write_all(&file_header_enc)?;
self.writer
.write_all(&vec![0u8; 64 - file_header_enc.len()])?;
} else {
self.writer.write_all(&file_header_enc[..64])?;
}
self.writer.flush()?;
Ok(())
}
fn write_library_index(&mut self) -> Result<u64, BixverseErrors> {
assert!(self.cell_based, "library-size index on a gene-based file");
assert_eq!(
self.library_sizes.len(),
self.header.no_chunks,
"library-size index has {} entries for {} cell chunks",
self.library_sizes.len(),
self.header.no_chunks
);
let offset = self.writer.stream_position()?;
self.writer
.write_all(&(self.library_sizes.len() as u64).to_le_bytes())?;
for library_size in &self.library_sizes {
self.writer.write_all(&library_size.to_le_bytes())?;
}
Ok(offset)
}
pub fn update_header_no_cells(&mut self, no_cells: usize) {
self.header.total_cells = no_cells;
}
pub fn update_header_no_genes(&mut self, no_genes: usize) {
self.header.total_genes = no_genes;
}
}
pub const INDEX_DROPPED: u32 = u32::MAX;
const CELL_WRITE_BATCH: usize = 4_096;
pub fn dense_index_map(kept: &[usize]) -> Vec<u32> {
let mut map = vec![INDEX_DROPPED; kept.iter().max().map_or(0, |&g| g + 1)];
for (new, &old) in kept.iter().enumerate() {
map[old] = new as u32;
}
map
}
pub fn dense_gene_map_from_options(mapping: &[Option<usize>]) -> Vec<u32> {
mapping
.iter()
.map(|g| g.map_or(INDEX_DROPPED, |g| g as u32))
.collect()
}
pub fn compress_cell_row(
row: &mut [(u32, u32)],
original_index: usize,
target_size: f32,
) -> std::io::Result<(usize, CellPayload)> {
if row.windows(2).any(|w| w[0].0 > w[1].0) {
row.sort_by_key(|&(g, _)| g);
}
let genes: Vec<u32> = row.iter().map(|&(g, _)| g).collect();
let counts: Vec<u32> = row.iter().map(|&(_, c)| c).collect();
let chunk = CsrCellChunk::from_data(&counts, &genes, original_index, target_size, true);
let (nnz, library_size) = chunk.get_qc_info();
let payload = CellPayload {
original_index,
library_size,
bytes: chunk.to_compressed_bytes()?,
};
Ok((nnz, payload))
}
pub fn write_cell_rows(
rows: &mut [Vec<(u32, u32)>],
cell_offset: usize,
target_size: f32,
writer: &mut CellGeneSparseWriter,
) -> Result<(Vec<usize>, Vec<usize>), BixverseErrors> {
let mut nnz = Vec::with_capacity(rows.len());
let mut lib_size = Vec::with_capacity(rows.len());
for (batch_idx, batch) in rows.chunks_mut(CELL_WRITE_BATCH).enumerate() {
let first = cell_offset + batch_idx * CELL_WRITE_BATCH;
let built = batch
.par_iter_mut()
.enumerate()
.map(|(k, row)| {
let mut row = std::mem::take(row);
Ok(compress_cell_row(&mut row, first + k, target_size)?)
})
.collect::<Result<Vec<_>, BixverseErrors>>()?;
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)?;
}
Ok((nnz, lib_size))
}
pub fn write_cell_chunks_built<F>(
n_chunks: usize,
build: F,
writer: &mut CellGeneSparseWriter,
) -> Result<(Vec<usize>, Vec<usize>), BixverseErrors>
where
F: Fn(usize) -> CsrCellChunk + Sync,
{
let mut nnz = Vec::with_capacity(n_chunks);
let mut lib_size = Vec::with_capacity(n_chunks);
for start in (0..n_chunks).step_by(CELL_WRITE_BATCH) {
let end = (start + CELL_WRITE_BATCH).min(n_chunks);
let built = (start..end)
.into_par_iter()
.map(|k| {
let chunk = build(k);
let (nnz_i, lib_i) = chunk.get_qc_info();
Ok::<_, BixverseErrors>((
nnz_i,
CellPayload {
original_index: chunk.original_index,
library_size: lib_i,
bytes: chunk.to_compressed_bytes()?,
},
))
})
.collect::<Result<Vec<_>, _>>()?;
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)?;
}
Ok((nnz, lib_size))
}
pub fn write_cell_chunks_parallel(
chunks: &[CsrCellChunk],
writer: &mut CellGeneSparseWriter,
) -> Result<(), BixverseErrors> {
for batch in chunks.chunks(CELL_WRITE_BATCH) {
let payloads = batch
.par_iter()
.map(|chunk| {
Ok::<_, BixverseErrors>(CellPayload {
original_index: chunk.original_index,
library_size: chunk.library_size,
bytes: chunk.to_compressed_bytes()?,
})
})
.collect::<Result<Vec<_>, _>>()?;
writer.write_compressed_cell_chunks(&payloads)?;
}
Ok(())
}
pub trait SingleCellReading: Send + Sync {
fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, BixverseErrors>;
fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, BixverseErrors>;
fn get_header(&self) -> SparseDataHeader;
fn is_cell_based(&self) -> bool;
fn is_gene_based(&self) -> bool {
!self.is_cell_based()
}
fn target_size(&self) -> Option<f32> {
None
}
fn read_cell(&self, index: usize) -> Result<CsrCellChunk, BixverseErrors> {
self.read_cells_parallel(&[index])?
.into_iter()
.next()
.ok_or(BixverseErrors::ChunkIndexNotFound(index))
}
fn read_gene(&self, index: usize) -> Result<CscGeneChunk, BixverseErrors> {
self.read_gene_parallel(&[index])?
.into_iter()
.next()
.ok_or(BixverseErrors::ChunkIndexNotFound(index))
}
fn read_cells_range(
&self,
start: usize,
end: usize,
) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
let indices: Vec<usize> = (start..end).collect();
self.read_cells_parallel(&indices)
}
fn get_all_cells(&self) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
let indices: Vec<usize> = (0..self.get_header().total_cells).collect();
self.read_cells_parallel(&indices)
}
fn get_all_genes(&self) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
let indices: Vec<usize> = (0..self.get_header().total_genes).collect();
self.read_gene_parallel(&indices)
}
fn read_gene_parallel_filtered(
&self,
indices: &[usize],
cells_to_keep: &IndexSet<u32>,
) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
let mut chunks = self.read_gene_parallel(indices)?;
chunks
.par_iter_mut()
.for_each(|chunk| chunk.filter_selected_cells(cells_to_keep));
Ok(chunks)
}
fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
Ok(self
.read_cells_parallel(indices)?
.iter()
.map(|chunk| chunk.library_size)
.collect())
}
fn read_gene_nnz(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
Ok(self
.read_gene_parallel(indices)?
.iter()
.map(|chunk| chunk.nnz)
.collect())
}
fn get_all_gene_nnz(&self) -> Result<Vec<usize>, BixverseErrors> {
let indices: Vec<usize> = (0..self.get_header().total_genes).collect();
self.read_gene_nnz(&indices)
}
fn get_clr_offsets(
&self,
indices: &[usize],
batch_size: Option<usize>,
) -> Result<Vec<f64>, BixverseErrors> {
if !self.is_cell_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
let batch_size = batch_size.unwrap_or(CELL_BATCH_SIZE);
let n_genes = self.get_header().total_genes as f64;
let mut offsets = Vec::with_capacity(indices.len());
for batch in indices.chunks(batch_size) {
let cells = self.read_cells_parallel(batch)?;
let batch_offsets: Vec<f64> = cells
.par_iter()
.map(|chunk| {
let inv_s = 1.0_f64 / chunk.library_size as f64;
let sum: f64 = chunk
.data_raw
.iter()
.map(|c| (c as f64 * inv_s).ln_1p())
.sum();
sum / n_genes
})
.collect();
offsets.extend_from_slice(&batch_offsets);
}
Ok(offsets)
}
}
pub fn peek_target_size<P: AsRef<Path>>(f_path: P) -> Result<Option<f32>, BixverseErrors> {
use std::io::Read;
let mut buffer = [0u8; 64];
File::open(f_path)?.read_exact(&mut buffer)?;
let (file_header, _) = decode_from_slice::<FileHeader, _>(&buffer, config::standard())
.map_err(|_| BixverseErrors::HeaderDecodeFailed)?;
Ok((file_header.target_size != 0.0).then_some(file_header.target_size))
}
pub struct ParallelSparseReader {
header: SparseDataHeader,
mmap: Arc<memmap2::Mmap>,
chunks_start: u64,
target_size: f32,
library_index: Option<usize>,
}
impl ParallelSparseReader {
pub fn new(f_path: &str) -> Result<Self, BixverseErrors> {
let file = File::open(f_path)?;
let file_size = file.metadata()?.len();
let mmap = unsafe {
let mut opts = MmapOptions::new();
if file_size <= 8 * 1024 * 1024 * 1024 {
opts.populate();
}
opts.map(&file)?
};
#[cfg(unix)]
mmap.advise(memmap2::Advice::Random)?;
if mmap.len() < 64 {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let file_header_bytes = &mmap[0..64];
let (file_header, _) =
decode_from_slice::<FileHeader, _>(file_header_bytes, config::standard())
.map_err(|_| BixverseErrors::HeaderDecodeFailed)?;
if &file_header.magic != FILE_MAGIC {
return Err(BixverseErrors::HeaderDecodeFailed);
}
if file_header.version != SC_FILE_VERSION {
return Err(BixverseErrors::FileVersionMismatch {
expected: SC_FILE_VERSION,
found: file_header.version,
});
}
let main_header_offset = file_header.main_header_offset as usize;
if main_header_offset.saturating_add(8) > mmap.len() {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let header_size = u64::from_le_bytes(
mmap[main_header_offset..main_header_offset + 8]
.try_into()
.expect("8-byte slice, bounds checked above"),
) as usize;
let header_end = main_header_offset
.saturating_add(8)
.saturating_add(header_size);
if header_end > mmap.len() {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let header_bytes = &mmap[main_header_offset + 8..header_end];
let (header, _) =
decode_from_slice::<SparseDataHeader, _>(header_bytes, config::standard())
.map_err(|_| BixverseErrors::HeaderDecodeFailed)?;
let library_index =
Self::check_library_index(&mmap, file_header.library_index_offset, &header)?;
Ok(Self {
header,
mmap: Arc::new(mmap),
chunks_start: 64,
target_size: file_header.target_size,
library_index,
})
}
fn check_library_index(
mmap: &memmap2::Mmap,
offset: u64,
header: &SparseDataHeader,
) -> Result<Option<usize>, BixverseErrors> {
if offset == 0 {
return Ok(None);
}
let offset = offset as usize;
if !header.cell_based || offset.saturating_add(8) > mmap.len() {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let count = le_u64_at(mmap, offset) as usize;
let end = count
.checked_mul(8)
.and_then(|bytes| bytes.checked_add(offset + 8));
if count != header.no_chunks || end.is_none_or(|end| end > mmap.len()) {
return Err(BixverseErrors::HeaderDecodeFailed);
}
Ok(Some(offset + 8))
}
fn library_sizes_from_index(
&self,
start: usize,
indices: &[usize],
) -> Result<Vec<usize>, BixverseErrors> {
indices
.iter()
.map(|&original_index| {
let chunk = self
.header
.index_map
.get(&original_index)
.copied()
.filter(|&chunk| chunk < self.header.no_chunks)
.ok_or(BixverseErrors::ChunkIndexNotFound(original_index))?;
Ok(le_u64_at(&self.mmap, start + 8 * chunk) as usize)
})
.collect()
}
#[cfg(test)]
pub(crate) fn has_library_index(&self) -> bool {
self.library_index.is_some()
}
fn decompress_chunk(&self, original_index: usize) -> Result<Vec<u8>, BixverseErrors> {
let chunk_index = *self
.header
.index_map
.get(&original_index)
.ok_or(BixverseErrors::ChunkIndexNotFound(original_index))?;
let chunk_offset = (self.chunks_start + self.header.chunk_offsets[chunk_index]) as usize;
let compressed_size = u64::from_le_bytes(
self.mmap[chunk_offset..chunk_offset + 8]
.try_into()
.expect("8-byte slice by construction"),
) as usize;
let compressed = &self.mmap[chunk_offset + 8..chunk_offset + 8 + compressed_size];
decompress_size_prepended(compressed)
.map_err(|_| BixverseErrors::ChunkDecompressionFailed(chunk_offset as u64))
}
}
impl SingleCellReading for ParallelSparseReader {
fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
if !self.is_cell_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
CsrCellChunk::read_from_buffer(&decompressed)
})
.collect()
}
fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
if !self.is_gene_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
CscGeneChunk::read_from_buffer(&decompressed)
})
.collect()
}
fn read_gene_parallel_filtered(
&self,
indices: &[usize],
cells_to_keep: &IndexSet<u32>,
) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
if !self.is_gene_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
let mut chunk = CscGeneChunk::read_from_buffer(&decompressed)?;
chunk.filter_selected_cells(cells_to_keep);
Ok(chunk)
})
.collect()
}
fn get_header(&self) -> SparseDataHeader {
self.header.clone()
}
fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
if !self.is_cell_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
if let Some(start) = self.library_index {
return self.library_sizes_from_index(start, indices);
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
Ok(u64::from_le_bytes([
decompressed[12],
decompressed[13],
decompressed[14],
decompressed[15],
decompressed[16],
decompressed[17],
decompressed[18],
decompressed[19],
]) as usize)
})
.collect()
}
fn read_gene_nnz(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
if !self.is_gene_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
Ok(u64::from_le_bytes([
decompressed[16],
decompressed[17],
decompressed[18],
decompressed[19],
decompressed[20],
decompressed[21],
decompressed[22],
decompressed[23],
]) as usize)
})
.collect()
}
fn is_cell_based(&self) -> bool {
self.header.cell_based
}
fn target_size(&self) -> Option<f32> {
(self.target_size != 0.0).then_some(self.target_size)
}
}
#[derive(Clone, Debug, Copy)]
pub enum DataLayerReturn {
BothLayers,
Raw,
Norm,
}
fn extend_raw_counts<T>(data: &mut Vec<T>, raw: &RawCounts) -> Result<(), BixverseErrors>
where
T: BixverseNumeric + FromPrimitive,
{
let convert = |val: u32| {
T::from_u32(val).ok_or(BixverseErrors::RawCountOverflow {
value: val,
target_type: std::any::type_name::<T>(),
})
};
match raw {
RawCounts::U16(v) => {
for &x in v {
data.push(convert(x as u32)?);
}
}
RawCounts::U32(v) => {
for &x in v {
data.push(convert(x)?);
}
}
}
Ok(())
}
pub fn from_gene_chunks<T>(
chunks: Vec<CscGeneChunk>,
data_layer: &DataLayerReturn,
n_cells: usize,
) -> Result<CompressedSparseData2<T, f32>, BixverseErrors>
where
T: BixverseNumeric + FromPrimitive,
{
let n_genes = chunks.len();
let keep_raw = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Raw
);
let keep_norm = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Norm
);
let total_nnz: usize = chunks.iter().map(|c| c.indices.len()).sum();
let mut data: Vec<T> = Vec::with_capacity(if keep_raw { total_nnz } else { 0 });
let mut data_2: Vec<f32> = Vec::with_capacity(if keep_norm { total_nnz } else { 0 });
let mut indices: Vec<u32> = Vec::with_capacity(total_nnz);
let mut indptr: Vec<usize> = Vec::with_capacity(n_genes + 1);
indptr.push(0);
for chunk in chunks {
if keep_raw {
extend_raw_counts(&mut data, &chunk.data_raw)?;
}
if keep_norm {
data_2.extend(chunk.data_norm.iter().map(|v| v.to_f32()));
}
indices.extend_from_slice(&chunk.indices);
indptr.push(indices.len());
}
Ok(CompressedSparseData2 {
data,
indices,
indptr: indptr.index_cast(),
cs_type: CompressedSparseFormat::Csc,
data_2: if keep_norm { Some(data_2) } else { None },
shape: (n_cells, n_genes),
})
}
pub fn from_cell_chunks<T>(
chunks: &[CsrCellChunk],
data_layer: &DataLayerReturn,
n_genes: usize,
) -> Result<CompressedSparseData2<T, f32>, BixverseErrors>
where
T: BixverseNumeric + FromPrimitive,
{
let n_cells = chunks.len();
let keep_raw = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Raw
);
let keep_norm = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Norm
);
let total_nnz: usize = chunks.iter().map(|c| c.indices.len()).sum();
let mut data: Vec<T> = Vec::with_capacity(if keep_raw { total_nnz } else { 0 });
let mut data_2: Vec<f32> = Vec::with_capacity(if keep_norm { total_nnz } else { 0 });
let mut indices: Vec<u32> = Vec::with_capacity(total_nnz);
let mut indptr: Vec<usize> = Vec::with_capacity(n_cells + 1);
indptr.push(0);
for chunk in chunks {
if keep_raw {
extend_raw_counts(&mut data, &chunk.data_raw)?;
}
if keep_norm {
data_2.extend(chunk.data_norm.iter().map(|v| v.to_f32()));
}
indices.extend_from_slice(&chunk.indices);
indptr.push(indices.len());
}
Ok(CompressedSparseData2 {
data,
indices,
indptr: indptr.index_cast(),
cs_type: CompressedSparseFormat::Csr,
data_2: if keep_norm { Some(data_2) } else { None },
shape: (n_cells, n_genes),
})
}
#[cfg(test)]
mod tests {
use super::*;
use half::f16;
struct TempBin(std::path::PathBuf);
impl Drop for TempBin {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
impl TempBin {
fn new(name: &str) -> Self {
Self(std::env::temp_dir().join(format!("bixverse_data_io_{name}.bin")))
}
fn path(&self) -> &str {
self.0.to_str().expect("temp path is valid UTF-8")
}
}
fn cell_chunk(raw: &[u32], indices: &[u32], original_index: usize) -> CsrCellChunk {
CsrCellChunk {
data_raw: RawCounts::from_u32_auto(raw),
data_norm: raw
.iter()
.map(|&v| F16::from(f16::from_f32(v as f32)))
.collect(),
library_size: raw.iter().map(|&v| v as usize).sum(),
indices: indices.to_vec(),
original_index,
to_keep: true,
}
}
fn gene_chunk(raw: &[u32], indices: &[u32], original_index: usize) -> CscGeneChunk {
CscGeneChunk {
data_raw: RawCounts::from_u32_auto(raw),
data_norm: raw
.iter()
.map(|&v| F16::from(f16::from_f32(v as f32)))
.collect(),
avg_exp: F16::from(f16::from_f32(1.0)),
nnz: raw.len(),
indices: indices.to_vec(),
original_index,
to_keep: true,
}
}
#[test]
fn test_cell_chunk_round_trip_preserves_fields() {
let raw = [1_u32, 7, 42, 65_535, 3];
let indices = [0_u32, 5, 9, 100_000, 300_000];
let chunk = cell_chunk(&raw, &indices, 17);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
assert_eq!(back.indices, indices.to_vec());
assert_eq!(back.library_size, chunk.library_size);
assert_eq!(back.original_index, 17);
assert!(back.to_keep);
let bits: Vec<u16> = back.data_norm.iter().map(|v| v.to_bits()).collect();
let want: Vec<u16> = chunk.data_norm.iter().map(|v| v.to_bits()).collect();
assert_eq!(bits, want);
}
#[test]
fn test_gene_chunk_round_trip_preserves_fields() {
let raw = [2_u32, 0, 99, 70_000];
let indices = [1_u32, 4, 8, 12];
let chunk = gene_chunk(&raw, &indices, 3);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
let back = CscGeneChunk::read_from_buffer(&buffer).expect("deserialise");
assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
assert_eq!(back.indices, indices.to_vec());
assert_eq!(back.nnz, 4);
assert_eq!(back.original_index, 3);
assert_eq!(back.avg_exp.to_bits(), chunk.avg_exp.to_bits());
}
#[test]
fn test_gene_chunk_short_buffer_errors_instead_of_panicking() {
for len in CSR_CHUNK_HEADER_LEN..CSC_CHUNK_HEADER_LEN {
let buffer = vec![0_u8; len];
match CscGeneChunk::read_from_buffer(&buffer) {
Err(BixverseErrors::ChunkBufferTooSmall { expected, found }) => {
assert_eq!(expected, CSC_CHUNK_HEADER_LEN);
assert_eq!(found, len);
}
other => panic!("expected ChunkBufferTooSmall for len {len}, got {other:?}"),
}
}
}
#[test]
fn test_chunk_payload_truncation_is_caught() {
let chunk = cell_chunk(&[1, 2, 3], &[0, 1, 2], 0);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
buffer[0..4].copy_from_slice(&12_u32.to_le_bytes());
assert!(matches!(
CsrCellChunk::read_from_buffer(&buffer),
Err(BixverseErrors::ChunkPayloadTruncated { .. })
));
let gene = gene_chunk(&[1, 2, 3], &[0, 1, 2], 0);
let mut buffer = Vec::new();
gene.write_to_bytes(&mut buffer).expect("serialise");
buffer[8..12].copy_from_slice(&64_u32.to_le_bytes());
assert!(matches!(
CscGeneChunk::read_from_buffer(&buffer),
Err(BixverseErrors::ChunkPayloadTruncated { .. })
));
}
#[test]
fn test_chunk_round_trip_with_unaligned_index_offset() {
let raw = [1_u32, 2, 3];
let indices = [7_u32, 8, 9];
let chunk = cell_chunk(&raw, &indices, 0);
assert_eq!(chunk.data_raw.elem_size(), RAW_ELEM_U16);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
assert_eq!(buffer.len() % 4, 0);
let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
assert_eq!(back.indices, indices.to_vec());
assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
}
#[test]
fn test_raw_counts_element_size_discriminant() {
let payload = [1_u8, 0, 2, 0];
let legacy = RawCounts::read_from_buffer(&payload, 2, 0).expect("legacy parses as u16");
assert_eq!(legacy.iter().collect::<Vec<_>>(), vec![1, 2]);
let explicit =
RawCounts::read_from_buffer(&payload, 2, RAW_ELEM_U16).expect("explicit u16 parses");
assert_eq!(explicit.iter().collect::<Vec<_>>(), vec![1, 2]);
assert!(matches!(
RawCounts::read_from_buffer(&payload, 2, 3),
Err(BixverseErrors::RawElemSizeInvalid(3))
));
}
#[test]
fn test_large_raw_count_survives_round_trip() {
let raw = [70_000_u32, 1];
let chunk = cell_chunk(&raw, &[0, 1], 0);
assert_eq!(chunk.data_raw.elem_size(), RAW_ELEM_U32);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
let csr = from_cell_chunks::<u32>(&[back], &DataLayerReturn::Raw, 2).expect("assemble");
assert_eq!(csr.data, vec![70_000_u32, 1]);
}
#[test]
fn test_narrow_target_type_reports_overflow() {
let chunk = gene_chunk(&[70_000, 1], &[0, 1], 0);
match from_gene_chunks::<u16>(vec![chunk], &DataLayerReturn::Raw, 2) {
Err(BixverseErrors::RawCountOverflow { value, .. }) => assert_eq!(value, 70_000),
other => panic!("expected RawCountOverflow, got {:?}", other.map(|_| ())),
}
}
#[test]
fn test_cell_based_file_round_trip() {
let temp = TempBin::new("cell_round_trip");
let chunks = vec![
cell_chunk(&[5, 70_000], &[0, 2], 0),
cell_chunk(&[1, 2, 3], &[0, 1, 2], 1),
];
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 2, 3, 1e4).expect("writer opens");
for chunk in chunks {
writer.write_cell_chunk(chunk).expect("write");
}
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert_eq!(reader.target_size(), Some(1e4));
assert!(reader.is_cell_based());
let back = reader.read_cells_parallel(&[0, 1]).expect("read");
assert_eq!(back.len(), 2);
assert_eq!(back[0].data_raw.iter().collect::<Vec<_>>(), vec![5, 70_000]);
assert_eq!(back[1].indices, vec![0, 1, 2]);
}
fn patch_file_header(path: &str, edit: impl FnOnce(&mut FileHeader)) {
let mut bytes = std::fs::read(path).expect("read back");
let (mut header, _) = decode_from_slice::<FileHeader, _>(&bytes[0..64], config::standard())
.expect("header decodes");
edit(&mut header);
let encoded = encode_to_vec(&header, config::standard()).expect("header encodes");
let mut slot = [0_u8; 64];
slot[..encoded.len()].copy_from_slice(&encoded);
bytes[0..64].copy_from_slice(&slot);
std::fs::write(path, bytes).expect("write back");
}
fn write_valid_file(path: &str) {
let mut writer = CellGeneSparseWriter::new(path, true, 1, 3, 1e4).expect("writer opens");
writer
.write_cell_chunk(cell_chunk(&[1, 2], &[0, 1], 0))
.expect("write");
writer.finalise().expect("finalise");
}
#[test]
fn test_reader_rejects_a_version_mismatch() {
let temp = TempBin::new("version_mismatch");
write_valid_file(temp.path());
patch_file_header(temp.path(), |h| h.version = SC_FILE_VERSION + 1);
match ParallelSparseReader::new(temp.path()) {
Err(BixverseErrors::FileVersionMismatch { expected, found }) => {
assert_eq!(expected, SC_FILE_VERSION);
assert_eq!(found, SC_FILE_VERSION + 1);
}
Err(other) => panic!("expected a version mismatch, got {other:?}"),
Ok(_) => panic!("a mismatched version must not open"),
}
}
#[test]
fn test_reader_rejects_a_bad_magic() {
let temp = TempBin::new("bad_magic");
write_valid_file(temp.path());
patch_file_header(temp.path(), |h| h.magic = *b"NOTOURS!");
assert!(
ParallelSparseReader::new(temp.path()).is_err(),
"a wrong magic string must not open"
);
}
#[test]
fn test_reader_rejects_a_main_header_offset_past_eof() {
let temp = TempBin::new("offset_past_eof");
write_valid_file(temp.path());
patch_file_header(temp.path(), |h| h.main_header_offset = u64::MAX / 2);
assert!(
ParallelSparseReader::new(temp.path()).is_err(),
"an out-of-range main header offset must not open"
);
}
#[test]
fn test_reader_rejects_a_truncated_file() {
let temp = TempBin::new("truncated");
write_valid_file(temp.path());
let bytes = std::fs::read(temp.path()).expect("read back");
std::fs::write(temp.path(), &bytes[..bytes.len() / 2]).expect("truncate");
assert!(
ParallelSparseReader::new(temp.path()).is_err(),
"a truncated file must not open"
);
}
#[test]
fn test_gene_based_file_round_trip() {
let temp = TempBin::new("gene_round_trip");
let mut writer =
CellGeneSparseWriter::new(temp.path(), false, 4, 2, 1e5).expect("writer opens");
writer
.write_gene_chunk(gene_chunk(&[9, 8], &[0, 3], 0))
.expect("write");
writer
.write_gene_chunk(gene_chunk(&[100_000], &[2], 1))
.expect("write");
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert_eq!(reader.target_size(), Some(1e5));
assert!(reader.is_gene_based());
let back = reader.read_gene_parallel(&[0, 1]).expect("read");
assert_eq!(back[0].data_raw.iter().collect::<Vec<_>>(), vec![9, 8]);
assert_eq!(back[1].data_raw.iter().collect::<Vec<_>>(), vec![100_000]);
}
#[test]
fn test_writer_rejects_wrong_chunk_orientation() {
let temp = TempBin::new("mode_mismatch");
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 1, 1, 0.0).expect("writer opens");
match writer.write_gene_chunk(gene_chunk(&[1], &[0], 0)) {
Err(BixverseErrors::ReaderModeMismatch { actual, requested }) => {
assert_eq!(actual, "cell-based");
assert_eq!(requested, "gene-based");
}
other => panic!("expected ReaderModeMismatch, got {other:?}"),
}
}
#[test]
fn test_raw_only_file_reports_no_target_size() {
let temp = TempBin::new("no_target_size");
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 1, 2, 0.0).expect("writer opens");
writer
.write_cell_chunk(cell_chunk(&[1, 2], &[0, 1], 0))
.expect("write");
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert_eq!(reader.target_size(), None);
assert_eq!(peek_target_size(temp.path()).expect("peek"), None);
}
#[derive(Encode, Decode, Serialize, Deserialize)]
struct LegacyFileHeader {
magic: [u8; 8],
version: u32,
main_header_offset: u64,
cell_based: bool,
_reserved_1: [u8; 32],
_reserved_2: [u8; 3],
}
fn encode_slot<T: Serialize>(header: &T) -> [u8; 64] {
let encoded = encode_to_vec(header, config::standard()).expect("encode");
assert!(encoded.len() <= 64, "header must fit the fixed slot");
let mut slot = [0_u8; 64];
slot[..encoded.len()].copy_from_slice(&encoded);
slot
}
#[test]
fn test_legacy_header_decodes_with_zero_target_size() {
let legacy = LegacyFileHeader {
magic: *b"SCRNASEQ",
version: SC_FILE_VERSION,
main_header_offset: 123_456,
cell_based: true,
_reserved_1: [0; 32],
_reserved_2: [0; 3],
};
let slot = encode_slot(&legacy);
let (decoded, _) =
decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode as v3");
assert_eq!(decoded.magic, *b"SCRNASEQ");
assert_eq!(decoded.version, SC_FILE_VERSION);
assert_eq!(decoded.main_header_offset, 123_456);
assert!(decoded.cell_based);
assert_eq!(decoded.target_size, 0.0);
}
#[test]
fn test_new_header_still_decodes_on_the_old_layout() {
let mut header = FileHeader::new(false, 1e4);
header.main_header_offset = 987_654_321;
let slot = encode_slot(&header);
let (decoded, _) = decode_from_slice::<LegacyFileHeader, _>(&slot, config::standard())
.expect("decode as legacy");
assert_eq!(decoded.magic, *b"SCRNASEQ");
assert_eq!(decoded.version, SC_FILE_VERSION);
assert_eq!(decoded.main_header_offset, 987_654_321);
assert!(!decoded.cell_based);
}
#[test]
fn test_target_size_survives_header_round_trip() {
let slot = encode_slot(&FileHeader::new(true, 1e5));
let (decoded, _) =
decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode");
assert_eq!(decoded.target_size, 1e5);
}
#[derive(Encode, Decode, Serialize, Deserialize)]
struct TargetSizeFileHeader {
magic: [u8; 8],
version: u32,
main_header_offset: u64,
cell_based: bool,
target_size: f32,
_reserved_1: [u8; 28],
_reserved_2: [u8; 3],
}
#[test]
fn test_pre_index_header_decodes_with_no_library_index() {
let old = TargetSizeFileHeader {
magic: *b"SCRNASEQ",
version: SC_FILE_VERSION,
main_header_offset: 123_456,
cell_based: true,
target_size: 1e4,
_reserved_1: [0; 28],
_reserved_2: [0; 3],
};
let slot = encode_slot(&old);
let (decoded, _) =
decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode");
assert_eq!(decoded.main_header_offset, 123_456);
assert_eq!(decoded.target_size, 1e4);
assert_eq!(decoded.library_index_offset, 0);
}
#[test]
fn test_header_with_library_index_decodes_on_the_pre_index_layout() {
let mut header = FileHeader::new(true, 1e4);
header.main_header_offset = 987_654_321;
header.library_index_offset = u64::MAX;
let slot = encode_slot(&header);
let (decoded, _) = decode_from_slice::<TargetSizeFileHeader, _>(&slot, config::standard())
.expect("decode as pre-index");
assert_eq!(decoded.magic, *b"SCRNASEQ");
assert_eq!(decoded.version, SC_FILE_VERSION);
assert_eq!(decoded.main_header_offset, 987_654_321);
assert!(decoded.cell_based);
assert_eq!(decoded.target_size, 1e4);
}
fn read_file_header(path: &str) -> FileHeader {
let bytes = std::fs::read(path).expect("read back");
decode_from_slice::<FileHeader, _>(&bytes[0..64], config::standard())
.expect("header decodes")
.0
}
fn write_index_test_file(path: &str, compressed: bool) {
let chunks = vec![
cell_chunk(&[5, 70_000], &[0, 2], 2),
cell_chunk(&[1, 2, 3], &[0, 1, 2], 0),
cell_chunk(&[9], &[1], 1),
];
let mut writer = CellGeneSparseWriter::new(path, true, 3, 3, 1e4).expect("writer opens");
if compressed {
write_cell_chunks_parallel(&chunks, &mut writer).expect("write");
} else {
for chunk in chunks {
writer.write_cell_chunk(chunk).expect("write");
}
}
writer.finalise().expect("finalise");
}
fn library_sizes_both_ways(
reader: &ParallelSparseReader,
indices: &[usize],
) -> (Vec<usize>, Vec<usize>) {
let answered = reader.read_cell_library_sizes(indices).expect("read");
let stored = reader
.read_cells_parallel(indices)
.expect("read chunks")
.iter()
.map(|chunk| chunk.library_size)
.collect();
(answered, stored)
}
#[test]
fn test_library_index_matches_chunk_headers() {
for compressed in [false, true] {
let temp = TempBin::new(&format!("library_index_{compressed}"));
write_index_test_file(temp.path(), compressed);
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert!(reader.library_index.is_some());
let (answered, stored) = library_sizes_both_ways(&reader, &[1, 2, 0, 2]);
assert_eq!(answered, vec![9, 70_005, 6, 70_005]);
assert_eq!(answered, stored);
}
}
#[test]
fn test_library_sizes_fall_back_without_an_index() {
let temp = TempBin::new("library_index_absent");
write_index_test_file(temp.path(), false);
patch_file_header(temp.path(), |h| h.library_index_offset = 0);
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert!(reader.library_index.is_none());
let (answered, _) = library_sizes_both_ways(&reader, &[0, 1, 2]);
assert_eq!(answered, vec![6, 9, 70_005]);
}
#[test]
fn test_reader_rejects_a_library_index_past_eof() {
let temp = TempBin::new("library_index_past_eof");
write_valid_file(temp.path());
let len = std::fs::metadata(temp.path()).expect("stat").len();
patch_file_header(temp.path(), |h| h.library_index_offset = len);
assert!(matches!(
ParallelSparseReader::new(temp.path()),
Err(BixverseErrors::HeaderDecodeFailed)
));
}
#[test]
fn test_reader_rejects_a_library_index_with_the_wrong_count() {
let temp = TempBin::new("library_index_wrong_count");
write_valid_file(temp.path());
let main_header_offset = read_file_header(temp.path()).main_header_offset;
patch_file_header(temp.path(), |h| h.library_index_offset = main_header_offset);
assert!(matches!(
ParallelSparseReader::new(temp.path()),
Err(BixverseErrors::HeaderDecodeFailed)
));
}
#[test]
fn test_library_index_on_the_ingest_write_path() {
let temp = TempBin::new("library_index_ingest");
let mut rows: Vec<Vec<(u32, u32)>> = (0..300_u32)
.map(|c| {
(0..20_u32)
.filter(|g| (c * 7 + g * 3) % 5 == 0)
.map(|g| (g, 1 + (c + g) % 4 + if c == 7 { 70_000 } else { 0 }))
.collect()
})
.collect();
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 300, 20, 1e4).expect("writer opens");
let (_, reported) = write_cell_rows(&mut rows, 0, 1e4, &mut writer).expect("write");
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert!(reader.library_index.is_some());
let (answered, stored) = library_sizes_both_ways(&reader, &(0..300).collect::<Vec<_>>());
assert_eq!(answered, stored);
assert_eq!(answered, reported);
}
#[test]
fn test_gene_based_file_has_no_library_index() {
let temp = TempBin::new("library_index_gene_file");
let mut writer =
CellGeneSparseWriter::new(temp.path(), false, 2, 1, 1e4).expect("writer opens");
writer
.write_gene_chunk(gene_chunk(&[1, 2], &[0, 1], 0))
.expect("write");
writer.finalise().expect("finalise");
assert_eq!(read_file_header(temp.path()).library_index_offset, 0);
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert!(reader.library_index.is_none());
}
}