Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -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"
);
}
}