segovia 0.3.0

A fast, chunked, memory-bounded Rust engine for electrophysiology (Neuropixels) signal processing, with Python bindings.
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum MetaError {
    #[error("failed to read meta file: {0}")]
    Io(#[from] std::io::Error),
    #[error("missing required meta key: {0}")]
    MissingKey(&'static str),
    #[error("could not parse meta key '{key}' from value '{value}'")]
    InvalidValue { key: &'static str, value: String },
}

#[derive(Debug, Clone)]
pub struct SpikeGlxMeta {
    pub n_channels: usize,
    pub sample_rate: f64,
    pub stream_type: String,
    pub declared_file_size_bytes: Option<u64>,
    pub fields: HashMap<String, String>,
}

impl SpikeGlxMeta {
    pub fn from_path(path: &Path) -> Result<Self, MetaError> {
        let text = fs::read_to_string(path)?;
        Self::parse(&text)
    }

    pub fn parse(text: &str) -> Result<Self, MetaError> {
        let mut fields = HashMap::new();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            if let Some((key, value)) = line.split_once('=') {
                fields.insert(key.trim().to_string(), value.trim().to_string());
            }
        }

        let n_channels = parse_required::<usize>(&fields, "nSavedChans")?;
        let sample_rate = parse_sample_rate(&fields)?;
        let stream_type = fields
            .get("typeThis")
            .cloned()
            .unwrap_or_else(|| "unknown".to_string());
        let declared_file_size_bytes = match fields.get("fileSizeBytes") {
            Some(value) => Some(value.parse::<u64>().map_err(|_| MetaError::InvalidValue {
                key: "fileSizeBytes",
                value: value.clone(),
            })?),
            None => None,
        };

        Ok(Self {
            n_channels,
            sample_rate,
            stream_type,
            declared_file_size_bytes,
            fields,
        })
    }
}

fn parse_required<T: std::str::FromStr>(
    fields: &HashMap<String, String>,
    key: &'static str,
) -> Result<T, MetaError> {
    let value = fields.get(key).ok_or(MetaError::MissingKey(key))?;
    value.parse::<T>().map_err(|_| MetaError::InvalidValue {
        key,
        value: value.clone(),
    })
}

fn parse_sample_rate(fields: &HashMap<String, String>) -> Result<f64, MetaError> {
    if fields.contains_key("imSampRate") {
        parse_required::<f64>(fields, "imSampRate")
    } else if fields.contains_key("niSampRate") {
        parse_required::<f64>(fields, "niSampRate")
    } else {
        Err(MetaError::MissingKey("imSampRate|niSampRate"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = "nSavedChans=385\nimSampRate=30000.5\ntypeThis=imec\nfileSizeBytes=1234\n~imroTbl=(0,1)(1,2)\n";

    #[test]
    fn parses_required_fields() {
        let meta = SpikeGlxMeta::parse(SAMPLE).unwrap();
        assert_eq!(meta.n_channels, 385);
        assert_eq!(meta.sample_rate, 30000.5);
        assert_eq!(meta.stream_type, "imec");
        assert_eq!(meta.declared_file_size_bytes, Some(1234));
    }

    #[test]
    fn keeps_complex_fields_verbatim() {
        let meta = SpikeGlxMeta::parse(SAMPLE).unwrap();
        assert_eq!(
            meta.fields.get("~imroTbl").map(String::as_str),
            Some("(0,1)(1,2)")
        );
    }

    #[test]
    fn falls_back_to_ni_sample_rate() {
        let meta = SpikeGlxMeta::parse("nSavedChans=8\nniSampRate=25000\ntypeThis=nidq\n").unwrap();
        assert_eq!(meta.sample_rate, 25000.0);
        assert_eq!(meta.stream_type, "nidq");
    }

    #[test]
    fn missing_channel_count_errors() {
        let err = SpikeGlxMeta::parse("imSampRate=30000\n").unwrap_err();
        assert!(matches!(err, MetaError::MissingKey("nSavedChans")));
    }

    #[test]
    fn missing_sample_rate_errors() {
        let err = SpikeGlxMeta::parse("nSavedChans=4\n").unwrap_err();
        assert!(matches!(
            err,
            MetaError::MissingKey("imSampRate|niSampRate")
        ));
    }
}