//! BrainVision format reader (.vhdr/.vmrk/.eeg files). //! //! BrainVision format consists of three files: //! - `.vhdr` - Header file (INI-like format) //! - `.vmrk` - Marker file (events) //! - `.eeg` or `.dat` - Binary data file use crate::{IoError, IoResult, NeuroReader}; use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::{Path, PathBuf}; /// BrainVision data format #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrainVisionFormat { /// Binary INT 16 (little endian) Int16, /// Binary IEEE float 32 Float32, /// ASCII (text format) Ascii, } /// BrainVision file header #[derive(Debug, Clone)] pub struct BrainVisionHeader { /// Data file path (relative or absolute) pub data_file: PathBuf, /// Marker file path pub marker_file: Option, /// Data format pub format: BrainVisionFormat, /// Data orientation (multiplexed or vectorized) pub multiplexed: bool, /// Number of channels pub n_channels: usize, /// Sampling interval in microseconds pub sampling_interval_us: f64, /// Channel information pub channels: Vec, } impl BrainVisionHeader { /// Sampling frequency in Hz #[must_use] pub fn sfreq(&self) -> f64 { 1_000_000.0 / self.sampling_interval_us } } /// BrainVision channel information #[derive(Debug, Clone)] pub struct BrainVisionChannel { /// Channel name pub name: String, /// Reference channel name pub reference: Option, /// Resolution (scaling factor to uV) pub resolution: f64, /// Unit (e.g., "µV") pub unit: String, } /// BrainVision marker (event) #[derive(Debug, Clone)] pub struct BrainVisionMarker { /// Marker type (e.g., "Stimulus", "Response") pub marker_type: String, /// Marker description pub description: String, /// Position in samples (1-based in file, 0-based here) pub position: usize, /// Duration in samples pub duration: usize, /// Channel (0 = all channels) pub channel: usize, } /// BrainVision file reader pub struct BrainVisionReader { /// Header file path header_path: PathBuf, /// Parsed header header: BrainVisionHeader, /// Markers markers: Vec, /// Total number of samples (calculated from file size) n_samples: usize, } impl BrainVisionReader { /// Open a BrainVision header file (.vhdr) pub fn open(path: impl AsRef) -> IoResult { let header_path = path.as_ref().to_path_buf(); if !header_path.exists() { return Err(IoError::FileNotFound(header_path.display().to_string())); } let header = Self::parse_header(&header_path)?; let markers = if let Some(ref marker_file) = header.marker_file { let marker_path = header_path.parent().unwrap().join(marker_file); Self::parse_markers(&marker_path)? } else { Vec::new() }; // Calculate number of samples from data file size let data_path = header_path.parent().unwrap().join(&header.data_file); let file_size = std::fs::metadata(&data_path)?.len() as usize; let bytes_per_sample = match header.format { BrainVisionFormat::Int16 => 2, BrainVisionFormat::Float32 => 4, BrainVisionFormat::Ascii => { return Err(IoError::UnsupportedVersion( "ASCII format not yet supported".to_string(), )); } }; let n_samples = file_size / (bytes_per_sample * header.n_channels); Ok(Self { header_path, header, markers, n_samples, }) } /// Get parsed header #[must_use] pub fn header(&self) -> &BrainVisionHeader { &self.header } /// Get markers #[must_use] pub fn markers(&self) -> &[BrainVisionMarker] { &self.markers } /// Parse the header file fn parse_header(path: &Path) -> IoResult { let file = File::open(path)?; let reader = BufReader::new(file); let mut sections: HashMap> = HashMap::new(); let mut current_section = String::new(); for line in reader.lines() { let line = line?; let line = line.trim(); if line.is_empty() || line.starts_with(';') { continue; } if line.starts_with('[') && line.ends_with(']') { current_section = line[1..line.len() - 1].to_string(); sections.insert(current_section.clone(), HashMap::new()); } else if let Some(pos) = line.find('=') { let key = line[..pos].trim().to_string(); let value = line[pos + 1..].trim().to_string(); if let Some(section) = sections.get_mut(¤t_section) { section.insert(key, value); } } } // Parse Common Infos let common = sections .get("Common Infos") .ok_or_else(|| IoError::HeaderParse("Missing [Common Infos] section".to_string()))?; let data_file = common .get("DataFile") .ok_or_else(|| IoError::HeaderParse("Missing DataFile".to_string()))? .into(); let marker_file = common.get("MarkerFile").map(|s| PathBuf::from(s)); let n_channels: usize = common .get("NumberOfChannels") .ok_or_else(|| IoError::HeaderParse("Missing NumberOfChannels".to_string()))? .parse() .map_err(|_| IoError::HeaderParse("Invalid NumberOfChannels".to_string()))?; let sampling_interval_us: f64 = common .get("SamplingInterval") .ok_or_else(|| IoError::HeaderParse("Missing SamplingInterval".to_string()))? .parse() .map_err(|_| IoError::HeaderParse("Invalid SamplingInterval".to_string()))?; // Parse Binary Infos let binary = sections.get("Binary Infos"); let format = if let Some(binary) = binary { match binary.get("BinaryFormat").map(String::as_str) { Some("INT_16") => BrainVisionFormat::Int16, Some("IEEE_FLOAT_32") => BrainVisionFormat::Float32, _ => BrainVisionFormat::Int16, } } else { BrainVisionFormat::Int16 }; let multiplexed = common .get("DataOrientation") .map(|s| s == "MULTIPLEXED") .unwrap_or(true); // Parse Channel Infos let channel_info = sections.get("Channel Infos"); let mut channels = Vec::with_capacity(n_channels); if let Some(ch_info) = channel_info { for i in 1..=n_channels { let key = format!("Ch{i}"); if let Some(value) = ch_info.get(&key) { let parts: Vec<&str> = value.split(',').collect(); let name = parts.first().map(|s| s.trim().to_string()).unwrap_or(key); let reference = parts.get(1).map(|s| s.trim().to_string()); let resolution: f64 = parts .get(2) .and_then(|s| s.trim().parse().ok()) .unwrap_or(1.0); let unit = parts .get(3) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "µV".to_string()); channels.push(BrainVisionChannel { name, reference, resolution, unit, }); } } } // Fill missing channels with defaults while channels.len() < n_channels { channels.push(BrainVisionChannel { name: format!("Ch{}", channels.len() + 1), reference: None, resolution: 1.0, unit: "µV".to_string(), }); } Ok(BrainVisionHeader { data_file, marker_file, format, multiplexed, n_channels, sampling_interval_us, channels, }) } /// Parse marker file fn parse_markers(path: &Path) -> IoResult> { if !path.exists() { return Ok(Vec::new()); } let file = File::open(path)?; let reader = BufReader::new(file); let mut markers = Vec::new(); let mut in_marker_section = false; for line in reader.lines() { let line = line?; let line = line.trim(); if line.starts_with("[Marker Infos]") { in_marker_section = true; continue; } if line.starts_with('[') { in_marker_section = false; continue; } if !in_marker_section || line.is_empty() || line.starts_with(';') { continue; } // Format: Mk=,,,, if let Some(pos) = line.find('=') { let value = &line[pos + 1..]; let parts: Vec<&str> = value.split(',').collect(); if parts.len() >= 4 { let marker_type = parts[0].trim().to_string(); let description = parts[1].trim().to_string(); let position: usize = parts[2].trim().parse().unwrap_or(1) - 1; // Convert to 0-based let duration: usize = parts[3].trim().parse().unwrap_or(1); let channel: usize = parts .get(4) .and_then(|s| s.trim().parse().ok()) .unwrap_or(0); markers.push(BrainVisionMarker { marker_type, description, position, duration, channel, }); } } } Ok(markers) } } impl NeuroReader for BrainVisionReader { fn read_header(&mut self) -> IoResult<()> { // Header is already parsed in open() Ok(()) } fn sfreq(&self) -> f64 { self.header.sfreq() } fn n_channels(&self) -> usize { self.header.n_channels } fn n_samples(&self) -> usize { self.n_samples } fn channel_names(&self) -> Vec { self.header .channels .iter() .map(|c| c.name.clone()) .collect() } fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult> { let sfreq = self.header.sfreq(); let start_sample = (tmin * sfreq).floor() as usize; let end_sample = (tmax * sfreq).ceil() as usize; let n_samples = (end_sample - start_sample).min(self.n_samples - start_sample); let n_channels = self.header.n_channels; let data_path = self .header_path .parent() .unwrap() .join(&self.header.data_file); let mut file = File::open(&data_path)?; let bytes_per_sample = match self.header.format { BrainVisionFormat::Int16 => 2, BrainVisionFormat::Float32 => 4, BrainVisionFormat::Ascii => { return Err(IoError::UnsupportedVersion( "ASCII format not supported".to_string(), )); } }; // Seek to start position let start_byte = start_sample * n_channels * bytes_per_sample; file.seek(SeekFrom::Start(start_byte as u64))?; let mut data = vec![0.0; n_channels * n_samples]; if self.header.multiplexed { // Data is interleaved: ch1_s1, ch2_s1, ... chN_s1, ch1_s2, ... use byteorder::{LittleEndian, ReadBytesExt}; for s in 0..n_samples { for ch in 0..n_channels { let value = match self.header.format { BrainVisionFormat::Int16 => { let raw = file.read_i16::()?; f64::from(raw) * self.header.channels[ch].resolution } BrainVisionFormat::Float32 => { let raw = file.read_f32::()?; f64::from(raw) * self.header.channels[ch].resolution } BrainVisionFormat::Ascii => unreachable!(), }; // Store in channel-major order [ch0: s0, s1, ..., ch1: s0, s1, ...] data[ch * n_samples + s] = value; } } } else { // Vectorized: all samples for ch1, then all for ch2, etc. use byteorder::{LittleEndian, ReadBytesExt}; for ch in 0..n_channels { let ch_start_byte = ch * self.n_samples * bytes_per_sample + start_sample * bytes_per_sample; file.seek(SeekFrom::Start(ch_start_byte as u64))?; for s in 0..n_samples { let value = match self.header.format { BrainVisionFormat::Int16 => { let raw = file.read_i16::()?; f64::from(raw) * self.header.channels[ch].resolution } BrainVisionFormat::Float32 => { let raw = file.read_f32::()?; f64::from(raw) * self.header.channels[ch].resolution } BrainVisionFormat::Ascii => unreachable!(), }; data[ch * n_samples + s] = value; } } } Ok(data) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_sfreq_calculation() { let header = BrainVisionHeader { data_file: PathBuf::from("test.eeg"), marker_file: None, format: BrainVisionFormat::Int16, multiplexed: true, n_channels: 32, sampling_interval_us: 2000.0, // 500 Hz channels: Vec::new(), }; assert!((header.sfreq() - 500.0).abs() < 0.01); } }