392 lines
12 KiB
Rust
392 lines
12 KiB
Rust
//! CTF RES4 Resource File Parser
|
|
//!
|
|
//! Parses the `.res4` file containing acquisition parameters and channel info.
|
|
|
|
use crate::{IoError, IoResult};
|
|
use byteorder::{BigEndian, ReadBytesExt};
|
|
use std::fs::File;
|
|
use std::io::{BufReader, Read, Seek, SeekFrom};
|
|
use std::path::Path;
|
|
|
|
use super::constants::*;
|
|
|
|
/// CTF channel type enumeration
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum CtfChannelKind {
|
|
/// MEG magnetometer/gradiometer
|
|
Meg,
|
|
/// Reference MEG channel
|
|
RefMeg,
|
|
/// EEG channel
|
|
Eeg,
|
|
/// Stimulus/trigger channel
|
|
Stim,
|
|
/// Analog to digital channel
|
|
Adc,
|
|
/// Head localization coil
|
|
Hlc,
|
|
/// Digital input
|
|
Din,
|
|
/// Digital auxiliary
|
|
Dac,
|
|
/// System clock
|
|
Sclk,
|
|
/// SAM (synthetic aperture magnetometry)
|
|
Sam,
|
|
/// Virtual channel
|
|
Virtual,
|
|
/// System channel
|
|
Sys,
|
|
/// Unknown channel type
|
|
Unknown(i16),
|
|
}
|
|
|
|
impl From<i16> for CtfChannelKind {
|
|
fn from(value: i16) -> Self {
|
|
match value {
|
|
CTF_MEG_CH => Self::Meg,
|
|
CTF_REF_MEG_CH => Self::RefMeg,
|
|
CTF_EEG_CH => Self::Eeg,
|
|
CTF_STIM_CH => Self::Stim,
|
|
CTF_ADC_CH => Self::Adc,
|
|
CTF_HLC_CH => Self::Hlc,
|
|
CTF_DIN_CH => Self::Din,
|
|
CTF_DAC_CH => Self::Dac,
|
|
CTF_SCLK_CH => Self::Sclk,
|
|
CTF_SAM_CH => Self::Sam,
|
|
CTF_VIRTUAL_CH => Self::Virtual,
|
|
CTF_SYS_CH => Self::Sys,
|
|
other => Self::Unknown(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CtfChannelKind {
|
|
/// Get string representation
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Meg => "MEG",
|
|
Self::RefMeg => "REF_MEG",
|
|
Self::Eeg => "EEG",
|
|
Self::Stim => "STIM",
|
|
Self::Adc => "ADC",
|
|
Self::Hlc => "HLC",
|
|
Self::Din => "DIN",
|
|
Self::Dac => "DAC",
|
|
Self::Sclk => "SCLK",
|
|
Self::Sam => "SAM",
|
|
Self::Virtual => "VIRTUAL",
|
|
Self::Sys => "SYS",
|
|
Self::Unknown(_) => "UNKNOWN",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Coil information for a sensor
|
|
#[derive(Debug, Clone)]
|
|
pub struct CoilInfo {
|
|
/// Position in 3D space (x, y, z) in meters
|
|
pub position: [f64; 3],
|
|
/// Orientation unit vector (x, y, z)
|
|
pub orientation: [f64; 3],
|
|
/// Coil area in m²
|
|
pub area: f64,
|
|
/// Number of turns
|
|
pub turns: i32,
|
|
}
|
|
|
|
/// CTF channel information
|
|
#[derive(Debug, Clone)]
|
|
pub struct CtfChannel {
|
|
/// Channel name (e.g., "MLT11", "EEG001")
|
|
pub name: String,
|
|
/// Channel type
|
|
pub kind: CtfChannelKind,
|
|
/// Raw channel type ID
|
|
pub kind_id: i16,
|
|
/// Sensor type ID
|
|
pub sensor_type: i16,
|
|
/// Proper gain (primary calibration)
|
|
pub proper_gain: f64,
|
|
/// Quality gain
|
|
pub q_gain: f64,
|
|
/// I/O gain
|
|
pub io_gain: f64,
|
|
/// I/O offset
|
|
pub io_offset: f64,
|
|
/// Gradient order (0, 1, 2, or 3)
|
|
pub grad_order: i16,
|
|
/// Coil information for this sensor
|
|
pub coils: Vec<CoilInfo>,
|
|
}
|
|
|
|
impl CtfChannel {
|
|
/// Calculate total scaling factor for this channel
|
|
pub fn scale(&self) -> f64 {
|
|
// CTF scaling: proper_gain * q_gain * io_gain
|
|
self.proper_gain * self.q_gain * self.io_gain
|
|
}
|
|
|
|
/// Get unit string for this channel type
|
|
pub fn unit(&self) -> &'static str {
|
|
match self.kind {
|
|
CtfChannelKind::Meg | CtfChannelKind::RefMeg => "T",
|
|
CtfChannelKind::Eeg => "V",
|
|
CtfChannelKind::Stim | CtfChannelKind::Din => "V",
|
|
CtfChannelKind::Adc | CtfChannelKind::Dac => "V",
|
|
_ => "AU",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parsed RES4 header containing all acquisition parameters
|
|
#[derive(Debug, Clone)]
|
|
pub struct Res4Header {
|
|
/// File format version (41 or 42)
|
|
pub version: u8,
|
|
/// Number of channels
|
|
pub n_channels: usize,
|
|
/// Sampling frequency in Hz
|
|
pub sfreq: f64,
|
|
/// Number of samples per trial
|
|
pub n_samples_per_trial: usize,
|
|
/// Number of trials
|
|
pub n_trials: usize,
|
|
/// Total number of samples (n_samples_per_trial * n_trials)
|
|
pub n_samples: usize,
|
|
/// Channel information
|
|
pub channels: Vec<CtfChannel>,
|
|
}
|
|
|
|
impl Res4Header {
|
|
/// Parse a RES4 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 RES4 file: {}", path.display()),
|
|
))
|
|
})?;
|
|
let mut reader = BufReader::new(file);
|
|
|
|
// Read and validate magic header
|
|
let mut magic = [0u8; 8];
|
|
reader.read_exact(&mut magic)?;
|
|
|
|
let version = if &magic == RES4_MAGIC_V41 {
|
|
41
|
|
} else if &magic == RES4_MAGIC_V42 {
|
|
42
|
|
} else {
|
|
return Err(IoError::InvalidFormat(format!(
|
|
"Invalid RES4 magic header: {:?}",
|
|
String::from_utf8_lossy(&magic)
|
|
)));
|
|
};
|
|
|
|
// Read general header info
|
|
// Note: CTF files are big-endian
|
|
|
|
// Seek to n_channels offset
|
|
reader.seek(SeekFrom::Start(RES4_NCHAN_OFFSET as u64))?;
|
|
let n_channels = reader.read_i32::<BigEndian>()? as usize;
|
|
|
|
// Read sampling frequency
|
|
reader.seek(SeekFrom::Start(RES4_SFREQ_OFFSET as u64))?;
|
|
let sfreq = reader.read_f64::<BigEndian>()?;
|
|
|
|
// Read number of samples per trial
|
|
reader.seek(SeekFrom::Start(RES4_NSAMP_OFFSET as u64))?;
|
|
let n_samples_per_trial = reader.read_i32::<BigEndian>()? as usize;
|
|
|
|
// Read number of trials
|
|
reader.seek(SeekFrom::Start(RES4_NTRIALS_OFFSET as u64))?;
|
|
let n_trials = reader.read_i32::<BigEndian>()? as usize;
|
|
|
|
// Calculate total samples
|
|
let n_samples = n_samples_per_trial * n_trials;
|
|
|
|
// Read channel info
|
|
let mut channels = Vec::with_capacity(n_channels);
|
|
|
|
for ch_idx in 0..n_channels {
|
|
let ch_offset = RES4_CHANNEL_INFO_OFFSET + ch_idx * RES4_CHANNEL_INFO_SIZE;
|
|
|
|
// Read channel name
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_NAME_OFFSET) as u64))?;
|
|
let mut name_buf = [0u8; MAX_CHANNEL_NAME];
|
|
reader.read_exact(&mut name_buf)?;
|
|
let name = String::from_utf8_lossy(&name_buf)
|
|
.trim_end_matches('\0')
|
|
.trim()
|
|
.to_string();
|
|
|
|
// Read channel type
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_TYPE_OFFSET) as u64))?;
|
|
let kind_id = reader.read_i16::<BigEndian>()?;
|
|
|
|
// Read sensor type
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_SENSOR_TYPE_OFFSET) as u64))?;
|
|
let sensor_type = reader.read_i16::<BigEndian>()?;
|
|
|
|
// Read gains
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_PROPER_GAIN_OFFSET) as u64))?;
|
|
let proper_gain = reader.read_f64::<BigEndian>()?;
|
|
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_Q_GAIN_OFFSET) as u64))?;
|
|
let q_gain = reader.read_f64::<BigEndian>()?;
|
|
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_IO_GAIN_OFFSET) as u64))?;
|
|
let io_gain = reader.read_f64::<BigEndian>()?;
|
|
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_IO_OFFSET_OFFSET) as u64))?;
|
|
let io_offset = reader.read_f64::<BigEndian>()?;
|
|
|
|
// Read number of coils and gradient order
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_NUM_COILS_OFFSET) as u64))?;
|
|
let num_coils = reader.read_i16::<BigEndian>()? as usize;
|
|
|
|
reader.seek(SeekFrom::Start((ch_offset + CH_GRAD_ORDER_OFFSET) as u64))?;
|
|
let grad_order = reader.read_i16::<BigEndian>()?;
|
|
|
|
// Read coil information
|
|
let mut coils = Vec::with_capacity(num_coils.min(MAX_COILS));
|
|
for coil_idx in 0..num_coils.min(MAX_COILS) {
|
|
let coil_offset = ch_offset + CH_COILS_OFFSET + coil_idx * COIL_INFO_SIZE;
|
|
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_POS_X_OFFSET) as u64))?;
|
|
let pos_x = reader.read_f64::<BigEndian>()?;
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_POS_Y_OFFSET) as u64))?;
|
|
let pos_y = reader.read_f64::<BigEndian>()?;
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_POS_Z_OFFSET) as u64))?;
|
|
let pos_z = reader.read_f64::<BigEndian>()?;
|
|
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_ORI_X_OFFSET) as u64))?;
|
|
let ori_x = reader.read_f64::<BigEndian>()?;
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_ORI_Y_OFFSET) as u64))?;
|
|
let ori_y = reader.read_f64::<BigEndian>()?;
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_ORI_Z_OFFSET) as u64))?;
|
|
let ori_z = reader.read_f64::<BigEndian>()?;
|
|
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_AREA_OFFSET) as u64))?;
|
|
let area = reader.read_f64::<BigEndian>()?;
|
|
|
|
reader.seek(SeekFrom::Start((coil_offset + COIL_TURNS_OFFSET) as u64))?;
|
|
let turns = reader.read_i32::<BigEndian>()?;
|
|
|
|
coils.push(CoilInfo {
|
|
position: [pos_x, pos_y, pos_z],
|
|
orientation: [ori_x, ori_y, ori_z],
|
|
area,
|
|
turns,
|
|
});
|
|
}
|
|
|
|
channels.push(CtfChannel {
|
|
name,
|
|
kind: CtfChannelKind::from(kind_id),
|
|
kind_id,
|
|
sensor_type,
|
|
proper_gain,
|
|
q_gain,
|
|
io_gain,
|
|
io_offset,
|
|
grad_order,
|
|
coils,
|
|
});
|
|
}
|
|
|
|
Ok(Self {
|
|
version,
|
|
n_channels,
|
|
sfreq,
|
|
n_samples_per_trial,
|
|
n_trials,
|
|
n_samples,
|
|
channels,
|
|
})
|
|
}
|
|
|
|
/// 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!(CtfChannelKind::from(CTF_MEG_CH), CtfChannelKind::Meg);
|
|
assert_eq!(CtfChannelKind::from(CTF_REF_MEG_CH), CtfChannelKind::RefMeg);
|
|
assert_eq!(CtfChannelKind::from(CTF_EEG_CH), CtfChannelKind::Eeg);
|
|
assert_eq!(CtfChannelKind::from(CTF_STIM_CH), CtfChannelKind::Stim);
|
|
assert_eq!(CtfChannelKind::from(999), CtfChannelKind::Unknown(999));
|
|
}
|
|
|
|
#[test]
|
|
fn test_channel_kind_str() {
|
|
assert_eq!(CtfChannelKind::Meg.as_str(), "MEG");
|
|
assert_eq!(CtfChannelKind::RefMeg.as_str(), "REF_MEG");
|
|
assert_eq!(CtfChannelKind::Eeg.as_str(), "EEG");
|
|
assert_eq!(CtfChannelKind::Stim.as_str(), "STIM");
|
|
}
|
|
|
|
#[test]
|
|
fn test_channel_scale() {
|
|
let ch = CtfChannel {
|
|
name: "MEG001".to_string(),
|
|
kind: CtfChannelKind::Meg,
|
|
kind_id: CTF_MEG_CH,
|
|
sensor_type: CTF_275_MAG,
|
|
proper_gain: 1e-15,
|
|
q_gain: 1.0,
|
|
io_gain: 1.0,
|
|
io_offset: 0.0,
|
|
grad_order: 3,
|
|
coils: vec![],
|
|
};
|
|
assert!((ch.scale() - 1e-15).abs() < 1e-20);
|
|
}
|
|
|
|
#[test]
|
|
fn test_channel_unit() {
|
|
assert_eq!(
|
|
CtfChannel {
|
|
name: "MEG001".to_string(),
|
|
kind: CtfChannelKind::Meg,
|
|
kind_id: 0,
|
|
sensor_type: 0,
|
|
proper_gain: 1.0,
|
|
q_gain: 1.0,
|
|
io_gain: 1.0,
|
|
io_offset: 0.0,
|
|
grad_order: 0,
|
|
coils: vec![],
|
|
}
|
|
.unit(),
|
|
"T"
|
|
);
|
|
|
|
assert_eq!(
|
|
CtfChannel {
|
|
name: "EEG001".to_string(),
|
|
kind: CtfChannelKind::Eeg,
|
|
kind_id: 2,
|
|
sensor_type: 0,
|
|
proper_gain: 1.0,
|
|
q_gain: 1.0,
|
|
io_gain: 1.0,
|
|
io_offset: 0.0,
|
|
grad_order: 0,
|
|
coils: vec![],
|
|
}
|
|
.unit(),
|
|
"V"
|
|
);
|
|
}
|
|
}
|