355 lines
10 KiB
Rust
355 lines
10 KiB
Rust
//! 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);
|
|
}
|
|
}
|