use bincode::{Decode, Encode, config, decode_from_slice};
use lz4_flex::decompress_size_prepended;
use memmap2::MmapOptions;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::path::Path;
use thousands::Separable;
use crate::prelude::*;
use crate::single_cell::sc_data::data_io::{
CellGeneSparseWriter, CscGeneChunk, CsrCellChunk, RawCounts, SparseDataHeader,
};
#[repr(C)]
#[derive(Encode, Decode, Serialize, Deserialize)]
struct FileHeaderV2 {
magic: [u8; 8],
version: u32,
main_header_offset: u64,
cell_based: bool,
_reserved_1: [u8; 32],
_reserved_2: [u8; 3],
}
fn read_v2_cell_chunk(buffer: &[u8]) -> std::io::Result<CsrCellChunk> {
if buffer.len() < 32 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Buffer too small for v2 cell chunk header",
));
}
let header = &buffer[0..32];
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 data_start = 32;
let data_end = data_start + data_raw_len * 2; let norm_end = data_end + data_norm_len * 2;
let data_raw_u16 = unsafe {
let ptr = buffer.as_ptr().add(data_start) as *const u16;
std::slice::from_raw_parts(ptr, data_raw_len).to_vec()
};
let data_norm: Vec<F16> = unsafe {
let ptr = buffer.as_ptr().add(data_end) as *const u16;
std::slice::from_raw_parts(ptr, data_norm_len)
.iter()
.map(|&bits| F16::from_bits(bits))
.collect()
};
let indices_u16 = unsafe {
let ptr = buffer.as_ptr().add(norm_end) as *const u16;
std::slice::from_raw_parts(ptr, col_indices_len).to_vec()
};
let indices: Vec<u32> = indices_u16.iter().map(|&x| x as u32).collect();
Ok(CsrCellChunk {
data_raw: RawCounts::U16(data_raw_u16),
data_norm,
library_size,
indices,
original_index,
to_keep,
})
}
fn read_v2_gene_chunk(buffer: &[u8]) -> std::io::Result<CscGeneChunk> {
if buffer.len() < 36 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Buffer too small for v2 gene chunk header",
));
}
let header = &buffer[0..36];
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 data_start = 36;
let data_end = data_start + data_raw_len * 2; let norm_end = data_end + data_norm_len * 2;
let data_raw_u16 = unsafe {
let ptr = buffer.as_ptr().add(data_start) as *const u16;
std::slice::from_raw_parts(ptr, data_raw_len).to_vec()
};
let data_norm: Vec<F16> = unsafe {
let ptr = buffer.as_ptr().add(data_end) as *const u16;
let slice = std::slice::from_raw_parts(ptr, data_norm_len);
slice.iter().map(|&bits| F16::from_bits(bits)).collect()
};
let indices = unsafe {
let ptr = buffer.as_ptr().add(norm_end) as *const u32;
std::slice::from_raw_parts(ptr, row_indices_len).to_vec()
};
Ok(CscGeneChunk {
data_raw: RawCounts::U16(data_raw_u16),
data_norm,
avg_exp,
nnz,
indices,
original_index,
to_keep,
})
}
pub fn migrate_v2_to_v3<P: AsRef<Path>>(
input_path: P,
output_path: P,
verbose: bool,
) -> Result<(), BixverseErrors> {
let file = File::open(input_path.as_ref())?;
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)?
};
let file_header_bytes = &mmap[0..64];
let (file_header, _) =
decode_from_slice::<FileHeaderV2, _>(file_header_bytes, config::standard()).map_err(
|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Failed to decode v2 file header",
)
},
)?;
if file_header.version != SC_FILE_VERSION_V2 {
return Err(BixverseErrors::FileVersionMismatch {
expected: SC_FILE_VERSION_V2,
found: file_header.version,
});
}
let cell_based = file_header.cell_based;
let main_header_offset = file_header.main_header_offset as usize;
let header_size = u64::from_le_bytes(
mmap[main_header_offset..main_header_offset + 8]
.try_into()
.unwrap(),
) as usize;
let header_bytes = &mmap[main_header_offset + 8..main_header_offset + 8 + header_size];
let (header, _) = decode_from_slice::<SparseDataHeader, _>(header_bytes, config::standard())
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "Failed to decode header")
})?;
let chunks_start: u64 = 64;
let total_chunks = header.no_chunks;
if verbose {
println!(
"Migrating v2 -> v3: {} ({} chunks, cell_based={})",
input_path.as_ref().display(),
total_chunks.separate_with_underscores(),
cell_based,
);
}
let mut writer = CellGeneSparseWriter::new(
output_path.as_ref(),
cell_based,
header.total_cells,
header.total_genes,
0.0,
)?;
let mut chunk_to_original: Vec<(usize, usize)> = header
.index_map
.iter()
.map(|(&original_idx, &chunk_idx)| (chunk_idx, original_idx))
.collect();
chunk_to_original.sort_by_key(|&(chunk_idx, _)| chunk_idx);
for (chunk_pos, (_, _original_idx)) in chunk_to_original.iter().enumerate() {
let chunk_offset = (chunks_start + header.chunk_offsets[chunk_pos]) as usize;
let compressed_size =
u64::from_le_bytes(mmap[chunk_offset..chunk_offset + 8].try_into().unwrap()) as usize;
let compressed = &mmap[chunk_offset + 8..chunk_offset + 8 + compressed_size];
let decompressed = decompress_size_prepended(compressed).unwrap();
if cell_based {
let chunk = read_v2_cell_chunk(&decompressed)?;
writer.write_cell_chunk(chunk)?;
} else {
let chunk = read_v2_gene_chunk(&decompressed)?;
writer.write_gene_chunk(chunk)?;
}
if verbose && (chunk_pos + 1) % 100_000 == 0 {
println!(
" Migrated {} / {} chunks",
(chunk_pos + 1).separate_with_underscores(),
total_chunks.separate_with_underscores(),
);
}
}
writer.finalise()?;
if verbose {
println!(
" Migrated {} / {} chunks (complete). Written to: {}",
total_chunks.separate_with_underscores(),
total_chunks.separate_with_underscores(),
output_path.as_ref().display(),
);
}
Ok(())
}
pub fn migrate_v2_to_v3_pair<P: AsRef<Path>>(
cell_input: P,
cell_output: P,
gene_input: P,
gene_output: P,
verbose: bool,
) -> Result<(), BixverseErrors> {
if verbose {
println!("Migrating cell-based file...");
}
migrate_v2_to_v3(&cell_input, &cell_output, verbose)?;
if verbose {
println!("Migrating gene-based file...");
}
migrate_v2_to_v3(&gene_input, &gene_output, verbose)?;
if verbose {
println!("Migration complete.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use bincode::serde::encode_to_vec;
use lz4_flex::compress_prepend_size;
use rustc_hash::FxHashMap;
use std::io::Write;
struct TempBins(Vec<std::path::PathBuf>);
impl Drop for TempBins {
fn drop(&mut self) {
for p in &self.0 {
let _ = std::fs::remove_file(p);
}
}
}
fn write_v2_fixture(
path: &std::path::Path,
version: u32,
raw: &[u16],
indices: &[u16],
) -> std::io::Result<()> {
let mut chunk = Vec::new();
chunk.extend_from_slice(&(raw.len() as u32).to_le_bytes());
chunk.extend_from_slice(&(raw.len() as u32).to_le_bytes());
chunk.extend_from_slice(&(indices.len() as u32).to_le_bytes());
chunk.extend_from_slice(&(raw.iter().map(|&v| v as u64).sum::<u64>()).to_le_bytes());
chunk.extend_from_slice(&0_u64.to_le_bytes()); chunk.extend_from_slice(&[1_u8, 0, 0, 0]); for &v in raw {
chunk.extend_from_slice(&v.to_le_bytes());
}
for &v in raw {
chunk.extend_from_slice(
&F16::from(half::f16::from_f32(v as f32))
.to_bits()
.to_le_bytes(),
);
}
for &i in indices {
chunk.extend_from_slice(&i.to_le_bytes());
}
let compressed = compress_prepend_size(&chunk);
let mut index_map = FxHashMap::default();
index_map.insert(0_usize, 0_usize);
let sparse_header = SparseDataHeader {
total_cells: 1,
total_genes: 16,
cell_based: true,
no_chunks: 1,
chunk_offsets: vec![0],
index_map,
};
let sparse_header_enc =
encode_to_vec(&sparse_header, config::standard()).expect("sparse header encodes");
let main_header_offset = 64 + 8 + compressed.len();
let file_header = FileHeaderV2 {
magic: *b"SCRNASEQ",
version,
main_header_offset: main_header_offset as u64,
cell_based: true,
_reserved_1: [0; 32],
_reserved_2: [0; 3],
};
let file_header_enc =
encode_to_vec(&file_header, config::standard()).expect("file header encodes");
let mut slot = [0_u8; 64];
slot[..file_header_enc.len()].copy_from_slice(&file_header_enc);
let mut f = File::create(path)?;
f.write_all(&slot)?;
f.write_all(&(compressed.len() as u64).to_le_bytes())?;
f.write_all(&compressed)?;
f.write_all(&(sparse_header_enc.len() as u64).to_le_bytes())?;
f.write_all(&sparse_header_enc)?;
f.flush()
}
#[test]
fn test_migration_accepts_v2_and_rejects_v3() {
let dir = std::env::temp_dir();
let v2_path = dir.join("bixverse_migrate_v2_src.bin");
let v3_path = dir.join("bixverse_migrate_v3_out.bin");
let wrong_path = dir.join("bixverse_migrate_v3_src.bin");
let unused_path = dir.join("bixverse_migrate_unused.bin");
let _guard = TempBins(vec![
v2_path.clone(),
v3_path.clone(),
wrong_path.clone(),
unused_path.clone(),
]);
let raw = [3_u16, 11, 250];
let indices = [0_u16, 4, 9];
write_v2_fixture(&v2_path, SC_FILE_VERSION_V2, &raw, &indices).expect("fixture written");
migrate_v2_to_v3(&v2_path, &v3_path, false).expect("v2 file migrates");
let reader = ParallelSparseReader::new(v3_path.to_str().unwrap()).expect("v3 file opens");
let cells = reader
.read_cells_parallel(&[0])
.expect("read migrated cell");
assert_eq!(
cells[0].data_raw.iter().collect::<Vec<_>>(),
raw.iter().map(|&v| v as u32).collect::<Vec<_>>()
);
assert_eq!(
cells[0].indices,
indices.iter().map(|&v| v as u32).collect::<Vec<_>>()
);
write_v2_fixture(&wrong_path, SC_FILE_VERSION, &raw, &indices).expect("fixture written");
match migrate_v2_to_v3(&wrong_path, &unused_path, false) {
Err(BixverseErrors::FileVersionMismatch { expected, found }) => {
assert_eq!(expected, SC_FILE_VERSION_V2);
assert_eq!(found, SC_FILE_VERSION);
}
other => panic!("expected FileVersionMismatch, got {other:?}"),
}
}
}