//! BTi Config File Parser //! //! Parses the ASCII `config` file containing channel definitions and calibrations. use crate::{IoError, IoResult}; use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; use super::constants::*; /// BTi channel type enumeration #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BtiChannelKind { /// MEG magnetometer/gradiometer Meg, /// EEG channel Eeg, /// Reference channel Ref, /// External/auxiliary channel Ext, /// Trigger channel Trig, /// Utility channel Util, /// Derived/computed channel Deriv, /// Shape/position channel Shape, /// Response channel Resp, /// Unknown channel type Unknown(i16), } impl From for BtiChannelKind { fn from(value: i16) -> Self { match value { BTI_MEG => Self::Meg, BTI_EEG => Self::Eeg, BTI_REF => Self::Ref, BTI_EXT => Self::Ext, BTI_TRIG => Self::Trig, BTI_UTIL => Self::Util, BTI_DERIV => Self::Deriv, BTI_SHAPE => Self::Shape, BTI_RESP => Self::Resp, other => Self::Unknown(other), } } } impl BtiChannelKind { /// Get string representation pub fn as_str(&self) -> &'static str { match self { Self::Meg => "MEG", Self::Eeg => "EEG", Self::Ref => "REF", Self::Ext => "EXT", Self::Trig => "TRIG", Self::Util => "UTIL", Self::Deriv => "DERIV", Self::Shape => "SHAPE", Self::Resp => "RESP", Self::Unknown(_) => "UNKNOWN", } } } /// Coil definition for MEG sensors #[derive(Debug, Clone)] pub struct BtiCoilDef { /// Position (x, y, z) in meters pub position: [f64; 3], /// Orientation (x, y, z) unit vector pub orientation: [f64; 3], /// Coil radius in meters pub radius: f64, /// Number of turns pub turns: i32, } /// BTi channel information #[derive(Debug, Clone)] pub struct BtiChannel { /// Channel name (e.g., "A1", "A2", "EEG001") pub name: String, /// Channel index (0-based) pub index: usize, /// Channel type pub kind: BtiChannelKind, /// Sensor type (magnetometer, gradiometer, etc.) pub sensor_type: i16, /// Calibration factor (scales raw to physical units) pub cal: f64, /// Units string (e.g., "T", "V") pub units: String, /// Coil definitions (for MEG channels) pub coils: Vec, } impl BtiChannel { /// Get unit string based on channel type pub fn default_units(&self) -> &'static str { match self.kind { BtiChannelKind::Meg | BtiChannelKind::Ref => "T", BtiChannelKind::Eeg => "V", BtiChannelKind::Trig => "V", _ => "AU", } } } /// Parsed BTi configuration #[derive(Debug, Clone)] pub struct BtiConfig { /// Sampling frequency in Hz pub sfreq: f64, /// Number of channels pub n_channels: usize, /// Number of epochs pub n_epochs: usize, /// Samples per epoch pub epoch_size: usize, /// Channel definitions pub channels: Vec, /// Additional parameters pub params: HashMap, } impl BtiConfig { /// Parse a BTi config file pub fn from_file(path: impl AsRef) -> IoResult { let path = path.as_ref(); let file = File::open(path).map_err(|e| { IoError::Io(std::io::Error::new( e.kind(), format!("Failed to open config file: {}", path.display()), )) })?; let reader = BufReader::new(file); let mut params = HashMap::new(); let mut channels = Vec::new(); let mut current_section = String::new(); let mut current_channel: Option = None; for line in reader.lines() { let line = line?; let line = line.trim(); // Skip empty lines and comments if line.is_empty() || line.starts_with('#') || line.starts_with(';') { continue; } // Check for section header if line.starts_with('[') && line.ends_with(']') { // Save previous channel if any if let Some(ch) = current_channel.take() { channels.push(ch); } current_section = line[1..line.len() - 1].to_lowercase(); continue; } // Parse key=value pairs if let Some(eq_pos) = line.find('=') { let key = line[..eq_pos].trim().to_lowercase(); let value = line[eq_pos + 1..].trim(); match current_section.as_str() { "channels" | "channel" => { // Handle channel-specific fields if key == "name" { // Start new channel if let Some(ch) = current_channel.take() { channels.push(ch); } current_channel = Some(BtiChannel { name: value.to_string(), index: channels.len(), kind: BtiChannelKind::Unknown(0), sensor_type: 0, cal: 1.0, units: String::new(), coils: Vec::new(), }); } else if let Some(ref mut ch) = current_channel { Self::parse_channel_field(ch, &key, value); } } _ => { // General parameters params.insert(key, value.to_string()); } } } } // Save last channel if let Some(ch) = current_channel { channels.push(ch); } // Extract key parameters let sfreq = params .get(CONFIG_SFREQ) .or_else(|| params.get("sample_rate")) .or_else(|| params.get("sfreq")) .and_then(|s| s.parse::().ok()) .unwrap_or(1000.0); let n_channels = params .get(CONFIG_NCHAN) .or_else(|| params.get("total_chans")) .or_else(|| params.get("nchan")) .and_then(|s| s.parse::().ok()) .unwrap_or(channels.len()); let n_epochs = params .get(CONFIG_NEPOCH) .or_else(|| params.get("total_epochs")) .or_else(|| params.get("nepoch")) .and_then(|s| s.parse::().ok()) .unwrap_or(1); let epoch_size = params .get(CONFIG_EPOCH_SIZE) .or_else(|| params.get("epoch_size")) .or_else(|| params.get("nsamp")) .and_then(|s| s.parse::().ok()) .unwrap_or(0); Ok(Self { sfreq, n_channels, n_epochs, epoch_size, channels, params, }) } /// Parse a channel field fn parse_channel_field(channel: &mut BtiChannel, key: &str, value: &str) { match key { "type" | "chan_type" => { channel.kind = value .parse::() .map(BtiChannelKind::from) .unwrap_or(BtiChannelKind::Unknown(0)); } "sensor_type" => { channel.sensor_type = value.parse().unwrap_or(0); } "cal" | "calibration" | "scale" => { channel.cal = value.parse().unwrap_or(1.0); } "units" | "unit" => { channel.units = value.to_string(); } "index" | "chan_no" => { channel.index = value.parse().unwrap_or(channel.index); } _ => {} } } /// Create a minimal config from PDF header values pub fn from_pdf_header( sfreq: f64, n_channels: usize, n_epochs: usize, epoch_size: usize, ) -> Self { // Create default channels let channels: Vec = (0..n_channels) .map(|i| BtiChannel { name: format!("MEG{:03}", i + 1), index: i, kind: BtiChannelKind::Meg, sensor_type: BTI_SENSOR_MAG, cal: 1.0, units: "T".to_string(), coils: Vec::new(), }) .collect(); Self { sfreq, n_channels, n_epochs, epoch_size, channels, params: HashMap::new(), } } /// Get total number of samples pub fn n_samples(&self) -> usize { self.n_epochs * self.epoch_size } /// Get duration in seconds pub fn duration(&self) -> f64 { self.n_samples() as f64 / self.sfreq } } #[cfg(test)] mod tests { use super::*; #[test] fn test_channel_kind_from_id() { assert_eq!(BtiChannelKind::from(BTI_MEG), BtiChannelKind::Meg); assert_eq!(BtiChannelKind::from(BTI_EEG), BtiChannelKind::Eeg); assert_eq!(BtiChannelKind::from(BTI_REF), BtiChannelKind::Ref); assert_eq!(BtiChannelKind::from(BTI_TRIG), BtiChannelKind::Trig); assert_eq!(BtiChannelKind::from(999), BtiChannelKind::Unknown(999)); } #[test] fn test_channel_kind_str() { assert_eq!(BtiChannelKind::Meg.as_str(), "MEG"); assert_eq!(BtiChannelKind::Eeg.as_str(), "EEG"); assert_eq!(BtiChannelKind::Ref.as_str(), "REF"); assert_eq!(BtiChannelKind::Trig.as_str(), "TRIG"); } #[test] fn test_config_from_pdf_header() { let config = BtiConfig::from_pdf_header(1000.0, 148, 1, 10000); assert_eq!(config.sfreq, 1000.0); assert_eq!(config.n_channels, 148); assert_eq!(config.n_epochs, 1); assert_eq!(config.epoch_size, 10000); assert_eq!(config.n_samples(), 10000); assert_eq!(config.duration(), 10.0); assert_eq!(config.channels.len(), 148); } #[test] fn test_data_type_size() { assert_eq!(BtiDataType::Short.size(), 2); assert_eq!(BtiDataType::Long.size(), 4); assert_eq!(BtiDataType::Float.size(), 4); assert_eq!(BtiDataType::Double.size(), 8); } }