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 length the file declares for it, else the
29/// number of names it lists (one per row or column, so trailing all-zero
30/// rows and columns, which leave no trace in the triplets, still count),
31/// else as far as the triplets reach. An error when the triplets reach past
32/// that length, or, with `compressed` (the axis the file's pointers run
33/// over, one vector per row or column), hold another number of vectors.
34pub(crate) fn axis_len(
35    declared: Option<usize>,
36    named: Option<usize>,
37    reach: usize,
38    compressed: bool,
39    what: &str,
40) -> anyhow::Result<usize> {
41    let (n, source) = match (declared, named) {
42        (Some(n), _) => (n, "declares"),
43        (None, Some(n)) => (n, "names"),
44        (None, None) => return Ok(reach),
45    };
46    anyhow::ensure!(
47        if compressed { reach == n } else { reach <= n },
48        "the file {source} {n} {what} but its data {} {reach}",
49        if compressed { "holds" } else { "reaches" }
50    );
51    Ok(n)
52}
53
54/// An error when a matrix of `nrows` rows keeps none of them: `n_kept` is
55/// zero. It names the type `patterns` (select, remove) and the file's
56/// types; `row_set` is `(rows of a kept type, rows in the set)` when the
57/// rows are also limited to a set of their own ([`TenxMatrix::keep_rows`]),
58/// which may be what leaves none.
59pub(crate) fn ensure_rows_kept(
60    n_kept: usize,
61    nrows: usize,
62    row_types: Option<&[Box<str>]>,
63    patterns: (&str, &str),
64    row_set: Option<(usize, usize)>,
65) -> anyhow::Result<()> {
66    if n_kept > 0 || nrows == 0 {
67        return Ok(());
68    }
69    let types: std::collections::BTreeSet<&str> = row_types
70        .iter()
71        .flat_map(|t| t.iter())
72        .map(|t| t.as_ref())
73        .collect();
74    let patterns = format!("select `{}`, remove `{}`", patterns.0, patterns.1);
75    match row_set {
76        Some((n_typed, n_set)) => anyhow::bail!(
77            "none of the {nrows} rows is kept: {n_typed} are of a kept type ({patterns}) and \
78             {n_set} are in `keep_rows`, the rows the file itself selects (a probe set's \
79             targets), but none is both; the file's feature types are {types:?}. Name the \
80             types to keep with --select-row-type"
81        ),
82        None => anyhow::bail!(
83            "none of the {nrows} rows is kept ({patterns}); the file's feature types are \
84             {types:?}. Name the types to keep with --select-row-type"
85        ),
86    }
87}
88
89/// Write `m` as a new backend at `backend_file`: rows named `{id}_{name}`
90/// (just `id` when the name is empty or repeats it; made unique) and kept by
91/// type (comma-separated, case-insensitive patterns; a file without types
92/// keeps all), columns named as the file names them. Shared by the 10x
93/// readers, so every format and entry point names and keeps rows alike.
94pub fn write_10x_matrix(
95    m: TenxMatrix,
96    select_row_type: &str,
97    remove_row_type: &str,
98    backend_file: &str,
99    backend: &SparseIoBackend,
100) -> anyhow::Result<()> {
101    let TenxMatrix {
102        mut triplets,
103        reach,
104        row_ids,
105        row_names,
106        row_types,
107        column_names,
108        keep_rows,
109    } = m;
110    // A 10x file declares no shape apart from its names, and a list of
111    // names longer than the pointers is taken as trailing empty vectors.
112    let nrows = axis_len(None, row_ids.as_ref().map(Vec::len), reach.0, false, "rows")?;
113    let named_columns = column_names.as_ref().map(Vec::len);
114    let ncols = axis_len(None, named_columns, reach.1, false, "columns")?;
115    let index = |n: usize| {
116        (0..n)
117            .map(|i| i.to_string().into_boxed_str())
118            .collect::<Vec<_>>()
119    };
120    let mut row_ids = row_ids.unwrap_or_else(|| index(nrows));
121    let mut row_names = row_names.unwrap_or_else(|| vec![Box::from(""); nrows]);
122    anyhow::ensure!(
123        row_names.len() == nrows
124            && row_types.as_ref().is_none_or(|t| t.len() == nrows)
125            && keep_rows.as_ref().is_none_or(|k| k.len() == nrows),
126        "the file lists {nrows} row ids but {} row names and {} row types",
127        row_names.len(),
128        row_types.as_ref().map_or(0, Vec::len)
129    );
130    let column_names = column_names.unwrap_or_else(|| index(ncols));
131
132    let n_peaks = colon_peak_names(&mut row_ids, &mut row_names, row_types.as_deref());
133    if n_peaks > 0 {
134        info!("{n_peaks} peak names rewritten in chr:start-end form");
135    }
136    let mut row_ids = compose_id_name(row_ids, row_names);
137    make_names_unique(&mut row_ids);
138    let mut keep = match &row_types {
139        Some(types) => filter_row_indices_by_type(types, select_row_type, remove_row_type),
140        None => (0..nrows).collect(),
141    };
142    let row_set = keep_rows.as_ref().map(|keep_rows| {
143        let n_typed = keep.len();
144        keep.retain(|&i| keep_rows[i]);
145        (n_typed, keep_rows.iter().filter(|&&k| k).count())
146    });
147    ensure_rows_kept(
148        keep.len(),
149        nrows,
150        row_types.as_deref(),
151        (select_row_type, remove_row_type),
152        row_set,
153    )?;
154    // The other rows leave the triplets before anything is written.
155    if keep.len() < nrows {
156        info!("Keeping {} of {nrows} rows", keep.len());
157        let mut new_row = vec![None; nrows];
158        for (new, &old) in keep.iter().enumerate() {
159            new_row[old] = Some(new as u64);
160        }
161        triplets.retain_mut(|(i, _, _)| match new_row[*i as usize] {
162            Some(new) => {
163                *i = new;
164                true
165            }
166            None => false,
167        });
168        row_ids = keep
169            .iter()
170            .map(|&i| std::mem::take(&mut row_ids[i]))
171            .collect();
172    }
173
174    let nnz = triplets.len();
175    info!("Matrix: {} x {ncols}, {nnz} non-zeros", row_ids.len());
176    let mut out = create_sparse_from_triplets_owned(
177        triplets,
178        (row_ids.len(), ncols, nnz),
179        Some(backend_file),
180        Some(backend),
181    )?;
182    info!("Created sparse matrix: {}", backend_file);
183    out.register_row_names_vec(&row_ids);
184    out.register_column_names_vec(&column_names);
185    Ok(())
186}
187
188/// Where a 10x file keeps its sparse matrix and the names of its rows and
189/// columns, and which rows to keep: [`MatrixLayout::cell_ranger_h5`] and
190/// [`MatrixLayout::xenium_zarr`].
191#[derive(Clone, Debug)]
192pub struct MatrixLayout {
193    pub data_field: Box<str>,
194    pub indices_field: Box<str>,
195    pub indptr_field: Box<str>,
196    pub pointer_type: IndexPointerType,
197    pub row_id_field: Box<str>,
198    pub row_name_field: Box<str>,
199    pub row_type_field: Box<str>,
200    /// Comma-separated, case-insensitive; a row whose type contains any is kept.
201    pub select_row_type: Box<str>,
202    /// Comma-separated, case-insensitive; a row whose type contains any is dropped.
203    pub remove_row_type: Box<str>,
204    pub column_name_field: Box<str>,
205}
206
207impl MatrixLayout {
208    /// The group a Cell Ranger HDF5 file keeps its matrix under; the
209    /// `H5_*` fields are relative to it.
210    pub const H5_ROOT: &'static str = "matrix";
211    pub const H5_DATA: &'static str = "data";
212    pub const H5_INDICES: &'static str = "indices";
213    pub const H5_INDPTR: &'static str = "indptr";
214    pub const H5_ROW_IDS: &'static str = "features/id";
215    pub const H5_ROW_NAMES: &'static str = "features/name";
216    pub const H5_ROW_TYPES: &'static str = "features/feature_type";
217    pub const H5_COLUMN_NAMES: &'static str = "barcodes";
218    pub const XENIUM_DATA: &'static str = "/cell_features/data";
219    pub const XENIUM_INDICES: &'static str = "/cell_features/indices";
220    pub const XENIUM_INDPTR: &'static str = "/cell_features/indptr";
221    pub const XENIUM_ROW_IDS: &'static str = "/cell_features/feature_ids";
222    pub const XENIUM_ROW_NAMES: &'static str = "/cell_features/feature_keys";
223    pub const XENIUM_ROW_TYPES: &'static str = "/cell_features/feature_types";
224    pub const XENIUM_COLUMN_NAMES: &'static str = "/cell_features/cell_id";
225    /// Gene Expression (Xenium `gene`) and ATAC Peaks.
226    pub const SELECT_ROW_TYPES: &'static str = "gene,peak";
227    /// Xenium's per-gene aggregates (`aggregate_gene`).
228    pub const REMOVE_ROW_TYPES: &'static str = "aggregate";
229
230    /// Cell Ranger / Space Ranger / Xenium `*feature_bc_matrix.h5`:
231    /// features × barcodes under [`Self::H5_ROOT`], pointers over barcodes.
232    pub fn cell_ranger_h5() -> Self {
233        Self {
234            data_field: Self::H5_DATA.into(),
235            indices_field: Self::H5_INDICES.into(),
236            indptr_field: Self::H5_INDPTR.into(),
237            pointer_type: IndexPointerType::Column,
238            row_id_field: Self::H5_ROW_IDS.into(),
239            row_name_field: Self::H5_ROW_NAMES.into(),
240            row_type_field: Self::H5_ROW_TYPES.into(),
241            select_row_type: Self::SELECT_ROW_TYPES.into(),
242            remove_row_type: Self::REMOVE_ROW_TYPES.into(),
243            column_name_field: Self::H5_COLUMN_NAMES.into(),
244        }
245    }
246
247    /// Xenium's `cell_feature_matrix.zarr`: features × cells, pointers over
248    /// features, feature names as attributes of `/cell_features`.
249    pub fn xenium_zarr() -> Self {
250        Self {
251            data_field: Self::XENIUM_DATA.into(),
252            indices_field: Self::XENIUM_INDICES.into(),
253            indptr_field: Self::XENIUM_INDPTR.into(),
254            pointer_type: IndexPointerType::Row,
255            row_id_field: Self::XENIUM_ROW_IDS.into(),
256            row_name_field: Self::XENIUM_ROW_NAMES.into(),
257            row_type_field: Self::XENIUM_ROW_TYPES.into(),
258            select_row_type: Self::SELECT_ROW_TYPES.into(),
259            remove_row_type: Self::REMOVE_ROW_TYPES.into(),
260            column_name_field: Self::XENIUM_COLUMN_NAMES.into(),
261        }
262    }
263}
264
265/// Read the sparse matrix of a 10x HDF5 file, its fields under
266/// `root_group`, into a new backend at `backend_file`
267/// ([`write_10x_matrix`]). Shared by `data-beans from-10x-matrix` and
268/// [`try_open_or_convert`]. The caller prepares and finalizes the output
269/// ([`prepare_output`], [`finalize_output`]).
270#[cfg(feature = "hdf5")]
271pub fn build_from_h5_matrix(
272    h5_file: &str,
273    root_group: &str,
274    layout: &MatrixLayout,
275    backend_file: &str,
276    backend: &SparseIoBackend,
277) -> anyhow::Result<()> {
278    let file = hdf5::File::open(h5_file)?;
279    info!("Opened 10x HDF5 file: {}", h5_file);
280    let root = file
281        .group(root_group)
282        .map_err(|_| anyhow::anyhow!("no group `{root_group}` in {h5_file}"))?;
283    let dataset = |field: &str| {
284        root.dataset(field)
285            .map_err(|_| anyhow::anyhow!("no dataset `{root_group}/{field}` in {h5_file}"))
286    };
287    // The arrays go once their triplets are made.
288    let CooTripletsShape { triplets, shape } = {
289        let values: Vec<f32> = dataset(&layout.data_field)?.read_raw()?;
290        let indices: Vec<u64> = dataset(&layout.indices_field)?.read_raw()?;
291        let indptr: Vec<u64> = dataset(&layout.indptr_field)?.read_raw()?;
292        ValuesIndicesPointers {
293            values: &values,
294            indices: &indices,
295            indptr: &indptr,
296        }
297        .to_coo(layout.pointer_type)?
298    };
299    info!(
300        "Read {} non-zero elements reaching {} x {}",
301        shape.nnz, shape.nrows, shape.ncols
302    );
303    let names = |field: &str| root.dataset(field).ok().map(read_hdf5_strings).transpose();
304    write_10x_matrix(
305        TenxMatrix {
306            triplets,
307            reach: (shape.nrows, shape.ncols),
308            row_ids: names(&layout.row_id_field)?,
309            row_names: names(&layout.row_name_field)?,
310            row_types: names(&layout.row_type_field)?,
311            column_names: names(&layout.column_name_field)?,
312            keep_rows: None,
313        },
314        &layout.select_row_type,
315        &layout.remove_row_type,
316        backend_file,
317        backend,
318    )
319}
320
321/// Read the sparse matrix of a 10x-style Zarr store into a new backend at
322/// `backend_file` ([`write_10x_matrix`]), columns named by Xenium cell id
323/// when they are encoded so. Shared by `data-beans from-zarr` and
324/// [`try_open_or_convert`], so both read a store alike. The caller prepares
325/// and finalizes the output ([`prepare_output`], [`finalize_output`]).
326pub fn build_from_zarr_matrix(
327    zarr_file: &str,
328    layout: &MatrixLayout,
329    backend_file: &str,
330    backend: &SparseIoBackend,
331) -> anyhow::Result<()> {
332    if !std::path::Path::new(zarr_file).exists() {
333        let zip_variant = format!("{}.zip", zarr_file);
334        let hint: Box<str> = if std::path::Path::new(&zip_variant).exists() {
335            format!(" (did you mean {}?)", zip_variant).into()
336        } else {
337            Box::from("")
338        };
339        anyhow::bail!("Zarr file not found: {}{}", zarr_file, hint);
340    }
341    let store = open_zarr_store(zarr_file)?;
342    info!("Opened zarr store: {}", zarr_file);
343
344    // The arrays go once their triplets are made.
345    let CooTripletsShape { triplets, shape } = {
346        let indices: Vec<u64> = read_zarr_numerics(store.clone(), &layout.indices_field)?;
347        let indptr: Vec<u64> = read_zarr_numerics(store.clone(), &layout.indptr_field)?;
348        let values: Vec<f32> = read_zarr_numerics(store.clone(), &layout.data_field)?;
349        ValuesIndicesPointers {
350            values: &values,
351            indices: &indices,
352            indptr: &indptr,
353        }
354        .to_coo(layout.pointer_type)?
355    };
356    let TripletsShape { nrows, ncols, nnz } = shape;
357    info!("Read {nnz} non-zero elements reaching {nrows} x {ncols}");
358
359    // Names stored as a group attribute (Xenium) or as a string array.
360    let names = |field: &str| {
361        read_zarr_group_attr::<Vec<Box<str>>>(store.clone(), field)
362            .or_else(|_| read_zarr_strings(store.clone(), field))
363            .ok()
364    };
365    let column_names = read_zarr_flat_u32(store.clone(), &layout.column_name_field)
366        .and_then(|(ids, shape)| {
367            anyhow::ensure!(shape.len() == 2 && shape[1] == 2, "cell_id must be [N, 2]");
368            parse_10x_cell_id_flat(&ids, shape[0] as usize)
369        })
370        .ok()
371        .or_else(|| names(&layout.column_name_field));
372    write_10x_matrix(
373        TenxMatrix {
374            triplets,
375            reach: (nrows, ncols),
376            row_ids: names(&layout.row_id_field),
377            row_names: names(&layout.row_name_field),
378            row_types: names(&layout.row_type_field),
379            column_names,
380            keep_rows: None,
381        },
382        &layout.select_row_type,
383        &layout.remove_row_type,
384        backend_file,
385        backend,
386    )
387}
388
389/// Convert to a Zarr backend at `output` with `build`, which writes it.
390fn convert_with(
391    output: &str,
392    build: impl FnOnce(&str, &SparseIoBackend) -> anyhow::Result<()>,
393) -> anyhow::Result<()> {
394    let (output, backend, backend_file) = prepare_output(output, SparseIoBackend::Zarr, false)?;
395    build(&backend_file, &backend)?;
396    finalize_output(&backend_file, &output)?;
397    info!("Conversion done: {}", output);
398    Ok(())
399}
400
401/// Convert a 10x HDF5 file (Cell Ranger / Space Ranger / Xenium
402/// `*feature_bc_matrix.h5`) to a data-beans backend, read as `data-beans
403/// from-10x-matrix` reads it with its defaults
404/// ([`MatrixLayout::cell_ranger_h5`]).
405#[cfg(feature = "hdf5")]
406pub fn convert_h5_to_backend(h5_file: &str, output: &str) -> anyhow::Result<()> {
407    convert_with(output, |backend_file, backend| {
408        let layout = MatrixLayout::cell_ranger_h5();
409        build_from_h5_matrix(
410            h5_file,
411            MatrixLayout::H5_ROOT,
412            &layout,
413            backend_file,
414            backend,
415        )
416    })
417}
418
419/// Convert a 10x-style Zarr file (Xenium `cell_feature_matrix.zarr.zip` or
420/// directory) to a data-beans backend, read as `data-beans from-zarr` reads
421/// it with its defaults ([`MatrixLayout::xenium_zarr`]).
422pub fn convert_zarr_to_backend(zarr_file: &str, output: &str) -> anyhow::Result<()> {
423    convert_with(output, |backend_file, backend| {
424        build_from_zarr_matrix(
425            zarr_file,
426            &MatrixLayout::xenium_zarr(),
427            backend_file,
428            backend,
429        )
430    })
431}
432
433/// The backend cached at `converted`, when it is there and whole: it opens
434/// and knows its shape. `None` for a missing or unusable one.
435fn cached_conversion(converted: &str) -> Option<Box<dyn SparseIo<IndexIter = Vec<usize>>>> {
436    if !std::path::Path::new(converted).exists() {
437        return None;
438    }
439    let data = open_sparse_matrix(converted, &SparseIoBackend::Zarr).ok()?;
440    let whole =
441        data.num_rows().is_some() && data.num_columns().is_some() && data.num_non_zeros().is_some();
442    whole.then_some(data)
443}
444
445/// Where a conversion cached as `{base}.db.zarr` is built: a sibling no
446/// other conversion writes to, in this process or another, so the cache
447/// path only ever holds a finished backend.
448fn staged_conversion(base: &str) -> String {
449    use std::sync::atomic::{AtomicUsize, Ordering};
450    static NTH: AtomicUsize = AtomicUsize::new(0);
451    format!(
452        "{base}.db.partial-{}-{}.zarr",
453        std::process::id(),
454        NTH.fetch_add(1, Ordering::Relaxed)
455    )
456}
457
458/// Move the backend built at `staged` to the cache path `converted`, in one
459/// rename. The rename fails when a backend is already there, which is looked
460/// at only then: a usable one is another conversion of the same input that
461/// finished first, and it stays while `staged` is dropped. An unusable one
462/// is moved aside, in one rename too, before `staged` takes its place, and
463/// only what was moved aside is deleted; a backend another conversion
464/// installs in between is therefore never deleted, it makes the second
465/// rename fail and is then the cache.
466fn install_conversion(staged: &str, converted: &str) -> anyhow::Result<()> {
467    let rename = || Ok(std::fs::rename(staged, converted)?);
468    let occupied = || std::path::Path::new(converted).exists();
469    let usable = || cached_conversion(converted).is_some();
470    let mut renamed: anyhow::Result<()> = rename();
471    if renamed.is_err() && occupied() && !usable() {
472        let stale = format!("{staged}.stale");
473        if std::fs::rename(converted, &stale).is_ok() {
474            renamed = rename();
475            remove_file(&stale)?;
476        }
477    }
478    let Err(e) = renamed else {
479        return Ok(());
480    };
481    remove_file(staged)?;
482    anyhow::ensure!(
483        usable(),
484        "cannot install the conversion at {converted}: {e}"
485    );
486    Ok(())
487}
488
489/// Try to open a data file directly; if that fails, attempt automatic
490/// conversion from raw 10x formats (Cell Ranger h5, Xenium zarr/zarr.zip).
491/// An AnnData `.h5ad` is not converted here: `data-beans from-h5ad` reads
492/// it, with its own layout and metadata files.
493///
494/// Converted backends are cached as `{data_file}.db.zarr` next to
495/// the original file so subsequent calls skip conversion. The conversion is
496/// built beside the cache path and renamed onto it when done, so an
497/// interrupted or concurrent conversion never leaves a partial cache; a
498/// cache that does not open is converted again.
499pub fn try_open_or_convert(
500    data_file: &str,
501) -> anyhow::Result<Box<dyn SparseIo<IndexIter = Vec<usize>>>> {
502    let ext = file_ext(data_file)?;
503    let backend = match ext.as_ref() {
504        "h5" | "h5ad" => SparseIoBackend::HDF5,
505        _ => SparseIoBackend::Zarr,
506    };
507
508    let original_err = match open_sparse_matrix(data_file, &backend) {
509        Ok(data) => return Ok(data),
510        Err(e) => e,
511    };
512    let base = strip_backend_suffix(data_file);
513    let converted = format!("{}.db.zarr", base);
514
515    if let Some(data) = cached_conversion(&converted) {
516        info!("Using cached conversion: {}", converted);
517        return Ok(data);
518    }
519    if std::path::Path::new(&converted).exists() {
520        log::warn!(
521            "the cached conversion {} is incomplete; converting {} again",
522            converted,
523            data_file
524        );
525    }
526
527    // The conversion, and the command that does the same with options.
528    type Convert = fn(&str, &str) -> anyhow::Result<()>;
529    let (convert, command): (Convert, &str) = match ext.as_ref() {
530        #[cfg(feature = "hdf5")]
531        "h5" => (convert_h5_to_backend, "from-10x-matrix"),
532        #[cfg(feature = "hdf5")]
533        "h5ad" => anyhow::bail!(
534            "{data_file} does not open as a data-beans backend ({original_err}), and an \
535             AnnData .h5ad is not converted automatically; run `data-beans from-h5ad \
536             {data_file} -o <output>` and use its output"
537        ),
538        #[cfg(not(feature = "hdf5"))]
539        "h5" | "h5ad" => anyhow::bail!(
540            "{} is an HDF5 file but data-beans was built without the `hdf5` \
541             feature. Reinstall with `--features hdf5` (and a working libhdf5) \
542             to read .h5/.h5ad inputs.",
543            data_file
544        ),
545        "zarr" | "zip" => (convert_zarr_to_backend, "from-zarr"),
546        _ => return Err(original_err),
547    };
548
549    let staged = staged_conversion(base);
550    info!("Converting {} to backend: {}", data_file, converted);
551    if let Err(e) = convert(data_file, &staged) {
552        let _ = remove_file(&staged);
553        // Converted here, the file is read with the defaults of `command`,
554        // which the caller has no options to change.
555        return Err(e.context(format!(
556            "{data_file} is not converted automatically with the default options; run \
557             `data-beans {command} {data_file} -o <output>` with its options, such as \
558             --select-row-type for the feature types to keep, and use its output"
559        )));
560    }
561    install_conversion(&staged, &converted)?;
562    open_sparse_matrix(&converted, &SparseIoBackend::Zarr)
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use std::path::Path;
569
570    /// An uncompressed Zarr v2 array of little-endian u32 under `dir/name`.
571    fn write_u32_array(dir: &Path, name: &str, shape: &[usize], values: &[u32]) {
572        let d = dir.join(name);
573        std::fs::create_dir_all(&d).unwrap();
574        let meta = serde_json::json!({
575            "zarr_format": 2, "shape": shape, "chunks": shape, "dtype": "<u4",
576            "compressor": null, "fill_value": 0, "order": "C", "filters": null
577        });
578        std::fs::write(d.join(".zarray"), meta.to_string()).unwrap();
579        let chunk = vec!["0"; shape.len()].join(".");
580        let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
581        std::fs::write(d.join(chunk), bytes).unwrap();
582    }
583
584    /// A store laid out as Xenium writes `cell_feature_matrix.zarr`: 3
585    /// features × 4 cells, pointers over features, names as attributes of
586    /// `/cell_features`, cell ids as a `[N, 2]` u32 array.
587    fn xenium_like_store(dir: &Path) -> Vec<u32> {
588        let cf = dir.join("cell_features");
589        std::fs::create_dir_all(&cf).unwrap();
590        std::fs::write(dir.join(".zgroup"), r#"{"zarr_format": 2}"#).unwrap();
591        std::fs::write(cf.join(".zgroup"), r#"{"zarr_format": 2}"#).unwrap();
592        let attrs = serde_json::json!({
593            "feature_ids": ["FID1", "FID2", "FID3"],
594            "feature_keys": ["GENE1", "GENE2", "NEG1"],
595            "feature_types": ["gene", "gene", "negative_control_probe"],
596            "number_cells": 4, "number_features": 3
597        });
598        std::fs::write(cf.join(".zattrs"), attrs.to_string()).unwrap();
599        // Rows (features): GENE1 [1 0 2 0], GENE2 [0 3 0 4], NEG1 [5 0 0 0].
600        write_u32_array(&cf, "indptr", &[4], &[0, 2, 4, 5]);
601        write_u32_array(&cf, "indices", &[5], &[0, 2, 1, 3, 0]);
602        write_u32_array(&cf, "data", &[5], &[1, 2, 3, 4, 5]);
603        let ids = vec![16844, 1, 22527, 1, 16845, 1, 22528, 1];
604        write_u32_array(&cf, "cell_id", &[4, 2], &ids);
605        ids
606    }
607
608    #[test]
609    fn a_xenium_store_converts_to_genes_by_cells_with_their_names() {
610        let dir = tempfile::tempdir().unwrap();
611        let store = dir.path().join("matrix.zarr");
612        let ids = xenium_like_store(&store);
613        let out = dir.path().join("out.db.zarr");
614        let out = out.to_str().unwrap();
615        convert_zarr_to_backend(store.to_str().unwrap(), out).unwrap();
616
617        let data = open_sparse_matrix(out, &SparseIoBackend::Zarr).unwrap();
618        // The control feature is dropped; genes are rows, cells columns.
619        assert_eq!(
620            data.row_names().unwrap(),
621            vec![Box::from("FID1_GENE1"), Box::from("FID2_GENE2")]
622        );
623        assert_eq!(
624            data.column_names().unwrap(),
625            parse_10x_cell_id_flat(&ids, 4).unwrap()
626        );
627        let m = data.read_columns_dmatrix((0..4).collect()).unwrap();
628        assert_eq!(m.shape(), (2, 4));
629        assert_eq!(
630            m.row(0).iter().copied().collect::<Vec<f32>>(),
631            [1., 0., 2., 0.]
632        );
633        assert_eq!(
634            m.row(1).iter().copied().collect::<Vec<f32>>(),
635            [0., 3., 0., 4.]
636        );
637    }
638
639    fn file_names(dir: &Path) -> Vec<String> {
640        let mut names: Vec<String> = std::fs::read_dir(dir)
641            .unwrap()
642            .map(|e| e.unwrap().file_name().into_string().unwrap())
643            .collect();
644        names.sort();
645        names
646    }
647
648    #[test]
649    fn a_partial_cached_conversion_is_converted_again() {
650        let dir = tempfile::tempdir().unwrap();
651        let store = dir.path().join("matrix.zarr");
652        xenium_like_store(&store);
653        // What an interrupted conversion left at the cache path.
654        let cache = dir.path().join("matrix.db.zarr");
655        std::fs::create_dir_all(cache.join("by_column")).unwrap();
656        std::fs::write(cache.join(".zgroup"), r#"{"zarr_format": 2}"#).unwrap();
657
658        let data = try_open_or_convert(store.to_str().unwrap()).unwrap();
659        assert_eq!(
660            data.row_names().unwrap(),
661            ["FID1_GENE1", "FID2_GENE2"].map(Box::from)
662        );
663        assert_eq!(data.num_columns(), Some(4));
664        drop(data);
665        // Nothing is left of the build but the cache, which the next call
666        // opens as it is.
667        assert_eq!(file_names(dir.path()), ["matrix.db.zarr", "matrix.zarr"]);
668        let stamp = |p: &Path| std::fs::metadata(p).unwrap().modified().unwrap();
669        let built = stamp(&cache);
670        let again = try_open_or_convert(store.to_str().unwrap()).unwrap();
671        assert_eq!(again.num_non_zeros(), Some(4));
672        assert_eq!(stamp(&cache), built);
673    }
674
675    #[test]
676    fn a_failed_conversion_leaves_no_cache() {
677        let dir = tempfile::tempdir().unwrap();
678        let store = dir.path().join("matrix.zarr");
679        xenium_like_store(&store);
680        // No feature is of a kept type, so the conversion fails.
681        let attrs = serde_json::json!({
682            "feature_ids": ["FID1", "FID2", "FID3"],
683            "feature_keys": ["A", "B", "C"],
684            "feature_types": vec!["negative_control_probe"; 3],
685        });
686        std::fs::write(store.join("cell_features/.zattrs"), attrs.to_string()).unwrap();
687
688        let e = try_open_or_convert(store.to_str().unwrap())
689            .err()
690            .expect("no row is kept");
691        // The error names the command whose options keep other types, and
692        // still says what went wrong.
693        let e = format!("{e:#}");
694        assert!(e.contains("data-beans from-zarr"), "{e}");
695        assert!(e.contains("--select-row-type"), "{e}");
696        assert!(e.contains("negative_control_probe"), "{e}");
697        assert_eq!(file_names(dir.path()), ["matrix.zarr"]);
698    }
699
700    #[test]
701    fn a_cache_installed_meanwhile_stays_unless_it_is_unusable() {
702        let dir = tempfile::tempdir().unwrap();
703        let store = dir.path().join("matrix.zarr");
704        xenium_like_store(&store);
705        let path = |name: &str| dir.path().join(name).to_str().unwrap().to_string();
706        let (cache, staged) = (path("matrix.db.zarr"), path("staged.zarr"));
707        let convert = |to: &str| convert_zarr_to_backend(store.to_str().unwrap(), to).unwrap();
708
709        // Another conversion finished first: its backend is the one kept.
710        convert(&cache);
711        let marker = Path::new(&cache).join("installed-first");
712        std::fs::write(&marker, "").unwrap();
713        convert(&staged);
714        install_conversion(&staged, &cache).unwrap();
715        assert!(marker.exists());
716        assert!(!Path::new(&staged).exists());
717
718        // What is there does not open: the staged backend takes its place.
719        remove_file(&cache).unwrap();
720        std::fs::create_dir_all(Path::new(&cache).join("by_column")).unwrap();
721        convert(&staged);
722        install_conversion(&staged, &cache).unwrap();
723        assert!(cached_conversion(&cache).is_some());
724        assert!(!Path::new(&staged).exists());
725    }
726
727    #[cfg(feature = "hdf5")]
728    #[test]
729    fn an_h5ad_file_is_sent_to_from_h5ad() {
730        let dir = tempfile::tempdir().unwrap();
731        let h5ad = dir.path().join("cells.h5ad");
732        let file = hdf5::File::create(&h5ad).unwrap();
733        file.create_group("X").unwrap();
734        file.create_group("obs").unwrap();
735        file.create_group("var").unwrap();
736        drop(file);
737
738        let e = try_open_or_convert(h5ad.to_str().unwrap())
739            .err()
740            .expect("an AnnData file is not a backend")
741            .to_string();
742        assert!(e.contains("from-h5ad"), "{e}");
743        assert_eq!(file_names(dir.path()), ["cells.h5ad"]);
744    }
745
746    fn names(xs: &[&str]) -> Option<Vec<Box<str>>> {
747        Some(xs.iter().map(|&x| Box::from(x)).collect())
748    }
749
750    #[test]
751    fn trailing_empty_rows_and_columns_are_kept() {
752        let dir = tempfile::tempdir().unwrap();
753        let out = dir.path().join("out.zarr");
754        let out = out.to_str().unwrap();
755        // The data reaches 2 x 2, but the file names 3 rows and 3 columns;
756        // the last row and column have nothing in them. Rows whose name is
757        // empty or repeats the id are named by id alone.
758        let m = TenxMatrix {
759            triplets: vec![(0, 0, 1.0), (1, 1, 2.0)],
760            reach: (2, 2),
761            row_ids: names(&["FID1", "FID2", "FID3"]),
762            row_names: names(&["GENE1", "FID2", ""]),
763            row_types: names(&["Gene Expression"; 3]),
764            column_names: names(&["BC1", "BC2", "BC3"]),
765            keep_rows: None,
766        };
767        write_10x_matrix(m, "gene", "", out, &SparseIoBackend::Zarr).unwrap();
768
769        let data = open_sparse_matrix(out, &SparseIoBackend::Zarr).unwrap();
770        assert_eq!(
771            data.row_names().unwrap(),
772            ["FID1_GENE1", "FID2", "FID3"].map(Box::from)
773        );
774        assert_eq!(
775            data.column_names().unwrap(),
776            ["BC1", "BC2", "BC3"].map(Box::from)
777        );
778        let m = data.read_columns_dmatrix((0..3).collect()).unwrap();
779        assert_eq!(m.shape(), (3, 3));
780        assert_eq!(
781            m.row(2).iter().sum::<f32>() + m.column(2).iter().sum::<f32>(),
782            0.
783        );
784    }
785
786    #[test]
787    fn data_reaching_past_the_names_is_an_error() {
788        let dir = tempfile::tempdir().unwrap();
789        let out = dir.path().join("out.zarr");
790        let m = TenxMatrix {
791            triplets: vec![(2, 0, 1.0)],
792            reach: (3, 1),
793            row_ids: names(&["FID1", "FID2"]),
794            row_names: None,
795            row_types: None,
796            column_names: None,
797            keep_rows: None,
798        };
799        let e = write_10x_matrix(m, "gene", "", out.to_str().unwrap(), &SparseIoBackend::Zarr);
800        assert!(e.unwrap_err().to_string().contains("names 2 rows"));
801    }
802
803    #[test]
804    fn keeping_no_row_is_an_error_naming_the_types() {
805        let dir = tempfile::tempdir().unwrap();
806        let out = dir.path().join("out.zarr");
807        let m = TenxMatrix {
808            triplets: vec![(0, 0, 1.0), (1, 1, 2.0)],
809            reach: (2, 2),
810            row_ids: names(&["HTO1", "CD3"]),
811            row_names: None,
812            row_types: names(&["Multiplexing Capture", "Antibody Capture"]),
813            column_names: None,
814            keep_rows: None,
815        };
816        let e = write_10x_matrix(
817            m,
818            MatrixLayout::SELECT_ROW_TYPES,
819            MatrixLayout::REMOVE_ROW_TYPES,
820            out.to_str().unwrap(),
821            &SparseIoBackend::Zarr,
822        )
823        .unwrap_err()
824        .to_string();
825        assert!(
826            e.contains("Antibody Capture") && e.contains("gene,peak"),
827            "{e}"
828        );
829        assert!(!out.exists());
830    }
831
832    #[test]
833    fn keeping_no_row_names_the_row_set_that_leaves_none() {
834        let dir = tempfile::tempdir().unwrap();
835        let out = dir.path().join("out.zarr");
836        // Two rows are genes, and the only row the file selects is of
837        // another type.
838        let m = TenxMatrix {
839            triplets: vec![(0, 0, 1.0), (1, 1, 2.0), (2, 0, 3.0)],
840            reach: (3, 2),
841            row_ids: names(&["G1", "G2", "CD3"]),
842            row_names: None,
843            row_types: names(&["Gene Expression", "Gene Expression", "Antibody Capture"]),
844            column_names: None,
845            keep_rows: Some(vec![false, false, true]),
846        };
847        let e = write_10x_matrix(m, "gene", "", out.to_str().unwrap(), &SparseIoBackend::Zarr)
848            .unwrap_err()
849            .to_string();
850        assert!(e.contains("2 are of a kept type"), "{e}");
851        assert!(e.contains("1 are in `keep_rows`"), "{e}");
852        assert!(!out.exists());
853    }
854}