Initial commit
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
//! 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<i16> 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<BtiCoilDef>,
|
||||
}
|
||||
|
||||
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<BtiChannel>,
|
||||
/// Additional parameters
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl BtiConfig {
|
||||
/// Parse a BTi config file
|
||||
pub fn from_file(path: impl AsRef<Path>) -> IoResult<Self> {
|
||||
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<BtiChannel> = 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::<f64>().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::<usize>().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::<usize>().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::<usize>().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::<i16>()
|
||||
.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<BtiChannel> = (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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user