1use 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
20pub type ZarrStoreRw = (Arc<dyn ZReadStorageTraits>, Option<Arc<FilesystemStore>>);
24
25fn 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
52pub 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
87pub fn open_zarr_store(path: &str) -> anyhow::Result<Arc<dyn ZReadStorageTraits>> {
89 open_zarr_store_rw(path).map(|(r, _)| r)
90}
91
92pub fn apply_zip_flag(output: &str, zip: bool, backend: &SparseIoBackend) -> Box<str> {
101 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
116pub 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
137pub 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
152pub 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 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
202pub 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
214pub 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 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
256pub 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
279pub 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
317pub 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
341pub fn read_zarr_ndarray<T>(
345 store: Arc<dyn ZReadStorageTraits>,
346 key_name: &str,
347) -> anyhow::Result<ndarray::ArrayD<T>>
348where
349 T: zarrs::array::ElementOwned + FromPrimitive,
350{
351 let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
352
353 let dt = arr.data_type();
354 if *dt == data_type::float32() {
355 let array: ndarray::ArrayD<f32> =
356 arr.retrieve_array_subset::<ndarray::ArrayD<f32>>(&arr.subset_all())?;
357 Ok(array.mapv(|x| T::from_f32(x).unwrap()))
358 } else if *dt == data_type::float64() {
359 let array: ndarray::ArrayD<f64> =
360 arr.retrieve_array_subset::<ndarray::ArrayD<f64>>(&arr.subset_all())?;
361 Ok(array.mapv(|x| T::from_f64(x).unwrap()))
362 } else if *dt == data_type::uint32() {
363 let array: ndarray::ArrayD<u32> =
364 arr.retrieve_array_subset::<ndarray::ArrayD<u32>>(&arr.subset_all())?;
365 Ok(array.mapv(|x| T::from_u32(x).unwrap()))
366 } else if *dt == data_type::uint64() {
367 let array: ndarray::ArrayD<u64> =
368 arr.retrieve_array_subset::<ndarray::ArrayD<u64>>(&arr.subset_all())?;
369 Ok(array.mapv(|x| T::from_u64(x).unwrap()))
370 } else {
371 anyhow::bail!("unsupported zarr data type: {:?}", dt)
372 }
373}
374
375pub fn read_zarr_numerics<T>(
377 store: Arc<dyn ZReadStorageTraits>,
378 key_name: &str,
379) -> anyhow::Result<Vec<T>>
380where
381 T: zarrs::array::ElementOwned + FromPrimitive,
382{
383 let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
384
385 let dt = arr.data_type();
386 let ret = if *dt == data_type::float32() {
387 arr.retrieve_array_subset::<Vec<f32>>(&arr.subset_all())?
388 .into_iter()
389 .map(|x| T::from_f32(x).unwrap())
390 .collect()
391 } else if *dt == data_type::float64() {
392 arr.retrieve_array_subset::<Vec<f64>>(&arr.subset_all())?
393 .into_iter()
394 .map(|x| T::from_f64(x).unwrap())
395 .collect()
396 } else if *dt == data_type::uint32() {
397 arr.retrieve_array_subset::<Vec<u32>>(&arr.subset_all())?
398 .into_iter()
399 .map(|x| T::from_u32(x).unwrap())
400 .collect()
401 } else if *dt == data_type::uint64() {
402 arr.retrieve_array_subset::<Vec<u64>>(&arr.subset_all())?
403 .into_iter()
404 .map(|x| T::from_u64(x).unwrap())
405 .collect()
406 } else {
407 anyhow::bail!("unsupported zarr data type: {:?}", dt);
408 };
409
410 Ok(ret)
411}
412
413pub fn read_zarr_group_attr<V>(
418 store: Arc<dyn ZReadStorageTraits>,
419 key_name: &str,
420) -> anyhow::Result<V>
421where
422 V: serde::de::DeserializeOwned,
423{
424 use anyhow::Context;
425
426 fn parse_key_name(key_name: &str) -> (Box<str>, Box<str>) {
427 let trimmed = key_name.strip_prefix('/').unwrap_or(key_name);
428 match trimmed.rsplit_once('/') {
429 Some((left, right)) => (
430 format!("/{}", left).into_boxed_str(),
431 right.to_string().into_boxed_str(),
432 ),
433 None => (
434 "/".to_string().into_boxed_str(),
435 trimmed.to_string().into_boxed_str(),
436 ),
437 }
438 }
439
440 let (group_name, attr_name) = parse_key_name(key_name);
441
442 let group = zarrs::group::Group::open_opt(
443 store,
444 group_name.as_ref(),
445 &MetadataRetrieveVersion::Default,
446 )
447 .with_context(|| format!("Failed to open group '{}'", group_name))?;
448
449 let attr_value = group
450 .attributes()
451 .get(attr_name.as_ref())
452 .with_context(|| {
453 format!(
454 "Attribute '{}' not found in group '{}'",
455 attr_name, group_name
456 )
457 })?;
458
459 Ok(serde_json::from_value(attr_value.clone())?)
460}
461
462pub fn read_zarr_strings(
464 store: Arc<dyn ZReadStorageTraits>,
465 key_name: &str,
466) -> anyhow::Result<Vec<Box<str>>> {
467 let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
468
469 Ok(arr
470 .retrieve_array_subset::<Vec<String>>(&arr.subset_all())?
471 .into_iter()
472 .map(|x| x.into_boxed_str())
473 .collect())
474}
475
476fn hex_to_shifted_lookup() -> [Option<char>; 256] {
480 let mut lookup = [None; 256];
481 for (i, ch) in "0123456789abcdef".chars().enumerate() {
482 lookup[ch as usize] = Some(
483 [
484 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p',
485 ][i],
486 );
487 }
488 lookup
489}
490
491fn encode_10x_cell_id(
493 lookup: &[Option<char>; 256],
494 barcode: u32,
495 suffix: u32,
496) -> anyhow::Result<Box<str>> {
497 let barcode: String = format!("{:08x}", barcode)
498 .chars()
499 .map(|ch| lookup[ch as usize].ok_or_else(|| anyhow::anyhow!("invalid hex char: {}", ch)))
500 .collect::<anyhow::Result<String>>()?;
501 Ok(format!("{}-{}", barcode, suffix).into_boxed_str())
502}
503
504pub fn parse_10x_cell_id(
508 input: ndarray::ArrayView<u32, ndarray::IxDyn>,
509) -> anyhow::Result<Vec<Box<str>>> {
510 anyhow::ensure!(
511 input.ndim() == 2 && input.shape()[1] == 2,
512 "Must be 2D with shape [N, 2]"
513 );
514 let lookup = hex_to_shifted_lookup();
515 input
516 .outer_iter()
517 .map(|row| encode_10x_cell_id(&lookup, row[0], row[1]))
518 .collect()
519}
520
521pub fn parse_10x_cell_id_flat(data: &[u32], nrows: usize) -> anyhow::Result<Vec<Box<str>>> {
525 anyhow::ensure!(data.len() == nrows * 2, "cell_id buffer size mismatch");
526 let lookup = hex_to_shifted_lookup();
527 (0..nrows)
528 .map(|i| encode_10x_cell_id(&lookup, data[i * 2], data[i * 2 + 1]))
529 .collect()
530}
531
532fn select_columns_to_dmatrix(
537 data: &[f32],
538 nrows: usize,
539 ncols_total: usize,
540 selected: &[usize],
541) -> anyhow::Result<DMatrix<f32>> {
542 let k = selected.len();
543 let mut col_major = vec![0.0f32; nrows * k];
544 for (col_out, &col_in) in selected.iter().enumerate() {
545 anyhow::ensure!(col_in < ncols_total, "column index {} out of range", col_in);
546 for row in 0..nrows {
547 col_major[row + col_out * nrows] = data[row * ncols_total + col_in];
548 }
549 }
550 Ok(DMatrix::from_vec(nrows, k, col_major))
551}
552
553fn resolve_columns(
555 column_indices: &[usize],
556 column_names: &[Box<str>],
557 all_col_names: &[Box<str>],
558 default_cols: &[usize],
559) -> anyhow::Result<Vec<usize>> {
560 if !column_indices.is_empty() {
561 Ok(column_indices.to_vec())
562 } else if !column_names.is_empty() {
563 let matched: Vec<usize> = column_names
566 .iter()
567 .filter_map(|name| all_col_names.iter().position(|c| c == name))
568 .collect();
569 if matched.is_empty() {
570 anyhow::bail!(
571 "none of the requested columns {:?} found (available: {:?})",
572 column_names,
573 all_col_names
574 );
575 }
576 Ok(matched)
577 } else {
578 Ok(default_cols.to_vec())
579 }
580}
581
582pub fn read_zarr_matrix(
595 file_path: &str,
596 data_array: &str,
597 col_names_attr: Option<&str>,
598 row_names_array: Option<&str>,
599 row_names_10x: bool,
600 column_indices: &[usize],
601 column_names: &[Box<str>],
602) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
603 let store = open_zarr_store(file_path)?;
604
605 let (flat, shape) = read_zarr_flat_f32(store.clone(), data_array)?;
607 let nrows = shape[0] as usize;
608 let ncols_total = if shape.len() > 1 {
609 shape[1] as usize
610 } else {
611 1
612 };
613
614 let all_col_names: Vec<Box<str>> = if let Some(attr) = col_names_attr {
616 read_zarr_array_attr(store.clone(), data_array, attr).unwrap_or_else(|_| {
617 (0..ncols_total)
618 .map(|i| i.to_string().into_boxed_str())
619 .collect()
620 })
621 } else {
622 (0..ncols_total)
623 .map(|i| i.to_string().into_boxed_str())
624 .collect()
625 };
626
627 let selected = resolve_columns(column_indices, column_names, &all_col_names, &[0, 1])?;
629 let sel_names: Vec<Box<str>> = selected
630 .iter()
631 .map(|&i| {
632 all_col_names
633 .get(i)
634 .cloned()
635 .unwrap_or_else(|| i.to_string().into_boxed_str())
636 })
637 .collect();
638
639 let mat = select_columns_to_dmatrix(&flat, nrows, ncols_total, &selected)?;
641
642 let row_names = if let Some(rn_path) = row_names_array {
644 if row_names_10x {
645 let (id_data, id_shape) = read_zarr_flat_u32(store.clone(), rn_path)?;
646 anyhow::ensure!(
647 id_shape[0] as usize == nrows,
648 "row names array rows ({}) != data rows ({})",
649 id_shape[0],
650 nrows
651 );
652 parse_10x_cell_id_flat(&id_data, nrows)?
653 } else {
654 read_zarr_strings(store.clone(), rn_path)?
655 }
656 } else {
657 (0..nrows).map(|i| i.to_string().into_boxed_str()).collect()
658 };
659
660 info!(
661 "Read {} x {} from zarr {}{}: {:?}",
662 nrows,
663 selected.len(),
664 file_path,
665 data_array,
666 sel_names
667 );
668
669 Ok(MatWithNames {
670 rows: row_names,
671 cols: sel_names,
672 mat,
673 })
674}
675
676pub fn read_zarr_coordinates(
685 file_path: &str,
686 column_indices: &[usize],
687 column_names: &[Box<str>],
688) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
689 read_zarr_matrix(
690 file_path,
691 "/cell_summary",
692 Some("column_names"),
693 Some("/cell_id"),
694 true,
695 column_indices,
696 column_names,
697 )
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn xenium_path() -> Option<std::path::PathBuf> {
705 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
706 .parent()
707 .unwrap()
708 .join("docs/temp/cells.zarr.zip");
709 p.exists().then_some(p)
710 }
711
712 #[test]
713 fn test_parse_10x_cell_id_flat() {
714 let data = vec![16844u32, 1, 22527, 1];
715 let ids = parse_10x_cell_id_flat(&data, 2).unwrap();
716 assert_eq!(ids.len(), 2);
717 assert!(ids[0].ends_with("-1"), "got: {}", ids[0]);
718 assert!(ids[1].ends_with("-1"), "got: {}", ids[1]);
719 assert_eq!(ids[0].len(), 10);
720 }
721
722 #[test]
723 fn test_parse_10x_cell_id_flat_mismatch() {
724 assert!(parse_10x_cell_id_flat(&[1, 2, 3], 2).is_err());
725 }
726
727 #[test]
728 fn test_read_zarr_coordinates_by_name() {
729 let Some(p) = xenium_path() else { return };
730 let result = read_zarr_coordinates(
731 p.to_str().unwrap(),
732 &[],
733 &["cell_centroid_x".into(), "cell_centroid_y".into()],
734 )
735 .unwrap();
736
737 assert_eq!(result.mat.ncols(), 2);
738 assert!(result.mat.nrows() > 0);
739 assert_eq!(result.rows.len(), result.mat.nrows());
740 assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
741 assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
742 assert!(result.mat.min() >= 0.0);
743 }
744
745 #[test]
746 fn test_read_zarr_coordinates_by_index() {
747 let Some(p) = xenium_path() else { return };
748 let result = read_zarr_coordinates(p.to_str().unwrap(), &[0, 1], &[]).unwrap();
749 assert_eq!(result.mat.ncols(), 2);
750 assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
751 }
752
753 #[test]
754 fn test_read_zarr_coordinates_default() {
755 let Some(p) = xenium_path() else { return };
756 let result = read_zarr_coordinates(p.to_str().unwrap(), &[], &[]).unwrap();
757 assert_eq!(result.mat.ncols(), 2);
758 }
759
760 #[test]
761 fn test_read_zarr_coordinates_bad_column() {
762 let Some(p) = xenium_path() else { return };
763 let result = read_zarr_coordinates(
764 p.to_str().unwrap(),
765 &[],
766 &["nonexistent".to_string().into_boxed_str()],
767 );
768 assert!(result.is_err());
769 }
770
771 #[test]
772 fn test_read_zarr_coordinates_mixed_defaults() {
773 let Some(p) = xenium_path() else { return };
776 let result = read_zarr_coordinates(
777 p.to_str().unwrap(),
778 &[],
779 &[
780 "pxl_row_in_fullres".into(),
781 "pxl_col_in_fullres".into(),
782 "cell_centroid_x".into(),
783 "cell_centroid_y".into(),
784 ],
785 )
786 .unwrap();
787 assert_eq!(result.mat.ncols(), 2);
788 assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
789 assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
790 assert!(result.mat.nrows() > 0);
791 }
792
793 #[test]
794 fn test_read_zarr_array_attr() {
795 let Some(p) = xenium_path() else { return };
796 let store = open_zarr_store(p.to_str().unwrap()).unwrap();
797 let names: Vec<Box<str>> =
798 read_zarr_array_attr(store, "/cell_summary", "column_names").unwrap();
799 assert!(!names.is_empty());
800 assert!(names.contains(&"cell_centroid_x".to_string().into_boxed_str()));
801 }
802
803 #[test]
804 fn test_read_zarr_matrix_generic() {
805 let Some(p) = xenium_path() else { return };
806 let result = read_zarr_matrix(
808 p.to_str().unwrap(),
809 "/cell_summary",
810 Some("column_names"),
811 Some("/cell_id"),
812 true,
813 &[2, 3],
814 &[],
815 )
816 .unwrap();
817 assert_eq!(result.mat.ncols(), 2);
818 assert_eq!(result.cols[0].as_ref(), "cell_area");
819 assert_eq!(result.cols[1].as_ref(), "nucleus_centroid_x");
820 }
821}