1use crate::hdf5_io::*;
2use crate::sparse_io::*;
3use crate::sparse_util::*;
4use crate::utilities::name_matching::{
5 colon_peak_names, compose_id_name, filter_row_indices_by_type, make_names_unique,
6};
7use crate::zarr_io::*;
8
9use legume_numeric::matrix::common_io::*;
10use log::info;
11
12pub struct TenxMatrix {
15 pub triplets: Vec<(u64, u64, f32)>,
16 pub reach: (usize, usize),
19 pub row_ids: Option<Vec<Box<str>>>,
20 pub row_names: Option<Vec<Box<str>>>,
21 pub row_types: Option<Vec<Box<str>>>,
22 pub column_names: Option<Vec<Box<str>>>,
23 pub keep_rows: Option<Vec<bool>>,
26}
27
28fn axis_len(named: Option<usize>, reach: usize, what: &str) -> anyhow::Result<usize> {
33 match named {
34 Some(n) if n >= reach => Ok(n),
35 Some(n) => anyhow::bail!("the file names {n} {what} but its data reaches {reach}"),
36 None => Ok(reach),
37 }
38}
39
40pub fn write_10x_matrix(
46 m: TenxMatrix,
47 select_row_type: &str,
48 remove_row_type: &str,
49 backend_file: &str,
50 backend: &SparseIoBackend,
51) -> anyhow::Result<()> {
52 let TenxMatrix {
53 mut triplets,
54 reach,
55 row_ids,
56 row_names,
57 row_types,
58 column_names,
59 keep_rows,
60 } = m;
61 let nrows = axis_len(row_ids.as_ref().map(Vec::len), reach.0, "rows")?;
62 let ncols = axis_len(column_names.as_ref().map(Vec::len), reach.1, "columns")?;
63 let index = |n: usize| {
64 (0..n)
65 .map(|i| i.to_string().into_boxed_str())
66 .collect::<Vec<_>>()
67 };
68 let mut row_ids = row_ids.unwrap_or_else(|| index(nrows));
69 let mut row_names = row_names.unwrap_or_else(|| vec![Box::from(""); nrows]);
70 anyhow::ensure!(
71 row_names.len() == nrows
72 && row_types.as_ref().is_none_or(|t| t.len() == nrows)
73 && keep_rows.as_ref().is_none_or(|k| k.len() == nrows),
74 "the file lists {nrows} row ids but {} row names and {} row types",
75 row_names.len(),
76 row_types.as_ref().map_or(0, Vec::len)
77 );
78 let column_names = column_names.unwrap_or_else(|| index(ncols));
79
80 let n_peaks = colon_peak_names(&mut row_ids, &mut row_names, row_types.as_deref());
81 if n_peaks > 0 {
82 info!("{n_peaks} peak names rewritten in chr:start-end form");
83 }
84 let mut row_ids = compose_id_name(row_ids, row_names);
85 make_names_unique(&mut row_ids);
86 let mut keep = match &row_types {
87 Some(types) => filter_row_indices_by_type(types, select_row_type, remove_row_type),
88 None => (0..nrows).collect(),
89 };
90 if let Some(keep_rows) = &keep_rows {
91 keep.retain(|&i| keep_rows[i]);
92 }
93 if keep.len() < nrows {
95 info!("Keeping {} of {nrows} rows", keep.len());
96 let mut new_row = vec![None; nrows];
97 for (new, &old) in keep.iter().enumerate() {
98 new_row[old] = Some(new as u64);
99 }
100 triplets.retain_mut(|(i, _, _)| match new_row[*i as usize] {
101 Some(new) => {
102 *i = new;
103 true
104 }
105 None => false,
106 });
107 row_ids = keep
108 .iter()
109 .map(|&i| std::mem::take(&mut row_ids[i]))
110 .collect();
111 }
112
113 let nnz = triplets.len();
114 info!("Matrix: {} x {ncols}, {nnz} non-zeros", row_ids.len());
115 let mut out = create_sparse_from_triplets_owned(
116 triplets,
117 (row_ids.len(), ncols, nnz),
118 Some(backend_file),
119 Some(backend),
120 )?;
121 info!("Created sparse matrix: {}", backend_file);
122 out.register_row_names_vec(&row_ids);
123 out.register_column_names_vec(&column_names);
124 Ok(())
125}
126
127#[derive(Clone, Debug)]
131pub struct MatrixLayout {
132 pub data_field: Box<str>,
133 pub indices_field: Box<str>,
134 pub indptr_field: Box<str>,
135 pub pointer_type: IndexPointerType,
136 pub row_id_field: Box<str>,
137 pub row_name_field: Box<str>,
138 pub row_type_field: Box<str>,
139 pub select_row_type: Box<str>,
141 pub remove_row_type: Box<str>,
143 pub column_name_field: Box<str>,
144}
145
146impl MatrixLayout {
147 pub const H5_ROOT: &'static str = "matrix";
150 pub const H5_DATA: &'static str = "data";
151 pub const H5_INDICES: &'static str = "indices";
152 pub const H5_INDPTR: &'static str = "indptr";
153 pub const H5_ROW_IDS: &'static str = "features/id";
154 pub const H5_ROW_NAMES: &'static str = "features/name";
155 pub const H5_ROW_TYPES: &'static str = "features/feature_type";
156 pub const H5_COLUMN_NAMES: &'static str = "barcodes";
157 pub const XENIUM_DATA: &'static str = "/cell_features/data";
158 pub const XENIUM_INDICES: &'static str = "/cell_features/indices";
159 pub const XENIUM_INDPTR: &'static str = "/cell_features/indptr";
160 pub const XENIUM_ROW_IDS: &'static str = "/cell_features/feature_ids";
161 pub const XENIUM_ROW_NAMES: &'static str = "/cell_features/feature_keys";
162 pub const XENIUM_ROW_TYPES: &'static str = "/cell_features/feature_types";
163 pub const XENIUM_COLUMN_NAMES: &'static str = "/cell_features/cell_id";
164 pub const SELECT_ROW_TYPES: &'static str = "gene,peak";
166 pub const REMOVE_ROW_TYPES: &'static str = "aggregate";
168
169 pub fn cell_ranger_h5() -> Self {
172 Self {
173 data_field: Self::H5_DATA.into(),
174 indices_field: Self::H5_INDICES.into(),
175 indptr_field: Self::H5_INDPTR.into(),
176 pointer_type: IndexPointerType::Column,
177 row_id_field: Self::H5_ROW_IDS.into(),
178 row_name_field: Self::H5_ROW_NAMES.into(),
179 row_type_field: Self::H5_ROW_TYPES.into(),
180 select_row_type: Self::SELECT_ROW_TYPES.into(),
181 remove_row_type: Self::REMOVE_ROW_TYPES.into(),
182 column_name_field: Self::H5_COLUMN_NAMES.into(),
183 }
184 }
185
186 pub fn xenium_zarr() -> Self {
189 Self {
190 data_field: Self::XENIUM_DATA.into(),
191 indices_field: Self::XENIUM_INDICES.into(),
192 indptr_field: Self::XENIUM_INDPTR.into(),
193 pointer_type: IndexPointerType::Row,
194 row_id_field: Self::XENIUM_ROW_IDS.into(),
195 row_name_field: Self::XENIUM_ROW_NAMES.into(),
196 row_type_field: Self::XENIUM_ROW_TYPES.into(),
197 select_row_type: Self::SELECT_ROW_TYPES.into(),
198 remove_row_type: Self::REMOVE_ROW_TYPES.into(),
199 column_name_field: Self::XENIUM_COLUMN_NAMES.into(),
200 }
201 }
202}
203
204#[cfg(feature = "hdf5")]
210pub fn build_from_h5_matrix(
211 h5_file: &str,
212 root_group: &str,
213 layout: &MatrixLayout,
214 backend_file: &str,
215 backend: &SparseIoBackend,
216) -> anyhow::Result<()> {
217 let file = hdf5::File::open(h5_file)?;
218 info!("Opened 10x HDF5 file: {}", h5_file);
219 let root = file
220 .group(root_group)
221 .map_err(|_| anyhow::anyhow!("no group `{root_group}` in {h5_file}"))?;
222 let dataset = |field: &str| {
223 root.dataset(field)
224 .map_err(|_| anyhow::anyhow!("no dataset `{root_group}/{field}` in {h5_file}"))
225 };
226 let CooTripletsShape { triplets, shape } = {
228 let values: Vec<f32> = dataset(&layout.data_field)?.read_raw()?;
229 let indices: Vec<u64> = dataset(&layout.indices_field)?.read_raw()?;
230 let indptr: Vec<u64> = dataset(&layout.indptr_field)?.read_raw()?;
231 ValuesIndicesPointers {
232 values: &values,
233 indices: &indices,
234 indptr: &indptr,
235 }
236 .to_coo(layout.pointer_type)?
237 };
238 info!(
239 "Read {} non-zero elements reaching {} x {}",
240 shape.nnz, shape.nrows, shape.ncols
241 );
242 let names = |field: &str| root.dataset(field).ok().map(read_hdf5_strings).transpose();
243 write_10x_matrix(
244 TenxMatrix {
245 triplets,
246 reach: (shape.nrows, shape.ncols),
247 row_ids: names(&layout.row_id_field)?,
248 row_names: names(&layout.row_name_field)?,
249 row_types: names(&layout.row_type_field)?,
250 column_names: names(&layout.column_name_field)?,
251 keep_rows: None,
252 },
253 &layout.select_row_type,
254 &layout.remove_row_type,
255 backend_file,
256 backend,
257 )
258}
259
260pub fn build_from_zarr_matrix(
266 zarr_file: &str,
267 layout: &MatrixLayout,
268 backend_file: &str,
269 backend: &SparseIoBackend,
270) -> anyhow::Result<()> {
271 if !std::path::Path::new(zarr_file).exists() {
272 let zip_variant = format!("{}.zip", zarr_file);
273 let hint: Box<str> = if std::path::Path::new(&zip_variant).exists() {
274 format!(" (did you mean {}?)", zip_variant).into()
275 } else {
276 Box::from("")
277 };
278 anyhow::bail!("Zarr file not found: {}{}", zarr_file, hint);
279 }
280 let store = open_zarr_store(zarr_file)?;
281 info!("Opened zarr store: {}", zarr_file);
282
283 let CooTripletsShape { triplets, shape } = {
285 let indices: Vec<u64> = read_zarr_numerics(store.clone(), &layout.indices_field)?;
286 let indptr: Vec<u64> = read_zarr_numerics(store.clone(), &layout.indptr_field)?;
287 let values: Vec<f32> = read_zarr_numerics(store.clone(), &layout.data_field)?;
288 ValuesIndicesPointers {
289 values: &values,
290 indices: &indices,
291 indptr: &indptr,
292 }
293 .to_coo(layout.pointer_type)?
294 };
295 let TripletsShape { nrows, ncols, nnz } = shape;
296 info!("Read {nnz} non-zero elements reaching {nrows} x {ncols}");
297
298 let names = |field: &str| {
300 read_zarr_group_attr::<Vec<Box<str>>>(store.clone(), field)
301 .or_else(|_| read_zarr_strings(store.clone(), field))
302 .ok()
303 };
304 let column_names = read_zarr_flat_u32(store.clone(), &layout.column_name_field)
305 .and_then(|(ids, shape)| {
306 anyhow::ensure!(shape.len() == 2 && shape[1] == 2, "cell_id must be [N, 2]");
307 parse_10x_cell_id_flat(&ids, shape[0] as usize)
308 })
309 .ok()
310 .or_else(|| names(&layout.column_name_field));
311 write_10x_matrix(
312 TenxMatrix {
313 triplets,
314 reach: (nrows, ncols),
315 row_ids: names(&layout.row_id_field),
316 row_names: names(&layout.row_name_field),
317 row_types: names(&layout.row_type_field),
318 column_names,
319 keep_rows: None,
320 },
321 &layout.select_row_type,
322 &layout.remove_row_type,
323 backend_file,
324 backend,
325 )
326}
327
328fn convert_with(
330 output: &str,
331 build: impl FnOnce(&str, &SparseIoBackend) -> anyhow::Result<()>,
332) -> anyhow::Result<()> {
333 let (output, backend, backend_file) = prepare_output(output, SparseIoBackend::Zarr, false)?;
334 build(&backend_file, &backend)?;
335 finalize_output(&backend_file, &output)?;
336 info!("Conversion done: {}", output);
337 Ok(())
338}
339
340#[cfg(feature = "hdf5")]
345pub fn convert_h5_to_backend(h5_file: &str, output: &str) -> anyhow::Result<()> {
346 convert_with(output, |backend_file, backend| {
347 let layout = MatrixLayout::cell_ranger_h5();
348 build_from_h5_matrix(
349 h5_file,
350 MatrixLayout::H5_ROOT,
351 &layout,
352 backend_file,
353 backend,
354 )
355 })
356}
357
358pub fn convert_zarr_to_backend(zarr_file: &str, output: &str) -> anyhow::Result<()> {
362 convert_with(output, |backend_file, backend| {
363 build_from_zarr_matrix(
364 zarr_file,
365 &MatrixLayout::xenium_zarr(),
366 backend_file,
367 backend,
368 )
369 })
370}
371
372pub fn try_open_or_convert(
378 data_file: &str,
379) -> anyhow::Result<Box<dyn SparseIo<IndexIter = Vec<usize>>>> {
380 let ext = file_ext(data_file)?;
381 let backend = match ext.as_ref() {
382 "h5" | "h5ad" => SparseIoBackend::HDF5,
383 _ => SparseIoBackend::Zarr,
384 };
385
386 match open_sparse_matrix(data_file, &backend) {
387 Ok(data) => Ok(data),
388 Err(original_err) => {
389 let base = strip_backend_suffix(data_file);
390 let converted = format!("{}.db.zarr", base);
391
392 if std::path::Path::new(&converted).exists() {
393 info!("Using cached conversion: {}", converted);
394 return open_sparse_matrix(&converted, &SparseIoBackend::Zarr);
395 }
396
397 match ext.as_ref() {
398 "h5" | "h5ad" => {
399 #[cfg(feature = "hdf5")]
400 {
401 info!(
402 "Converting h5/h5ad to backend: {} -> {}",
403 data_file, converted
404 );
405 convert_h5_to_backend(data_file, &converted)?;
406 }
407 #[cfg(not(feature = "hdf5"))]
408 {
409 anyhow::bail!(
410 "{} is an HDF5 file but data-beans was built without the `hdf5` \
411 feature. Reinstall with `--features hdf5` (and a working libhdf5) \
412 to read .h5/.h5ad inputs.",
413 data_file
414 );
415 }
416 }
417 "zarr" | "zip" => {
418 info!("Converting zarr to backend: {} -> {}", data_file, converted);
419 convert_zarr_to_backend(data_file, &converted)?;
420 }
421 _ => return Err(original_err),
422 }
423
424 open_sparse_matrix(&converted, &SparseIoBackend::Zarr)
425 }
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use std::path::Path;
433
434 fn write_u32_array(dir: &Path, name: &str, shape: &[usize], values: &[u32]) {
436 let d = dir.join(name);
437 std::fs::create_dir_all(&d).unwrap();
438 let meta = serde_json::json!({
439 "zarr_format": 2, "shape": shape, "chunks": shape, "dtype": "<u4",
440 "compressor": null, "fill_value": 0, "order": "C", "filters": null
441 });
442 std::fs::write(d.join(".zarray"), meta.to_string()).unwrap();
443 let chunk = vec!["0"; shape.len()].join(".");
444 let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
445 std::fs::write(d.join(chunk), bytes).unwrap();
446 }
447
448 fn xenium_like_store(dir: &Path) -> Vec<u32> {
452 let cf = dir.join("cell_features");
453 std::fs::create_dir_all(&cf).unwrap();
454 std::fs::write(dir.join(".zgroup"), r#"{"zarr_format": 2}"#).unwrap();
455 std::fs::write(cf.join(".zgroup"), r#"{"zarr_format": 2}"#).unwrap();
456 let attrs = serde_json::json!({
457 "feature_ids": ["FID1", "FID2", "FID3"],
458 "feature_keys": ["GENE1", "GENE2", "NEG1"],
459 "feature_types": ["gene", "gene", "negative_control_probe"],
460 "number_cells": 4, "number_features": 3
461 });
462 std::fs::write(cf.join(".zattrs"), attrs.to_string()).unwrap();
463 write_u32_array(&cf, "indptr", &[4], &[0, 2, 4, 5]);
465 write_u32_array(&cf, "indices", &[5], &[0, 2, 1, 3, 0]);
466 write_u32_array(&cf, "data", &[5], &[1, 2, 3, 4, 5]);
467 let ids = vec![16844, 1, 22527, 1, 16845, 1, 22528, 1];
468 write_u32_array(&cf, "cell_id", &[4, 2], &ids);
469 ids
470 }
471
472 #[test]
473 fn a_xenium_store_converts_to_genes_by_cells_with_their_names() {
474 let dir = tempfile::tempdir().unwrap();
475 let store = dir.path().join("matrix.zarr");
476 let ids = xenium_like_store(&store);
477 let out = dir.path().join("out.db.zarr");
478 let out = out.to_str().unwrap();
479 convert_zarr_to_backend(store.to_str().unwrap(), out).unwrap();
480
481 let data = open_sparse_matrix(out, &SparseIoBackend::Zarr).unwrap();
482 assert_eq!(
484 data.row_names().unwrap(),
485 vec![Box::from("FID1_GENE1"), Box::from("FID2_GENE2")]
486 );
487 assert_eq!(
488 data.column_names().unwrap(),
489 parse_10x_cell_id_flat(&ids, 4).unwrap()
490 );
491 let m = data.read_columns_dmatrix((0..4).collect()).unwrap();
492 assert_eq!(m.shape(), (2, 4));
493 assert_eq!(
494 m.row(0).iter().copied().collect::<Vec<f32>>(),
495 [1., 0., 2., 0.]
496 );
497 assert_eq!(
498 m.row(1).iter().copied().collect::<Vec<f32>>(),
499 [0., 3., 0., 4.]
500 );
501 }
502
503 fn names(xs: &[&str]) -> Option<Vec<Box<str>>> {
504 Some(xs.iter().map(|&x| Box::from(x)).collect())
505 }
506
507 #[test]
508 fn trailing_empty_rows_and_columns_are_kept() {
509 let dir = tempfile::tempdir().unwrap();
510 let out = dir.path().join("out.zarr");
511 let out = out.to_str().unwrap();
512 let m = TenxMatrix {
516 triplets: vec![(0, 0, 1.0), (1, 1, 2.0)],
517 reach: (2, 2),
518 row_ids: names(&["FID1", "FID2", "FID3"]),
519 row_names: names(&["GENE1", "FID2", ""]),
520 row_types: names(&["Gene Expression"; 3]),
521 column_names: names(&["BC1", "BC2", "BC3"]),
522 keep_rows: None,
523 };
524 write_10x_matrix(m, "gene", "", out, &SparseIoBackend::Zarr).unwrap();
525
526 let data = open_sparse_matrix(out, &SparseIoBackend::Zarr).unwrap();
527 assert_eq!(
528 data.row_names().unwrap(),
529 ["FID1_GENE1", "FID2", "FID3"].map(Box::from)
530 );
531 assert_eq!(
532 data.column_names().unwrap(),
533 ["BC1", "BC2", "BC3"].map(Box::from)
534 );
535 let m = data.read_columns_dmatrix((0..3).collect()).unwrap();
536 assert_eq!(m.shape(), (3, 3));
537 assert_eq!(
538 m.row(2).iter().sum::<f32>() + m.column(2).iter().sum::<f32>(),
539 0.
540 );
541 }
542
543 #[test]
544 fn data_reaching_past_the_names_is_an_error() {
545 let dir = tempfile::tempdir().unwrap();
546 let out = dir.path().join("out.zarr");
547 let m = TenxMatrix {
548 triplets: vec![(2, 0, 1.0)],
549 reach: (3, 1),
550 row_ids: names(&["FID1", "FID2"]),
551 row_names: None,
552 row_types: None,
553 column_names: None,
554 keep_rows: None,
555 };
556 let e = write_10x_matrix(m, "gene", "", out.to_str().unwrap(), &SparseIoBackend::Zarr);
557 assert!(e.unwrap_err().to_string().contains("names 2 rows"));
558 }
559}