use crate::hdf5_io::*;
use crate::sparse_io::*;
use hdf5::types::{H5Type, VarLenUnicode};
use legume_numeric::matrix::common_io::read_lines;
use log::info;
use std::collections::HashMap;
const GZIP_LEVEL: u8 = 4;
const DONOR_COLUMN: &str = "donor_id";
const MAX_CHUNK_ELEMS: usize = 1 << 20;
#[derive(Clone, Debug, Default)]
pub struct H5adExport {
pub obs: Option<Box<str>>,
pub var: Option<Box<str>>,
}
pub fn h5ad_path(data_file: &str, output: Option<&str>) -> String {
match output {
Some(o) if o.ends_with(".h5ad") => o.to_string(),
Some(o) => format!("{}.h5ad", o),
None => format!("{}.h5ad", strip_backend_suffix(data_file)),
}
}
pub fn to_h5ad(
data: &mut dyn SparseIo<IndexIter = Vec<usize>>,
output: &str,
opts: &H5adExport,
) -> anyhow::Result<()> {
info!("Exporting to {}", output);
let nrow = data
.num_rows()
.ok_or_else(|| anyhow::anyhow!("backend has no `nrow`"))?; let ncol = data
.num_columns()
.ok_or_else(|| anyhow::anyhow!("backend has no `ncol`"))?; let nnz = data
.num_non_zeros()
.ok_or_else(|| anyhow::anyhow!("backend has no `nnz`"))?;
let row_names = data.row_names()?; let col_names = data.column_names()?; if row_names.len() != nrow {
anyhow::bail!("row name count {} != nrow {}", row_names.len(), nrow);
}
if col_names.len() != ncol {
anyhow::bail!("column name count {} != ncol {}", col_names.len(), ncol);
}
info!("preloading columns ({} cells, {} non-zeros)...", ncol, nnz);
data.preload_columns()?;
let (indptr, indices, values) = data.csc_column_arrays().ok_or_else(|| {
anyhow::anyhow!(
"to-h5ad needs the whole CSC resident ({} non-zeros x 12 bytes) and the preload \
was declined by the memory budget; raise LEGUME_PRELOAD_BUDGET_BYTES to export \
this matrix",
nnz
)
})?;
debug_assert_eq!(indptr.len(), ncol + 1);
debug_assert_eq!(indices.len(), values.len());
let file = hdf5::File::create(output)?;
write_str_attr(&file, "encoding-type", "anndata")?;
write_str_attr(&file, "encoding-version", "0.1.0")?;
write_csr_x(&file, ncol, nrow, indptr, indices, values)?;
write_dataframe(&file, "obs", &col_names, opts.obs.as_deref(), true)?;
write_dataframe(&file, "var", &row_names, opts.var.as_deref(), false)?;
file.flush()?;
info!(
"done: {} ({} obs x {} var, {} non-zeros)",
output, ncol, nrow, nnz
);
Ok(())
}
fn write_csr_x(
file: &hdf5::File,
n_obs: usize,
n_var: usize,
indptr: &[u64],
indices: &[u64],
values: &[f32],
) -> anyhow::Result<()> {
let x = file.create_group("X")?;
write_str_attr(&x, "encoding-type", "csr_matrix")?;
write_str_attr(&x, "encoding-version", "0.1.0")?;
x.new_attr::<i64>()
.shape([2])
.create("shape")?
.write_raw(&[n_obs as i64, n_var as i64])?;
write_gzip_dataset(&x, "data", values)?;
if values.len() > i32::MAX as usize || n_var > i32::MAX as usize {
write_index_arrays(&x, indptr, indices, |v| v as i64)?;
} else {
write_index_arrays(&x, indptr, indices, |v| v as i32)?;
}
Ok(())
}
fn write_index_arrays<T: H5Type>(
x: &hdf5::Group,
indptr: &[u64],
indices: &[u64],
cast: impl Fn(u64) -> T,
) -> anyhow::Result<()> {
write_gzip_dataset(
x,
"indices",
&indices.iter().map(|&v| cast(v)).collect::<Vec<T>>(),
)?;
write_gzip_dataset(
x,
"indptr",
&indptr.iter().map(|&v| cast(v)).collect::<Vec<T>>(),
)?;
Ok(())
}
fn write_dataframe(
file: &hdf5::File,
name: &str,
index: &[Box<str>],
meta_tsv: Option<&str>,
barcode_fallback: bool,
) -> anyhow::Result<()> {
let group = file.create_group(name)?;
write_str_attr(&group, "encoding-type", "dataframe")?;
write_str_attr(&group, "encoding-version", "0.2.0")?;
write_str_attr(&group, "_index", "_index")?;
write_string_array(&group, "_index", index)?;
let (col_names, columns) = match meta_tsv {
Some(path) => load_aligned_metadata(path, index, barcode_fallback)?,
None => (vec![], vec![]),
};
for (cname, col) in col_names.iter().zip(columns.iter()) {
write_string_array(&group, cname, col)?;
}
let order: Vec<VarLenUnicode> = col_names
.iter()
.map(|c| vlu(c))
.collect::<anyhow::Result<_>>()?;
group
.new_attr::<VarLenUnicode>()
.shape([order.len()])
.create("column-order")?
.write_raw(&order)?;
Ok(())
}
type AlignedMetadata = (Vec<Box<str>>, Vec<Vec<Box<str>>>);
fn load_aligned_metadata(
path: &str,
names: &[Box<str>],
barcode_fallback: bool,
) -> anyhow::Result<AlignedMetadata> {
let lines = read_lines(path)?;
if lines.is_empty() {
log::warn!("metadata file {} is empty; no columns attached", path);
return Ok((vec![], vec![]));
}
let header: Vec<&str> = lines[0].split('\t').collect();
let (keep_idx, col_names): (Vec<usize>, Vec<Box<str>>) = header
.iter()
.enumerate()
.skip(1)
.filter(|(_, &c)| c != "_index")
.map(|(i, &c)| (i, Box::<str>::from(c)))
.unzip();
let rows: Vec<Vec<&str>> = lines[1..]
.iter()
.map(|line| line.split('\t').collect())
.collect();
let kept = |row: usize| {
let fields = &rows[row];
keep_idx
.iter()
.map(move |&hidx| fields.get(hidx).copied().unwrap_or("NA"))
};
let mut key2row: HashMap<Box<str>, Option<usize>> = HashMap::new();
let mut add_key = |key: Box<str>, row: usize| {
key2row
.entry(key)
.and_modify(|listed| *listed = listed.filter(|&first| kept(first).eq(kept(row))))
.or_insert(Some(row));
};
let donor_col = header.iter().position(|&c| c == DONOR_COLUMN);
for (i, fields) in rows.iter().enumerate() {
let Some(&key) = fields.first() else {
continue;
};
add_key(key.into(), i);
let donor = donor_col.and_then(|d| fields.get(d));
if let (true, Some(donor)) = (barcode_fallback, donor) {
if !key.contains(COLUMN_SEP) {
add_key(format!("{key}{COLUMN_SEP}{donor}").into(), i);
}
}
}
let mut columns: Vec<Vec<Box<str>>> = vec![Vec::with_capacity(names.len()); col_names.len()];
let mut n_matched = 0usize;
let mut n_ambiguous = 0usize;
for nm in names {
let listed = key2row.get(nm).or_else(|| {
if barcode_fallback {
nm.split(COLUMN_SEP).next().and_then(|bc| key2row.get(bc))
} else {
None
}
});
n_ambiguous += usize::from(listed == Some(&None));
match listed.copied().flatten() {
Some(row) => {
n_matched += 1;
for (column, value) in columns.iter_mut().zip(kept(row)) {
column.push(value.into());
}
}
None => {
for col in columns.iter_mut() {
col.push("NA".into());
}
}
}
}
if n_ambiguous > 0 {
log::warn!(
"{} of {} entries match a key that {} lists on rows that differ; \
their metadata is left NA",
n_ambiguous,
names.len(),
path
);
}
info!(
"attached {} metadata column(s) from {}; matched {}/{} entries",
col_names.len(),
path,
n_matched,
names.len()
);
Ok((col_names, columns))
}
pub(crate) fn vlu(s: &str) -> anyhow::Result<VarLenUnicode> {
s.parse::<VarLenUnicode>()
.map_err(|e| anyhow::anyhow!("cannot encode '{}' as an HDF5 string: {}", s, e))
}
pub(crate) fn write_str_attr(loc: &hdf5::Location, name: &str, value: &str) -> anyhow::Result<()> {
loc.new_attr::<VarLenUnicode>()
.create(name)?
.write_scalar(&vlu(value)?)?;
Ok(())
}
fn write_gzip_dataset<T: H5Type>(
group: &hdf5::Group,
name: &str,
data: &[T],
) -> anyhow::Result<()> {
let n = data.len();
let ds = if n > 0 {
let chunk = n.min(MAX_CHUNK_ELEMS);
group
.new_dataset::<T>()
.shape(n)
.deflate(GZIP_LEVEL)
.chunk([chunk])
.create(name)?
} else {
group.new_dataset::<T>().shape(n).create(name)?
};
ds.write_raw(data)?;
Ok(())
}
fn write_string_array(group: &hdf5::Group, name: &str, values: &[Box<str>]) -> anyhow::Result<()> {
let encoded: Vec<VarLenUnicode> = values
.iter()
.map(|s| vlu(s))
.collect::<anyhow::Result<_>>()?;
let ds = group
.new_dataset::<VarLenUnicode>()
.shape(encoded.len())
.create(name)?;
ds.write_raw(&encoded)?;
write_str_attr(&ds, "encoding-type", "string-array")?;
write_str_attr(&ds, "encoding-version", "0.2.0")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn names(xs: &[&str]) -> Vec<Box<str>> {
xs.iter().map(|&x| Box::from(x)).collect()
}
#[test]
fn a_barcode_shared_by_two_donors_keeps_each_donors_metadata() {
let dir = tempfile::tempdir().unwrap();
let cells = names(&["AAAC-1@donorA", "AAAC-1@donorB", "GGGT-1@donorA"]);
let with_donor = dir.path().join("with_donor.tsv");
std::fs::write(
&with_donor,
"barcode\tdonor_id\tcell_type\n\
AAAC-1\tdonorA\tT\n\
AAAC-1\tdonorB\tB\n\
GGGT-1\tdonorA\tNK\n",
)
.unwrap();
let (cols, data) =
load_aligned_metadata(with_donor.to_str().unwrap(), &cells, true).unwrap();
assert_eq!(cols, names(&["donor_id", "cell_type"]));
assert_eq!(data[0], names(&["donorA", "donorB", "donorA"]));
assert_eq!(data[1], names(&["T", "B", "NK"]));
let bare = dir.path().join("bare.tsv");
std::fs::write(
&bare,
"barcode\tcell_type\nAAAC-1\tT\nAAAC-1\tB\nGGGT-1\tNK\n",
)
.unwrap();
let (_, data) = load_aligned_metadata(bare.to_str().unwrap(), &cells, true).unwrap();
assert_eq!(data[0], names(&["NA", "NA", "NK"]));
}
#[test]
fn a_key_listed_twice_is_ambiguous_only_when_its_rows_differ() {
let dir = tempfile::tempdir().unwrap();
let table = dir.path().join("table.tsv");
std::fs::write(
&table,
"key\tkind\tnote\n\
A\tx\tsame\n\
A\tx\tsame\n\
B\ty\tone\n\
B\ty\tother\n\
C\tz\tonly\n",
)
.unwrap();
let entries = names(&["A", "B", "C", "D"]);
for barcode_fallback in [true, false] {
let (cols, data) =
load_aligned_metadata(table.to_str().unwrap(), &entries, barcode_fallback).unwrap();
assert_eq!(cols, names(&["kind", "note"]));
assert_eq!(data[0], names(&["x", "NA", "z", "NA"]));
assert_eq!(data[1], names(&["same", "NA", "only", "NA"]));
}
let obs = dir.path().join("obs.tsv");
std::fs::write(
&obs,
"barcode\tdonor_id\tcell_type\n\
AAAC-1\tdonorA\tT\n\
AAAC-1\tdonorA\tT\n\
GGGT-1\tdonorA\tNK\n\
GGGT-1\tdonorA\tB\n",
)
.unwrap();
let cells = names(&["AAAC-1@donorA", "AAAC-1@donorB", "GGGT-1@donorA"]);
let (_, data) = load_aligned_metadata(obs.to_str().unwrap(), &cells, true).unwrap();
assert_eq!(data[1], names(&["T", "T", "NA"]));
}
}