//! 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 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, } 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, } impl Res4Header { /// Parse a RES4 file pub fn from_file(path: impl AsRef) -> IoResult { 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::()? as usize; // Read sampling frequency reader.seek(SeekFrom::Start(RES4_SFREQ_OFFSET as u64))?; let sfreq = reader.read_f64::()?; // Read number of samples per trial reader.seek(SeekFrom::Start(RES4_NSAMP_OFFSET as u64))?; let n_samples_per_trial = reader.read_i32::()? as usize; // Read number of trials reader.seek(SeekFrom::Start(RES4_NTRIALS_OFFSET as u64))?; let n_trials = reader.read_i32::()? 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::()?; // Read sensor type reader.seek(SeekFrom::Start((ch_offset + CH_SENSOR_TYPE_OFFSET) as u64))?; let sensor_type = reader.read_i16::()?; // Read gains reader.seek(SeekFrom::Start((ch_offset + CH_PROPER_GAIN_OFFSET) as u64))?; let proper_gain = reader.read_f64::()?; reader.seek(SeekFrom::Start((ch_offset + CH_Q_GAIN_OFFSET) as u64))?; let q_gain = reader.read_f64::()?; reader.seek(SeekFrom::Start((ch_offset + CH_IO_GAIN_OFFSET) as u64))?; let io_gain = reader.read_f64::()?; reader.seek(SeekFrom::Start((ch_offset + CH_IO_OFFSET_OFFSET) as u64))?; let io_offset = reader.read_f64::()?; // 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::()? as usize; reader.seek(SeekFrom::Start((ch_offset + CH_GRAD_ORDER_OFFSET) as u64))?; let grad_order = reader.read_i16::()?; // 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::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_POS_Y_OFFSET) as u64))?; let pos_y = reader.read_f64::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_POS_Z_OFFSET) as u64))?; let pos_z = reader.read_f64::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_ORI_X_OFFSET) as u64))?; let ori_x = reader.read_f64::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_ORI_Y_OFFSET) as u64))?; let ori_y = reader.read_f64::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_ORI_Z_OFFSET) as u64))?; let ori_z = reader.read_f64::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_AREA_OFFSET) as u64))?; let area = reader.read_f64::()?; reader.seek(SeekFrom::Start((coil_offset + COIL_TURNS_OFFSET) as u64))?; let turns = reader.read_i32::()?; 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" ); } }