Skip to main content

data_beans/
convert.rs

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
12/// The sparse matrix of a 10x file as read, before rows are named and kept:
13/// its triplets, how far they reach, and whatever names the file lists.
14pub struct TenxMatrix {
15    pub triplets: Vec<(u64, u64, f32)>,
16    /// Rows and columns the triplets span: every compressed vector, and up
17    /// to the largest index on the other axis.
18    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    /// Rows to keep whatever their type, as a probe set's targets; `None`
24    /// for all.
25    pub keep_rows: Option<Vec<bool>>,
26}
27
28/// The length of one axis: the number of names the file lists for it (one
29/// per row or column, so trailing all-zero rows and columns, which leave no
30/// trace in the triplets, still count), else as far as the triplets reach.
31/// An error when the triplets reach past the names.
32fn 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
40/// Write `m` as a new backend at `backend_file`: rows named `{id}_{name}`
41/// (just `id` when the name is empty or repeats it; made unique) and kept by
42/// type (comma-separated, case-insensitive patterns; a file without types
43/// keeps all), columns named as the file names them. Shared by the 10x
44/// readers, so every format and entry point names and keeps rows alike.
45pub 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    // The other rows leave the triplets before anything is written.
94    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/// Where a 10x file keeps its sparse matrix and the names of its rows and
128/// columns, and which rows to keep: [`MatrixLayout::cell_ranger_h5`] and
129/// [`MatrixLayout::xenium_zarr`].
130#[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    /// Comma-separated, case-insensitive; a row whose type contains any is kept.
140    pub select_row_type: Box<str>,
141    /// Comma-separated, case-insensitive; a row whose type contains any is dropped.
142    pub remove_row_type: Box<str>,
143    pub column_name_field: Box<str>,
144}
145
146impl MatrixLayout {
147    /// The group a Cell Ranger HDF5 file keeps its matrix under; the
148    /// `H5_*` fields are relative to it.
149    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    /// Gene Expression (Xenium `gene`) and ATAC Peaks.
165    pub const SELECT_ROW_TYPES: &'static str = "gene,peak";
166    /// Xenium's per-gene aggregates (`aggregate_gene`).
167    pub const REMOVE_ROW_TYPES: &'static str = "aggregate";
168
169    /// Cell Ranger / Space Ranger / Xenium `*feature_bc_matrix.h5`:
170    /// features × barcodes under [`Self::H5_ROOT`], pointers over barcodes.
171    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    /// Xenium's `cell_feature_matrix.zarr`: features × cells, pointers over
187    /// features, feature names as attributes of `/cell_features`.
188    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/// Read the sparse matrix of a 10x HDF5 file, its fields under
205/// `root_group`, into a new backend at `backend_file`
206/// ([`write_10x_matrix`]). Shared by `data-beans from-10x-matrix` and
207/// [`try_open_or_convert`]. The caller prepares and finalizes the output
208/// ([`prepare_output`], [`finalize_output`]).
209#[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    // The arrays go once their triplets are made.
227    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
260/// Read the sparse matrix of a 10x-style Zarr store into a new backend at
261/// `backend_file` ([`write_10x_matrix`]), columns named by Xenium cell id
262/// when they are encoded so. Shared by `data-beans from-zarr` and
263/// [`try_open_or_convert`], so both read a store alike. The caller prepares
264/// and finalizes the output ([`prepare_output`], [`finalize_output`]).
265pub 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    // The arrays go once their triplets are made.
284    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    // Names stored as a group attribute (Xenium) or as a string array.
299    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
328/// Convert to a Zarr backend at `output` with `build`, which writes it.
329fn 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/// Convert a 10x HDF5 file (Cell Ranger / Space Ranger / Xenium
341/// `*feature_bc_matrix.h5`) to a data-beans backend, read as `data-beans
342/// from-10x-matrix` reads it with its defaults
343/// ([`MatrixLayout::cell_ranger_h5`]).
344#[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
358/// Convert a 10x-style Zarr file (Xenium `cell_feature_matrix.zarr.zip` or
359/// directory) to a data-beans backend, read as `data-beans from-zarr` reads
360/// it with its defaults ([`MatrixLayout::xenium_zarr`]).
361pub 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
372/// Try to open a data file directly; if that fails, attempt automatic
373/// conversion from raw 10x formats (h5/h5ad, zarr/zarr.zip).
374///
375/// Converted backends are cached as `{data_file}.db.zarr` next to
376/// the original file so subsequent calls skip conversion.
377pub 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    /// An uncompressed Zarr v2 array of little-endian u32 under `dir/name`.
435    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    /// A store laid out as Xenium writes `cell_feature_matrix.zarr`: 3
449    /// features × 4 cells, pointers over features, names as attributes of
450    /// `/cell_features`, cell ids as a `[N, 2]` u32 array.
451    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        // Rows (features): GENE1 [1 0 2 0], GENE2 [0 3 0 4], NEG1 [5 0 0 0].
464        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        // The control feature is dropped; genes are rows, cells columns.
483        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        // The data reaches 2 x 2, but the file names 3 rows and 3 columns;
513        // the last row and column have nothing in them. Rows whose name is
514        // empty or repeats the id are named by id alone.
515        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}