modewise_hdf5/
lib.rs

1//! Reads multichannel signals from HDF5 files. Pure Rust, no HDF5 C library.
2
3use std::path::Path;
4
5use hdf5_pure::{DType, File};
6use modewise_signals::{MultichannelSeries, SamplingRate, SignalError};
7use ndarray::Array2;
8use thiserror::Error;
9
10/// How the 2D dataset is oriented on disk.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Layout {
13    #[default]
14    ChannelsByTime,
15    TimeByChannels,
16}
17
18#[derive(Debug, Error)]
19pub enum Hdf5Error {
20    #[error("cannot open {path}: {message}")]
21    Open { path: String, message: String },
22    #[error("cannot read dataset {dataset:?}: {message}")]
23    Dataset { dataset: String, message: String },
24    #[error("dataset {dataset:?} must be 2D (channels and time), got shape {shape:?}")]
25    NotTwoDimensional { dataset: String, shape: Vec<u64> },
26    #[error("dataset {dataset:?} is not numeric ({dtype})")]
27    NotNumeric { dataset: String, dtype: String },
28    #[error(transparent)]
29    Signal(#[from] SignalError),
30}
31
32/// Reads one 2D numeric dataset as a signal. Any integer or float type is converted to f64.
33/// Without `labels`, channels are named `ch0`, `ch1`, ...
34pub fn read_series(
35    path: &Path,
36    dataset: &str,
37    layout: Layout,
38    sampling_rate: SamplingRate,
39    labels: Option<Vec<String>>,
40) -> Result<MultichannelSeries, Hdf5Error> {
41    let file = File::open(path).map_err(|e| Hdf5Error::Open {
42        path: path.display().to_string(),
43        message: e.to_string(),
44    })?;
45    let dataset_error = |message: String| Hdf5Error::Dataset {
46        dataset: dataset.to_string(),
47        message,
48    };
49    let ds = file
50        .dataset(dataset)
51        .map_err(|e| dataset_error(e.to_string()))?;
52
53    let dtype = ds.dtype().map_err(|e| dataset_error(e.to_string()))?;
54    if !is_numeric(&dtype) {
55        return Err(Hdf5Error::NotNumeric {
56            dataset: dataset.to_string(),
57            dtype: format!("{dtype:?}"),
58        });
59    }
60    let shape = ds.shape().map_err(|e| dataset_error(e.to_string()))?;
61    let [rows, cols] = shape[..] else {
62        return Err(Hdf5Error::NotTwoDimensional {
63            dataset: dataset.to_string(),
64            shape,
65        });
66    };
67    let values = ds.read_f64().map_err(|e| dataset_error(e.to_string()))?;
68    let stored = Array2::from_shape_vec((rows as usize, cols as usize), values)
69        .map_err(|e| dataset_error(e.to_string()))?;
70    let data = match layout {
71        Layout::ChannelsByTime => stored,
72        Layout::TimeByChannels => stored.reversed_axes().as_standard_layout().into_owned(),
73    };
74
75    let labels = labels.unwrap_or_else(|| (0..data.nrows()).map(|i| format!("ch{i}")).collect());
76    Ok(MultichannelSeries::new(data, sampling_rate, labels)?)
77}
78
79fn is_numeric(dtype: &DType) -> bool {
80    use DType::*;
81    matches!(
82        dtype,
83        F32 | F64 | I8 | I16 | I32 | I64 | U8 | U16 | U32 | U64
84    )
85}