Files
rustytorch/crates/specialized/rtx-neuro-io/src/bti/config.rs
T
2026-03-04 00:08:42 +00:00

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);
}
}