Initial commit
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user