Skip to main content

data_beans/
zarr_io.rs

1//! General-purpose zarr I/O for reading numeric arrays and attributes from
2//! zarr directories or `.zarr.zip` archives.
3//!
4//! Also provides a convenience function for reading coordinate-style data
5//! (row names + numeric matrix) that returns `MatWithNames<DMatrix<f32>>`,
6//! matching the parquet reader interface in legume_numeric::matrix.
7
8use crate::sparse_io::SparseIoBackend;
9use legume_numeric::matrix::traits::MatWithNames;
10use log::info;
11use nalgebra::DMatrix;
12use rand_distr::num_traits::FromPrimitive;
13use std::sync::Arc;
14use zarrs::array::{data_type, Array as ZArray};
15use zarrs::config::MetadataRetrieveVersion;
16use zarrs::filesystem::FilesystemStore;
17use zarrs::storage::ReadableListableStorageTraits as ZReadStorageTraits;
18use zarrs_zip::ZipStorageAdapter;
19
20// ── Store management ────────────────────────────────────────────────────
21
22/// A read store paired with an optional write store (present for directories, `None` for zips).
23pub type ZarrStoreRw = (Arc<dyn ZReadStorageTraits>, Option<Arc<FilesystemStore>>);
24
25/// Detect the path prefix inside a zarr zip (empty if entries are at root).
26///
27/// Tries the new `foo/` prefix first (zips produced by `finalize_zarr_output`
28/// after the rename), then falls back to the legacy `foo.zarr/` prefix for
29/// archives created before the rename.
30fn detect_zip_zarr_prefix(zip_path: &std::path::Path) -> anyhow::Result<Box<str>> {
31    let filename = zip_path
32        .file_name()
33        .and_then(|f| f.to_str())
34        .ok_or_else(|| anyhow::anyhow!("invalid zip path: {:?}", zip_path))?;
35    let stem = filename.strip_suffix(".zip").unwrap_or(filename);
36    let new_prefix = format!("{}/", stem.strip_suffix(".zarr").unwrap_or(stem));
37    let legacy_prefix = format!("{}/", stem);
38
39    let file = std::fs::File::open(zip_path)?;
40    let archive = zip::ZipArchive::new(std::io::BufReader::new(file))?;
41    if archive.file_names().any(|n| n.starts_with(&new_prefix)) {
42        Ok(new_prefix.into())
43    } else if new_prefix != legacy_prefix
44        && archive.file_names().any(|n| n.starts_with(&legacy_prefix))
45    {
46        Ok(legacy_prefix.into())
47    } else {
48        Ok("".into())
49    }
50}
51
52/// Open a zarr store, returning both a read store and an optional write store.
53///
54/// Supports both `.zarr` directories and `.zarr.zip` archives.
55/// For zip archives, uses [`ZipStorageAdapter`] for direct random-access
56/// reads without extracting to a temp directory; the write store is `None`.
57pub fn open_zarr_store_rw(path: &str) -> anyhow::Result<ZarrStoreRw> {
58    let p = std::path::Path::new(path);
59    let is_zip = p.extension().is_some_and(|e| e == "zip");
60
61    if is_zip {
62        let parent = p
63            .parent()
64            .ok_or_else(|| anyhow::anyhow!("no parent directory for {}", path))?;
65        let filename = p
66            .file_name()
67            .ok_or_else(|| anyhow::anyhow!("no filename for {}", path))?
68            .to_str()
69            .ok_or_else(|| anyhow::anyhow!("non-UTF8 filename in {}", path))?;
70        let zarr_prefix = detect_zip_zarr_prefix(p)?;
71        let fs = Arc::new(FilesystemStore::new(parent)?);
72        let key = zarrs::storage::StoreKey::new(filename)?;
73        info!(
74            "Opening zarr zip store: {} (prefix: {:?})",
75            path, zarr_prefix
76        );
77        Ok((
78            Arc::new(ZipStorageAdapter::new_with_path(fs, key, &*zarr_prefix)?),
79            None,
80        ))
81    } else {
82        let fs = Arc::new(FilesystemStore::new(path)?);
83        Ok((fs.clone(), Some(fs)))
84    }
85}
86
87/// Open a zarr store for reading (convenience wrapper around [`open_zarr_store_rw`]).
88pub fn open_zarr_store(path: &str) -> anyhow::Result<Arc<dyn ZReadStorageTraits>> {
89    open_zarr_store_rw(path).map(|(r, _)| r)
90}
91
92/// If `zip` is true and the target is zarr, ensure the output path ends with
93/// `.zarr.zip`; otherwise pass through unchanged.
94///
95/// The target is treated as HDF5 (and left alone) when `backend` is
96/// [`SparseIoBackend::HDF5`] or the name already carries a `.h5` / `.hdf5`
97/// suffix. Without the `backend` signal, a bare `-o foo` under `--backend hdf5`
98/// would be zarr-ified here and then flipped back to Zarr by
99/// `resolve_backend_file` — silently ignoring the requested backend.
100pub fn apply_zip_flag(output: &str, zip: bool, backend: &SparseIoBackend) -> Box<str> {
101    // The `.zarr.zip` suffix only makes sense for a zarr target. An HDF5 target
102    // — selected by `--backend hdf5` or an explicit `.h5`/`.hdf5` name — is left
103    // untouched, so a bare `-o foo` under `--backend hdf5` resolves to `foo.h5`
104    // rather than being silently zarr-ified by `resolve_backend_file`.
105    let target_is_hdf5 = matches!(backend, SparseIoBackend::HDF5)
106        || output.ends_with(".h5")
107        || output.ends_with(".hdf5");
108    if zip && !target_is_hdf5 && !output.ends_with(".zarr.zip") {
109        let base = crate::hdf5_io::strip_backend_suffix(output);
110        format!("{}.zarr.zip", base).into()
111    } else {
112        output.into()
113    }
114}
115
116/// Resolve the output target for a handler that writes a fresh backend, and
117/// clear any pre-existing file at that path. Returns
118/// `(effective_output, backend, working_file)`, where `working_file` is the
119/// path to write (the un-zipped `.zarr` directory for a `.zarr.zip` target) —
120/// pair it with [`finalize_output`] once the backend is populated.
121pub fn prepare_output(
122    output: &str,
123    backend: SparseIoBackend,
124    zip: bool,
125) -> anyhow::Result<(Box<str>, SparseIoBackend, Box<str>)> {
126    use crate::hdf5_io::resolve_backend_file;
127    use legume_numeric::matrix::common_io::remove_file;
128
129    let effective_output = apply_zip_flag(output, zip, &backend);
130    let (backend, working_file) = resolve_backend_file(&effective_output, Some(backend))?;
131    if std::path::Path::new(working_file.as_ref()).exists() {
132        remove_file(&working_file)?;
133    }
134    Ok((effective_output, backend, working_file))
135}
136
137/// Finalize a handler's output: re-zip the working `.zarr` directory when the
138/// target is `.zarr.zip`, and return the path the user will actually find (the
139/// archive for a `.zarr.zip` target, otherwise the working file).
140pub fn finalize_output<'a>(
141    working_file: &'a str,
142    effective_output: &'a str,
143) -> anyhow::Result<&'a str> {
144    finalize_zarr_output(working_file, effective_output)?;
145    Ok(if effective_output.ends_with(".zarr.zip") {
146        effective_output
147    } else {
148        working_file
149    })
150}
151
152/// Extract a `.zarr.zip` archive into `target_dir`, transparently stripping
153/// any internal prefix produced by [`legume_numeric::matrix::common_io::zip_dir`] so
154/// the result is a flat `.zarr` directory regardless of the zip's filename.
155pub fn extract_zarr_zip(zip_path: &str, target_dir: &str) -> anyhow::Result<()> {
156    let zip_file = std::fs::File::open(zip_path)?;
157    let mut archive = zip::ZipArchive::new(std::io::BufReader::new(zip_file))?;
158
159    // Detect prefix directly from the already-opened archive (avoids a
160    // second open + central-directory parse). Try the new `foo/` prefix
161    // first, then fall back to legacy `foo.zarr/`.
162    let filename = std::path::Path::new(zip_path)
163        .file_name()
164        .and_then(|f| f.to_str())
165        .ok_or_else(|| anyhow::anyhow!("invalid zip path: {}", zip_path))?;
166    let stem = filename.strip_suffix(".zip").unwrap_or(filename);
167    let new_prefix = format!("{}/", stem.strip_suffix(".zarr").unwrap_or(stem));
168    let legacy_prefix = format!("{}/", stem);
169    let prefix: &str = if archive.file_names().any(|n| n.starts_with(&new_prefix)) {
170        &new_prefix
171    } else if new_prefix != legacy_prefix
172        && archive.file_names().any(|n| n.starts_with(&legacy_prefix))
173    {
174        &legacy_prefix
175    } else {
176        ""
177    };
178
179    std::fs::create_dir_all(target_dir)?;
180    let target = std::path::Path::new(target_dir);
181
182    for i in 0..archive.len() {
183        let mut file = archive.by_index(i)?;
184        let rel = file.name().strip_prefix(prefix).unwrap_or(file.name());
185        if rel.is_empty() {
186            continue;
187        }
188        let out_path = target.join(rel);
189        if file.is_dir() {
190            std::fs::create_dir_all(&out_path)?;
191        } else {
192            if let Some(parent) = out_path.parent() {
193                std::fs::create_dir_all(parent)?;
194            }
195            let mut outfile = std::fs::File::create(&out_path)?;
196            std::io::copy(&mut file, &mut outfile)?;
197        }
198    }
199    Ok(())
200}
201
202/// Copy input backend to a writable output location. Transparently extracts
203/// `.zarr.zip` archives into a `.zarr` directory so the output can be opened
204/// read/write.
205pub fn materialize_writable_backend(src: &str, dst: &str) -> anyhow::Result<()> {
206    if src.ends_with(".zarr.zip") {
207        info!("extracting {} → {}", src, dst);
208        extract_zarr_zip(src, dst)
209    } else {
210        legume_numeric::matrix::common_io::recursive_copy(src, dst)
211    }
212}
213
214/// If `target_path` ends with `.zarr.zip`, zip the `zarr_dir` into it and
215/// remove the directory. Otherwise this is a no-op (the directory IS the target).
216///
217/// To keep in-zip entry names compact, the directory is staged into a temp
218/// directory under its short stem (e.g. `atac.zarr` → `<tmp>/atac`) before
219/// zipping, so entries are prefixed `foo/...` rather than `foo.zarr/...`.
220/// Staging in a tempdir avoids collisions when a sibling file or directory
221/// already uses the short stem (e.g. an input dir named `atac/` next to
222/// `atac.zarr`).
223pub fn finalize_zarr_output(zarr_dir: &str, target_path: &str) -> anyhow::Result<()> {
224    if !target_path.ends_with(".zarr.zip") {
225        return Ok(());
226    }
227
228    let zarr_path = std::path::Path::new(zarr_dir);
229    let stem = zarr_path
230        .file_name()
231        .and_then(|s| s.to_str())
232        .map(|s| s.strip_suffix(".zarr").unwrap_or(s))
233        .ok_or_else(|| anyhow::anyhow!("invalid zarr_dir: {}", zarr_dir))?
234        .to_string();
235
236    // Zip to a sibling `.tmp` first so the source .zarr survives any failure
237    // (disk full, etc.). Only after the zip succeeds do we atomically rename
238    // into place and remove the source — so a crash leaves either the source
239    // directory or the final archive, never neither.
240    let tmp_target = format!("{}.tmp", target_path);
241    info!("Zipping zarr output: {} → {}", zarr_dir, target_path);
242    if let Err(e) =
243        legume_numeric::matrix::common_io::zip_dir_as(zarr_dir, &tmp_target, Some(&stem))
244    {
245        let _ = std::fs::remove_file(&tmp_target);
246        return Err(e);
247    }
248    if let Err(e) = std::fs::rename(&tmp_target, target_path) {
249        let _ = std::fs::remove_file(&tmp_target);
250        return Err(e.into());
251    }
252    std::fs::remove_dir_all(zarr_dir)?;
253    Ok(())
254}
255
256// ── Attribute reading ───────────────────────────────────────────────────
257
258/// Read an attribute from a zarr array node.
259///
260/// ```text
261/// read_zarr_array_attr::<Vec<String>>(store, "/cell_summary", "column_names")
262/// ```
263pub fn read_zarr_array_attr<V: serde::de::DeserializeOwned>(
264    store: Arc<dyn ZReadStorageTraits>,
265    array_path: &str,
266    attr_name: &str,
267) -> anyhow::Result<V> {
268    let arr = ZArray::open_opt(store, array_path, &MetadataRetrieveVersion::Default)?;
269    let attr = arr.attributes().get(attr_name).ok_or_else(|| {
270        anyhow::anyhow!(
271            "attribute '{}' not found on array '{}'",
272            attr_name,
273            array_path
274        )
275    })?;
276    Ok(serde_json::from_value(attr.clone())?)
277}
278
279// ── Flat array readers ──────────────────────────────────────────────────
280
281/// Retrieve a zarr array as a flat `Vec<f32>` (row-major) and its shape.
282///
283/// Handles f32, f64, u32, u64 source types with automatic conversion.
284pub fn read_zarr_flat_f32(
285    store: Arc<dyn ZReadStorageTraits>,
286    key: &str,
287) -> anyhow::Result<(Vec<f32>, Vec<u64>)> {
288    let arr = ZArray::open_opt(store, key, &MetadataRetrieveVersion::Default)?;
289    let shape = arr.shape().to_vec();
290    let subset = arr.subset_all();
291
292    let dt = arr.data_type();
293    let data = if *dt == data_type::float32() {
294        arr.retrieve_array_subset::<Vec<f32>>(&subset)?
295    } else if *dt == data_type::float64() {
296        arr.retrieve_array_subset::<Vec<f64>>(&subset)?
297            .into_iter()
298            .map(|x| x as f32)
299            .collect()
300    } else if *dt == data_type::uint32() {
301        arr.retrieve_array_subset::<Vec<u32>>(&subset)?
302            .into_iter()
303            .map(|x| x as f32)
304            .collect()
305    } else if *dt == data_type::uint64() {
306        arr.retrieve_array_subset::<Vec<u64>>(&subset)?
307            .into_iter()
308            .map(|x| x as f32)
309            .collect()
310    } else {
311        anyhow::bail!("unsupported zarr data type: {:?}", dt)
312    };
313
314    Ok((data, shape))
315}
316
317/// Retrieve a zarr array as a flat `Vec<u32>` and its shape.
318pub fn read_zarr_flat_u32(
319    store: Arc<dyn ZReadStorageTraits>,
320    key: &str,
321) -> anyhow::Result<(Vec<u32>, Vec<u64>)> {
322    let arr = ZArray::open_opt(store, key, &MetadataRetrieveVersion::Default)?;
323    let shape = arr.shape().to_vec();
324    let subset = arr.subset_all();
325
326    let dt = arr.data_type();
327    let data = if *dt == data_type::uint32() {
328        arr.retrieve_array_subset::<Vec<u32>>(&subset)?
329    } else if *dt == data_type::uint64() {
330        arr.retrieve_array_subset::<Vec<u64>>(&subset)?
331            .into_iter()
332            .map(|x| x as u32)
333            .collect()
334    } else {
335        anyhow::bail!("unsupported zarr data type for u32: {:?}", dt)
336    };
337
338    Ok((data, shape))
339}
340
341// ── Generic array/attribute readers (moved from misc.rs) ────────────────
342
343#[cfg(feature = "ndarray")]
344/// Read a full ndarray from zarr storage.
345pub fn read_zarr_ndarray<T>(
346    store: Arc<dyn ZReadStorageTraits>,
347    key_name: &str,
348) -> anyhow::Result<ndarray::ArrayD<T>>
349where
350    T: zarrs::array::ElementOwned + FromPrimitive,
351{
352    let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
353
354    let dt = arr.data_type();
355    if *dt == data_type::float32() {
356        let array: ndarray::ArrayD<f32> =
357            arr.retrieve_array_subset::<ndarray::ArrayD<f32>>(&arr.subset_all())?;
358        Ok(array.mapv(|x| T::from_f32(x).unwrap()))
359    } else if *dt == data_type::float64() {
360        let array: ndarray::ArrayD<f64> =
361            arr.retrieve_array_subset::<ndarray::ArrayD<f64>>(&arr.subset_all())?;
362        Ok(array.mapv(|x| T::from_f64(x).unwrap()))
363    } else if *dt == data_type::uint32() {
364        let array: ndarray::ArrayD<u32> =
365            arr.retrieve_array_subset::<ndarray::ArrayD<u32>>(&arr.subset_all())?;
366        Ok(array.mapv(|x| T::from_u32(x).unwrap()))
367    } else if *dt == data_type::uint64() {
368        let array: ndarray::ArrayD<u64> =
369            arr.retrieve_array_subset::<ndarray::ArrayD<u64>>(&arr.subset_all())?;
370        Ok(array.mapv(|x| T::from_u64(x).unwrap()))
371    } else {
372        anyhow::bail!("unsupported zarr data type: {:?}", dt)
373    }
374}
375
376/// Read a numeric vector from zarr storage.
377pub fn read_zarr_numerics<T>(
378    store: Arc<dyn ZReadStorageTraits>,
379    key_name: &str,
380) -> anyhow::Result<Vec<T>>
381where
382    T: zarrs::array::ElementOwned + FromPrimitive,
383{
384    let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
385
386    let dt = arr.data_type();
387    let ret = if *dt == data_type::float32() {
388        arr.retrieve_array_subset::<Vec<f32>>(&arr.subset_all())?
389            .into_iter()
390            .map(|x| T::from_f32(x).unwrap())
391            .collect()
392    } else if *dt == data_type::float64() {
393        arr.retrieve_array_subset::<Vec<f64>>(&arr.subset_all())?
394            .into_iter()
395            .map(|x| T::from_f64(x).unwrap())
396            .collect()
397    } else if *dt == data_type::uint32() {
398        arr.retrieve_array_subset::<Vec<u32>>(&arr.subset_all())?
399            .into_iter()
400            .map(|x| T::from_u32(x).unwrap())
401            .collect()
402    } else if *dt == data_type::uint64() {
403        arr.retrieve_array_subset::<Vec<u64>>(&arr.subset_all())?
404            .into_iter()
405            .map(|x| T::from_u64(x).unwrap())
406            .collect()
407    } else {
408        anyhow::bail!("unsupported zarr data type: {:?}", dt);
409    };
410
411    Ok(ret)
412}
413
414/// Extract an attribute from a zarr group node.
415///
416/// `key_name` is parsed as `"group_path/attr_name"`, e.g.
417/// `"/cell_features/features/id"` → group `"/cell_features/features"`, attr `"id"`.
418pub fn read_zarr_group_attr<V>(
419    store: Arc<dyn ZReadStorageTraits>,
420    key_name: &str,
421) -> anyhow::Result<V>
422where
423    V: serde::de::DeserializeOwned,
424{
425    use anyhow::Context;
426
427    fn parse_key_name(key_name: &str) -> (Box<str>, Box<str>) {
428        let trimmed = key_name.strip_prefix('/').unwrap_or(key_name);
429        match trimmed.rsplit_once('/') {
430            Some((left, right)) => (
431                format!("/{}", left).into_boxed_str(),
432                right.to_string().into_boxed_str(),
433            ),
434            None => (
435                "/".to_string().into_boxed_str(),
436                trimmed.to_string().into_boxed_str(),
437            ),
438        }
439    }
440
441    let (group_name, attr_name) = parse_key_name(key_name);
442
443    let group = zarrs::group::Group::open_opt(
444        store,
445        group_name.as_ref(),
446        &MetadataRetrieveVersion::Default,
447    )
448    .with_context(|| format!("Failed to open group '{}'", group_name))?;
449
450    let attr_value = group
451        .attributes()
452        .get(attr_name.as_ref())
453        .with_context(|| {
454            format!(
455                "Attribute '{}' not found in group '{}'",
456                attr_name, group_name
457            )
458        })?;
459
460    Ok(serde_json::from_value(attr_value.clone())?)
461}
462
463/// Read a string array from zarr storage.
464pub fn read_zarr_strings(
465    store: Arc<dyn ZReadStorageTraits>,
466    key_name: &str,
467) -> anyhow::Result<Vec<Box<str>>> {
468    let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
469
470    Ok(arr
471        .retrieve_array_subset::<Vec<String>>(&arr.subset_all())?
472        .into_iter()
473        .map(|x| x.into_boxed_str())
474        .collect())
475}
476
477// ── 10x cell ID encoding ────────────────────────────────────────────────
478
479/// Hex-digit → shifted-alpha lookup table for 10x cell ID encoding.
480fn hex_to_shifted_lookup() -> [Option<char>; 256] {
481    let mut lookup = [None; 256];
482    for (i, ch) in "0123456789abcdef".chars().enumerate() {
483        lookup[ch as usize] = Some(
484            [
485                'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p',
486            ][i],
487        );
488    }
489    lookup
490}
491
492/// Encode a single `(barcode_u32, suffix_u32)` pair into a 10x cell-ID string.
493fn encode_10x_cell_id(
494    lookup: &[Option<char>; 256],
495    barcode: u32,
496    suffix: u32,
497) -> anyhow::Result<Box<str>> {
498    let barcode: String = format!("{:08x}", barcode)
499        .chars()
500        .map(|ch| lookup[ch as usize].ok_or_else(|| anyhow::anyhow!("invalid hex char: {}", ch)))
501        .collect::<anyhow::Result<String>>()?;
502    Ok(format!("{}-{}", barcode, suffix).into_boxed_str())
503}
504
505#[cfg(feature = "ndarray")]
506/// Parse a 10x Xenium `[N, 2]` u32 cell_id ndarray into string barcodes.
507///
508/// See [10x docs](https://www.10xgenomics.com/support/software/xenium-onboard-analysis/3.4/advanced/xoa-output-zarr#cellID).
509pub fn parse_10x_cell_id(
510    input: ndarray::ArrayView<u32, ndarray::IxDyn>,
511) -> anyhow::Result<Vec<Box<str>>> {
512    anyhow::ensure!(
513        input.ndim() == 2 && input.shape()[1] == 2,
514        "Must be 2D with shape [N, 2]"
515    );
516    let lookup = hex_to_shifted_lookup();
517    input
518        .outer_iter()
519        .map(|row| encode_10x_cell_id(&lookup, row[0], row[1]))
520        .collect()
521}
522
523/// Parse a flat row-major `[N, 2]` u32 buffer into 10x Xenium cell-ID strings.
524///
525/// Same encoding as `parse_10x_cell_id` but works on a flat slice.
526pub fn parse_10x_cell_id_flat(data: &[u32], nrows: usize) -> anyhow::Result<Vec<Box<str>>> {
527    anyhow::ensure!(data.len() == nrows * 2, "cell_id buffer size mismatch");
528    let lookup = hex_to_shifted_lookup();
529    (0..nrows)
530        .map(|i| encode_10x_cell_id(&lookup, data[i * 2], data[i * 2 + 1]))
531        .collect()
532}
533
534// ── Matrix reading (generic) ────────────────────────────────────────────
535
536/// Build a column-major `DMatrix<f32>` by selecting columns from a flat
537/// row-major `[nrows, ncols_total]` buffer.
538fn select_columns_to_dmatrix(
539    data: &[f32],
540    nrows: usize,
541    ncols_total: usize,
542    selected: &[usize],
543) -> anyhow::Result<DMatrix<f32>> {
544    let k = selected.len();
545    let mut col_major = vec![0.0f32; nrows * k];
546    for (col_out, &col_in) in selected.iter().enumerate() {
547        anyhow::ensure!(col_in < ncols_total, "column index {} out of range", col_in);
548        for row in 0..nrows {
549            col_major[row + col_out * nrows] = data[row * ncols_total + col_in];
550        }
551    }
552    Ok(DMatrix::from_vec(nrows, k, col_major))
553}
554
555/// Resolve column selection: explicit indices, name lookup, or default.
556fn resolve_columns(
557    column_indices: &[usize],
558    column_names: &[Box<str>],
559    all_col_names: &[Box<str>],
560    default_cols: &[usize],
561) -> anyhow::Result<Vec<usize>> {
562    if !column_indices.is_empty() {
563        Ok(column_indices.to_vec())
564    } else if !column_names.is_empty() {
565        // Keep only names that exist — allows generous defaults
566        // covering multiple platforms (e.g. Visium + Xenium).
567        let matched: Vec<usize> = column_names
568            .iter()
569            .filter_map(|name| all_col_names.iter().position(|c| c == name))
570            .collect();
571        if matched.is_empty() {
572            anyhow::bail!(
573                "none of the requested columns {:?} found (available: {:?})",
574                column_names,
575                all_col_names
576            );
577        }
578        Ok(matched)
579    } else {
580        Ok(default_cols.to_vec())
581    }
582}
583
584/// Read a numeric matrix from a zarr array, selecting columns by index or
585/// name (looked up via an attribute on the array).
586///
587/// * `file_path` — zarr directory or `.zarr.zip`
588/// * `data_array` — path to the numeric `[N, C]` array (e.g. `/cell_summary`)
589/// * `col_names_attr` — attribute name on `data_array` that holds column
590///   names (e.g. `"column_names"`); pass `None` to skip name lookup
591/// * `row_names_array` — path to the row-name array (e.g. `/cell_id`);
592///   `None` generates numeric row names `"0", "1", …`
593/// * `row_names_10x` — if `true`, parse `row_names_array` as 10x u32 `[N,2]`
594///   cell IDs; if `false`, read as string array
595/// * `column_indices` / `column_names` — which columns to select
596pub fn read_zarr_matrix(
597    file_path: &str,
598    data_array: &str,
599    col_names_attr: Option<&str>,
600    row_names_array: Option<&str>,
601    row_names_10x: bool,
602    column_indices: &[usize],
603    column_names: &[Box<str>],
604) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
605    let store = open_zarr_store(file_path)?;
606
607    // Read the data array
608    let (flat, shape) = read_zarr_flat_f32(store.clone(), data_array)?;
609    let nrows = shape[0] as usize;
610    let ncols_total = if shape.len() > 1 {
611        shape[1] as usize
612    } else {
613        1
614    };
615
616    // Read column names from attribute (if available)
617    let all_col_names: Vec<Box<str>> = if let Some(attr) = col_names_attr {
618        read_zarr_array_attr(store.clone(), data_array, attr).unwrap_or_else(|_| {
619            (0..ncols_total)
620                .map(|i| i.to_string().into_boxed_str())
621                .collect()
622        })
623    } else {
624        (0..ncols_total)
625            .map(|i| i.to_string().into_boxed_str())
626            .collect()
627    };
628
629    // Resolve column selection
630    let selected = resolve_columns(column_indices, column_names, &all_col_names, &[0, 1])?;
631    let sel_names: Vec<Box<str>> = selected
632        .iter()
633        .map(|&i| {
634            all_col_names
635                .get(i)
636                .cloned()
637                .unwrap_or_else(|| i.to_string().into_boxed_str())
638        })
639        .collect();
640
641    // Build the matrix
642    let mat = select_columns_to_dmatrix(&flat, nrows, ncols_total, &selected)?;
643
644    // Read row names
645    let row_names = if let Some(rn_path) = row_names_array {
646        if row_names_10x {
647            let (id_data, id_shape) = read_zarr_flat_u32(store.clone(), rn_path)?;
648            anyhow::ensure!(
649                id_shape[0] as usize == nrows,
650                "row names array rows ({}) != data rows ({})",
651                id_shape[0],
652                nrows
653            );
654            parse_10x_cell_id_flat(&id_data, nrows)?
655        } else {
656            read_zarr_strings(store.clone(), rn_path)?
657        }
658    } else {
659        (0..nrows).map(|i| i.to_string().into_boxed_str()).collect()
660    };
661
662    info!(
663        "Read {} x {} from zarr {}{}: {:?}",
664        nrows,
665        selected.len(),
666        file_path,
667        data_array,
668        sel_names
669    );
670
671    Ok(MatWithNames {
672        rows: row_names,
673        cols: sel_names,
674        mat,
675    })
676}
677
678// ── Xenium convenience ──────────────────────────────────────────────────
679
680/// Read cell coordinates from a Xenium-style zarr file.
681///
682/// Shorthand for [`read_zarr_matrix`] with Xenium defaults:
683///   - data array: `/cell_summary`
684///   - column names attribute: `column_names`
685///   - row names: `/cell_id` (10x encoded)
686pub fn read_zarr_coordinates(
687    file_path: &str,
688    column_indices: &[usize],
689    column_names: &[Box<str>],
690) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
691    read_zarr_matrix(
692        file_path,
693        "/cell_summary",
694        Some("column_names"),
695        Some("/cell_id"),
696        true,
697        column_indices,
698        column_names,
699    )
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    fn xenium_path() -> Option<std::path::PathBuf> {
707        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
708            .parent()
709            .unwrap()
710            .join("docs/temp/cells.zarr.zip");
711        p.exists().then_some(p)
712    }
713
714    #[test]
715    fn test_parse_10x_cell_id_flat() {
716        let data = vec![16844u32, 1, 22527, 1];
717        let ids = parse_10x_cell_id_flat(&data, 2).unwrap();
718        assert_eq!(ids.len(), 2);
719        assert!(ids[0].ends_with("-1"), "got: {}", ids[0]);
720        assert!(ids[1].ends_with("-1"), "got: {}", ids[1]);
721        assert_eq!(ids[0].len(), 10);
722    }
723
724    #[test]
725    fn test_parse_10x_cell_id_flat_mismatch() {
726        assert!(parse_10x_cell_id_flat(&[1, 2, 3], 2).is_err());
727    }
728
729    #[test]
730    fn test_read_zarr_coordinates_by_name() {
731        let Some(p) = xenium_path() else { return };
732        let result = read_zarr_coordinates(
733            p.to_str().unwrap(),
734            &[],
735            &["cell_centroid_x".into(), "cell_centroid_y".into()],
736        )
737        .unwrap();
738
739        assert_eq!(result.mat.ncols(), 2);
740        assert!(result.mat.nrows() > 0);
741        assert_eq!(result.rows.len(), result.mat.nrows());
742        assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
743        assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
744        assert!(result.mat.min() >= 0.0);
745    }
746
747    #[test]
748    fn test_read_zarr_coordinates_by_index() {
749        let Some(p) = xenium_path() else { return };
750        let result = read_zarr_coordinates(p.to_str().unwrap(), &[0, 1], &[]).unwrap();
751        assert_eq!(result.mat.ncols(), 2);
752        assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
753    }
754
755    #[test]
756    fn test_read_zarr_coordinates_default() {
757        let Some(p) = xenium_path() else { return };
758        let result = read_zarr_coordinates(p.to_str().unwrap(), &[], &[]).unwrap();
759        assert_eq!(result.mat.ncols(), 2);
760    }
761
762    #[test]
763    fn test_read_zarr_coordinates_bad_column() {
764        let Some(p) = xenium_path() else { return };
765        let result = read_zarr_coordinates(
766            p.to_str().unwrap(),
767            &[],
768            &["nonexistent".to_string().into_boxed_str()],
769        );
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn test_read_zarr_coordinates_mixed_defaults() {
775        // Simulates pinto's generous defaults: Visium names that won't match
776        // plus Xenium names that will — only matching names should be kept.
777        let Some(p) = xenium_path() else { return };
778        let result = read_zarr_coordinates(
779            p.to_str().unwrap(),
780            &[],
781            &[
782                "pxl_row_in_fullres".into(),
783                "pxl_col_in_fullres".into(),
784                "cell_centroid_x".into(),
785                "cell_centroid_y".into(),
786            ],
787        )
788        .unwrap();
789        assert_eq!(result.mat.ncols(), 2);
790        assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
791        assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
792        assert!(result.mat.nrows() > 0);
793    }
794
795    #[test]
796    fn test_read_zarr_array_attr() {
797        let Some(p) = xenium_path() else { return };
798        let store = open_zarr_store(p.to_str().unwrap()).unwrap();
799        let names: Vec<Box<str>> =
800            read_zarr_array_attr(store, "/cell_summary", "column_names").unwrap();
801        assert!(!names.is_empty());
802        assert!(names.contains(&"cell_centroid_x".to_string().into_boxed_str()));
803    }
804
805    #[test]
806    fn test_read_zarr_matrix_generic() {
807        let Some(p) = xenium_path() else { return };
808        // Read cell_summary selecting columns 2,3 (cell_area, nucleus_centroid_x)
809        let result = read_zarr_matrix(
810            p.to_str().unwrap(),
811            "/cell_summary",
812            Some("column_names"),
813            Some("/cell_id"),
814            true,
815            &[2, 3],
816            &[],
817        )
818        .unwrap();
819        assert_eq!(result.mat.ncols(), 2);
820        assert_eq!(result.cols[0].as_ref(), "cell_area");
821        assert_eq!(result.cols[1].as_ref(), "nucleus_centroid_x");
822    }
823}