Skip to main content

data_beans/
hdf5_io.rs

1use crate::sparse_io::*;
2
3#[cfg(feature = "hdf5")]
4use hdf5::types::FixedAscii;
5#[cfg(feature = "hdf5")]
6use hdf5::types::FixedUnicode;
7#[cfg(feature = "hdf5")]
8use hdf5::types::TypeDescriptor;
9#[cfg(feature = "hdf5")]
10use hdf5::types::VarLenUnicode;
11
12/// Column names and their string data from an H5AD obs/var dataframe
13#[cfg(feature = "hdf5")]
14pub struct H5adDataFrame {
15    pub col_names: Vec<Box<str>>,
16    pub col_data: Vec<Vec<Box<str>>>,
17}
18
19/// Strip known backend extensions (`.zarr.zip`, `.zarr`, `.h5`) from a path,
20/// returning the bare output prefix.
21pub fn strip_backend_suffix(path: &str) -> &str {
22    path.strip_suffix(".zarr.zip")
23        .or_else(|| path.strip_suffix(".zarr"))
24        .or_else(|| path.strip_suffix(".h5"))
25        .unwrap_or(path)
26}
27
28/// Resolve the backend type and the corresponding file path
29///
30/// If you want to decide the backend by the file name:
31/// `resolve_backend_file(&data_file_path, None)`
32/// If you want to check/revise output backend file name:
33/// `resolve_backend_file(&output_header, Some(backend))`
34///
35/// If there were an extension string in the output_header,
36/// we will change the backend to accommodate the backend type
37/// implicated by the file name.
38///
39pub fn resolve_backend_file(
40    file_path: &str,
41    backend: Option<SparseIoBackend>,
42) -> anyhow::Result<(SparseIoBackend, Box<str>)> {
43    use legume_numeric::matrix::common_io::file_ext;
44    let ext = file_ext(file_path).unwrap_or(Box::<str>::from(""));
45
46    if let Some(backend) = backend {
47        let mut resolved_backend = backend;
48        let mut backend_file = file_path.to_string();
49
50        // if needed, change the backend to match with the file extension
51        match ext.as_ref() {
52            "zarr" => {
53                resolved_backend = SparseIoBackend::Zarr;
54            }
55            "h5" => {
56                resolved_backend = SparseIoBackend::HDF5;
57            }
58            "zip" if file_path.ends_with(".zarr.zip") => {
59                resolved_backend = SparseIoBackend::Zarr;
60                // strip .zip — write to .zarr dir, caller finalizes via finalize_zarr_output
61                backend_file = file_path
62                    .strip_suffix(".zip")
63                    .unwrap_or(file_path)
64                    .to_string();
65            }
66            _ => {
67                // there is no recognized extension — append based on backend
68                backend_file = match resolved_backend {
69                    SparseIoBackend::HDF5 => format!("{}.h5", file_path),
70                    SparseIoBackend::Zarr => format!("{}.zarr", file_path),
71                }
72            }
73        };
74
75        // When the binary was built without the `hdf5` feature, transparently
76        // fall back to a Zarr write target instead of letting the factory bail
77        // later. Applies only to writes (this `Some(backend)` branch) — for
78        // reads the existing `.h5` file isn't Zarr, so we leave the backend
79        // as HDF5 and let `open_sparse_matrix` surface the rebuild guidance.
80        #[cfg(not(feature = "hdf5"))]
81        if resolved_backend == SparseIoBackend::HDF5 {
82            let stripped = strip_backend_suffix(&backend_file);
83            let new_path = format!("{}.zarr", stripped);
84            log::warn!(
85                "HDF5 output requested but this binary was built without the \
86                 `hdf5` feature; writing Zarr instead ({} -> {}). Pass \
87                 `--zip=true` (or use a `.zarr.zip` output path) for a zipped \
88                 archive.",
89                &backend_file,
90                &new_path
91            );
92            return Ok((SparseIoBackend::Zarr, new_path.into_boxed_str()));
93        }
94
95        Ok((resolved_backend, backend_file.into_boxed_str()))
96    } else {
97        // backend has to be inferred
98        let resolved_backend = match ext.as_ref() {
99            "zarr" => SparseIoBackend::Zarr,
100            "h5" => SparseIoBackend::HDF5,
101            "zip" if file_path.ends_with(".zarr.zip") => SparseIoBackend::Zarr,
102            _ => return Err(anyhow::anyhow!("Unknown file format: {}", file_path)),
103        };
104
105        // For reads, keep the full path (e.g., "foo.zarr.zip") so
106        // SparseMtxData::open can use ZipStorageAdapter directly.
107        let backend_file = file_path.to_string();
108
109        Ok((resolved_backend, backend_file.into_boxed_str()))
110    }
111}
112
113/// Read a single column from an AnnData HDF5 DataFrame group (obs or var).
114///
115/// Handles:
116/// - Categorical columns (subgroups with `codes` + `categories` datasets)
117/// - String datasets (VarLenUnicode, FixedAscii, FixedUnicode)
118/// - Boolean datasets
119/// - Integer and float datasets (converted to string)
120#[cfg(feature = "hdf5")]
121pub fn read_h5ad_column(group: &hdf5::Group, col_name: &str) -> anyhow::Result<Vec<Box<str>>> {
122    // Try as categorical first: column is a subgroup with codes + categories
123    if let Ok(col_group) = group.group(col_name) {
124        let categories = read_hdf5_strings(col_group.dataset("categories")?)?;
125        let codes_ds = col_group.dataset("codes")?;
126        let dtype = codes_ds.dtype()?;
127        let desc = dtype.to_descriptor()?;
128
129        let codes: Vec<i32> = match desc {
130            TypeDescriptor::Integer(sz) => match sz {
131                hdf5::types::IntSize::U1 => codes_ds
132                    .read_1d::<i8>()?
133                    .iter()
134                    .map(|&x| x as i32)
135                    .collect(),
136                hdf5::types::IntSize::U2 => codes_ds
137                    .read_1d::<i16>()?
138                    .iter()
139                    .map(|&x| x as i32)
140                    .collect(),
141                hdf5::types::IntSize::U4 => codes_ds.read_1d::<i32>()?.to_vec(),
142                hdf5::types::IntSize::U8 => codes_ds
143                    .read_1d::<i64>()?
144                    .iter()
145                    .map(|&x| x as i32)
146                    .collect(),
147            },
148            TypeDescriptor::Unsigned(sz) => match sz {
149                hdf5::types::IntSize::U1 => codes_ds
150                    .read_1d::<u8>()?
151                    .iter()
152                    .map(|&x| x as i32)
153                    .collect(),
154                hdf5::types::IntSize::U2 => codes_ds
155                    .read_1d::<u16>()?
156                    .iter()
157                    .map(|&x| x as i32)
158                    .collect(),
159                hdf5::types::IntSize::U4 => codes_ds
160                    .read_1d::<u32>()?
161                    .iter()
162                    .map(|&x| x as i32)
163                    .collect(),
164                hdf5::types::IntSize::U8 => codes_ds
165                    .read_1d::<u64>()?
166                    .iter()
167                    .map(|&x| x as i32)
168                    .collect(),
169            },
170            _ => {
171                return Err(anyhow::anyhow!(
172                    "unsupported codes dtype for categorical '{}'",
173                    col_name
174                ));
175            }
176        };
177
178        let result: Vec<Box<str>> = codes
179            .iter()
180            .map(|&c| {
181                if c < 0 {
182                    "NA".to_string().into_boxed_str()
183                } else {
184                    categories[c as usize].clone()
185                }
186            })
187            .collect();
188
189        return Ok(result);
190    }
191
192    // Otherwise it's a direct dataset
193    let ds = group.dataset(col_name)?;
194    let dtype = ds.dtype()?;
195    let desc = dtype.to_descriptor()?;
196
197    match desc {
198        TypeDescriptor::VarLenUnicode
199        | TypeDescriptor::FixedAscii(_)
200        | TypeDescriptor::FixedUnicode(_) => read_hdf5_strings(ds),
201        TypeDescriptor::Boolean => {
202            let data = ds.read_1d::<bool>()?;
203            Ok(data
204                .iter()
205                .map(|&b| if b { "true" } else { "false" }.into())
206                .collect())
207        }
208        TypeDescriptor::Integer(_) => {
209            let data = ds.read_1d::<i64>()?;
210            Ok(data
211                .iter()
212                .map(|x| x.to_string().into_boxed_str())
213                .collect())
214        }
215        TypeDescriptor::Unsigned(_) => {
216            let data = ds.read_1d::<u64>()?;
217            Ok(data
218                .iter()
219                .map(|x| x.to_string().into_boxed_str())
220                .collect())
221        }
222        TypeDescriptor::Float(sz) => match sz {
223            hdf5::types::FloatSize::U4 => {
224                let data = ds.read_1d::<f32>()?;
225                Ok(data
226                    .iter()
227                    .map(|x| x.to_string().into_boxed_str())
228                    .collect())
229            }
230            hdf5::types::FloatSize::U8 => {
231                let data = ds.read_1d::<f64>()?;
232                Ok(data
233                    .iter()
234                    .map(|x| x.to_string().into_boxed_str())
235                    .collect())
236            }
237        },
238        _ => Err(anyhow::anyhow!(
239            "unsupported dtype for column '{}'",
240            col_name
241        )),
242    }
243}
244
245/// Try reading strings from a group by trying each field name in order.
246///
247/// For each field, calls `read_h5ad_column` which handles categorical,
248/// string, numeric, and boolean datasets. Returns `None` if no field
249/// succeeds.
250#[cfg(feature = "hdf5")]
251pub fn resolve_h5ad_field(
252    group: &hdf5::Group,
253    fields: &[Box<str>],
254    label: &str,
255) -> Option<Vec<Box<str>>> {
256    for field in fields {
257        let field = field.trim();
258        if field.is_empty() {
259            continue;
260        }
261        if let Ok(v) = read_h5ad_column(group, field) {
262            log::info!("Using '{}' for {} ({} entries)", field, label, v.len());
263            return Some(v);
264        }
265    }
266    None
267}
268
269/// Read all columns from an AnnData HDF5 DataFrame group (obs or var).
270///
271/// Uses the `column-order` attribute to discover column names,
272/// then reads each column via `read_h5ad_column`.
273/// Columns that fail to read are skipped with a warning.
274///
275/// Returns `(column_names, columns_data)` where each entry in
276/// `columns_data` is a `Vec<Box<str>>` of the same length.
277#[cfg(feature = "hdf5")]
278pub fn read_h5ad_dataframe(group: &hdf5::Group) -> anyhow::Result<H5adDataFrame> {
279    let col_order: Vec<String> = match group.attr("column-order") {
280        Ok(attr) => match attr.read_1d::<VarLenUnicode>() {
281            Ok(arr) => arr.iter().map(|x| x.to_string()).collect(),
282            Err(e) => {
283                log::warn!("Failed to read column-order attribute: {}", e);
284                vec![]
285            }
286        },
287        Err(_) => {
288            log::warn!("No column-order attribute found; returning empty dataframe");
289            vec![]
290        }
291    };
292
293    let mut col_names = Vec::new();
294    let mut col_data = Vec::new();
295
296    for col_name in &col_order {
297        match read_h5ad_column(group, col_name) {
298            Ok(data) => {
299                col_names.push(col_name.clone().into_boxed_str());
300                col_data.push(data);
301            }
302            Err(e) => {
303                log::warn!("Skipping obs column '{}': {}", col_name, e);
304            }
305        }
306    }
307
308    Ok(H5adDataFrame {
309        col_names,
310        col_data,
311    })
312}
313
314/// Read strings from `HDF5` dataset
315#[cfg(feature = "hdf5")]
316pub fn read_hdf5_strings(data: hdf5::dataset::Dataset) -> anyhow::Result<Vec<Box<str>>> {
317    let dtype = data.dtype()?;
318    let desc = dtype.to_descriptor()?;
319
320    let ret: Vec<Box<str>> = match desc {
321        TypeDescriptor::VarLenUnicode => data
322            .read_1d::<VarLenUnicode>()?
323            .map(|x| x.to_string().into_boxed_str())
324            .into_iter()
325            .collect(),
326        TypeDescriptor::FixedAscii(n) => {
327            if n < 24 {
328                data.read_1d::<FixedAscii<24>>()?
329                    .map(|x| x.to_string().into_boxed_str())
330                    .into_iter()
331                    .collect()
332            } else if n < 128 {
333                data.read_1d::<FixedAscii<128>>()?
334                    .map(|x| x.to_string().into_boxed_str())
335                    .into_iter()
336                    .collect()
337            } else {
338                data.read_1d::<FixedAscii<1024>>()?
339                    .map(|x| x.to_string().into_boxed_str())
340                    .into_iter()
341                    .collect()
342            }
343        }
344        TypeDescriptor::FixedUnicode(n) => {
345            if n < 24 {
346                data.read_1d::<FixedUnicode<24>>()?
347                    .map(|x| x.to_string().into_boxed_str())
348                    .into_iter()
349                    .collect()
350            } else if n < 128 {
351                data.read_1d::<FixedUnicode<128>>()?
352                    .map(|x| x.to_string().into_boxed_str())
353                    .into_iter()
354                    .collect()
355            } else {
356                data.read_1d::<FixedUnicode<1024>>()?
357                    .map(|x| x.to_string().into_boxed_str())
358                    .into_iter()
359                    .collect()
360            }
361        }
362        _ => {
363            return Err(anyhow::anyhow!("unsupported string"));
364        }
365    };
366
367    Ok(ret)
368}
369
370// ndarray v0.17 issue
371// use ndarray::Array1;
372// fn ndarray_into_box_str<T, U, S>(data: Array1<T>) -> Vec<Box<str>>
373// where
374//     T: RawData<Elem = U> + Data + ToString,
375// {
376//     data.into_iter()
377//         .map(|x| x.to_string().into_boxed_str())
378//         .collect()
379// }