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,380 @@
//! BIDS Dataset Parsing
//!
//! Main structures for representing and navigating BIDS datasets.
use super::super::{IoError, IoResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use super::entities::{FileEntities, parse_bids_filename};
use super::sidecar::{ChannelsTsv, EegSidecar, EventsTsv, MegSidecar};
/// Dataset description from dataset_description.json
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "PascalCase")]
pub struct DatasetDescription {
/// Dataset name (required)
pub name: String,
/// BIDS version (required)
#[serde(rename = "BIDSVersion")]
pub bids_version: String,
/// Dataset type (raw, derivative)
#[serde(default)]
pub dataset_type: Option<String>,
/// License
#[serde(default)]
pub license: Option<String>,
/// Authors
#[serde(default)]
pub authors: Vec<String>,
/// Acknowledgements
#[serde(default)]
pub acknowledgements: Option<String>,
/// How to acknowledge
#[serde(default)]
pub how_to_acknowledge: Option<String>,
/// References and links
#[serde(default)]
pub references_and_links: Vec<String>,
}
/// A file in a BIDS dataset
#[derive(Debug, Clone)]
pub struct BidsFile {
/// Path to the data file
pub path: PathBuf,
/// Parsed filename entities
pub entities: FileEntities,
/// Path to JSON sidecar (if exists)
pub sidecar_path: Option<PathBuf>,
/// Path to channels.tsv (if exists)
pub channels_path: Option<PathBuf>,
/// Path to events.tsv (if exists)
pub events_path: Option<PathBuf>,
}
impl BidsFile {
/// Load the MEG sidecar JSON
pub fn load_meg_sidecar(&self) -> Option<IoResult<MegSidecar>> {
self.sidecar_path
.as_ref()
.map(super::sidecar::load_meg_sidecar)
}
/// Load the EEG sidecar JSON
pub fn load_eeg_sidecar(&self) -> Option<IoResult<EegSidecar>> {
self.sidecar_path
.as_ref()
.map(super::sidecar::load_eeg_sidecar)
}
/// Load the channels TSV
pub fn load_channels(&self) -> Option<IoResult<ChannelsTsv>> {
self.channels_path.as_ref().map(ChannelsTsv::from_file)
}
/// Load the events TSV
pub fn load_events(&self) -> Option<IoResult<EventsTsv>> {
self.events_path.as_ref().map(EventsTsv::from_file)
}
}
/// A session in a BIDS dataset
#[derive(Debug, Clone, Default)]
pub struct BidsSession {
/// Session label (None if no sessions)
pub label: Option<String>,
/// MEG files in this session
pub meg_files: Vec<BidsFile>,
/// EEG files in this session
pub eeg_files: Vec<BidsFile>,
}
/// A subject in a BIDS dataset
#[derive(Debug, Clone)]
pub struct BidsSubject {
/// Subject label (without "sub-" prefix)
pub label: String,
/// Sessions for this subject
pub sessions: HashMap<Option<String>, BidsSession>,
}
impl BidsSubject {
/// Get all MEG files for this subject
pub fn meg_files(&self) -> Vec<&BidsFile> {
self.sessions.values().flat_map(|s| &s.meg_files).collect()
}
/// Get all EEG files for this subject
pub fn eeg_files(&self) -> Vec<&BidsFile> {
self.sessions.values().flat_map(|s| &s.eeg_files).collect()
}
}
/// A BIDS dataset
#[derive(Debug, Clone)]
pub struct BidsDataset {
/// Root path of the dataset
pub root: PathBuf,
/// Dataset description
pub description: DatasetDescription,
/// Subjects in the dataset
pub subjects: HashMap<String, BidsSubject>,
}
impl BidsDataset {
/// Open a BIDS dataset from a directory
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let root = path.as_ref().to_path_buf();
if !root.is_dir() {
return Err(IoError::InvalidFormat(format!(
"BIDS path is not a directory: {}",
root.display()
)));
}
// Load dataset_description.json
let desc_path = root.join("dataset_description.json");
if !desc_path.exists() {
return Err(IoError::InvalidFormat(format!(
"Not a valid BIDS dataset: dataset_description.json not found in {}",
root.display()
)));
}
let desc_file = File::open(&desc_path)?;
let description: DatasetDescription = serde_json::from_reader(desc_file).map_err(|e| {
IoError::InvalidFormat(format!("Failed to parse dataset_description.json: {}", e))
})?;
// Validate required fields
if description.name.is_empty() {
return Err(IoError::InvalidFormat(
"dataset_description.json missing 'Name' field".to_string(),
));
}
if description.bids_version.is_empty() {
return Err(IoError::InvalidFormat(
"dataset_description.json missing 'BIDSVersion' field".to_string(),
));
}
// Find all subjects
let mut subjects = HashMap::new();
for entry in fs::read_dir(&root)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("sub-") && entry.path().is_dir() {
let label = name[4..].to_string();
let subject = Self::parse_subject(&entry.path(), &label)?;
subjects.insert(label, subject);
}
}
Ok(Self {
root,
description,
subjects,
})
}
/// Parse a subject directory
fn parse_subject(path: &Path, label: &str) -> IoResult<BidsSubject> {
let mut sessions = HashMap::new();
// Check for sessions
let mut has_sessions = false;
for entry in fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("ses-") && entry.path().is_dir() {
has_sessions = true;
let ses_label = name[4..].to_string();
let session = Self::parse_session(&entry.path(), Some(&ses_label))?;
sessions.insert(Some(ses_label), session);
}
}
// If no sessions, look for data directly in subject folder
if !has_sessions {
let session = Self::parse_session(path, None)?;
sessions.insert(None, session);
}
Ok(BidsSubject {
label: label.to_string(),
sessions,
})
}
/// Parse a session directory (or subject dir if no sessions)
fn parse_session(path: &Path, _label: Option<&str>) -> IoResult<BidsSession> {
let mut meg_files = Vec::new();
let mut eeg_files = Vec::new();
// Look in meg/ and eeg/ subdirectories
let meg_dir = path.join("meg");
let eeg_dir = path.join("eeg");
if meg_dir.is_dir() {
meg_files = Self::find_data_files(&meg_dir, "meg")?;
}
if eeg_dir.is_dir() {
eeg_files = Self::find_data_files(&eeg_dir, "eeg")?;
}
Ok(BidsSession {
label: _label.map(|s| s.to_string()),
meg_files,
eeg_files,
})
}
/// Find data files in a modality directory
fn find_data_files(dir: &Path, modality: &str) -> IoResult<Vec<BidsFile>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
// Skip non-data files
if name.ends_with(".json") || name.ends_with(".tsv") {
continue;
}
// Parse filename
let entities = match parse_bids_filename(&name) {
Some(e) if e.datatype.as_deref() == Some(modality) => e,
_ => continue,
};
// Find associated sidecar files
let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(&name);
let sidecar_path = dir.join(format!("{}.json", base));
let channels_path = dir.join(format!(
"{}_channels.tsv",
base.rsplit_once('_').map(|(b, _)| b).unwrap_or(base)
));
let events_path = dir.join(format!(
"{}_events.tsv",
base.rsplit_once('_').map(|(b, _)| b).unwrap_or(base)
));
files.push(BidsFile {
path,
entities,
sidecar_path: sidecar_path.exists().then_some(sidecar_path),
channels_path: channels_path.exists().then_some(channels_path),
events_path: events_path.exists().then_some(events_path),
});
}
Ok(files)
}
/// Get dataset name
pub fn name(&self) -> &str {
&self.description.name
}
/// Get BIDS version
pub fn bids_version(&self) -> &str {
&self.description.bids_version
}
/// Get list of subject labels
pub fn subject_labels(&self) -> Vec<&str> {
self.subjects.keys().map(|s| s.as_str()).collect()
}
/// Get a subject by label
pub fn get_subject(&self, label: &str) -> Option<&BidsSubject> {
self.subjects.get(label)
}
/// Get all MEG files in the dataset
pub fn all_meg_files(&self) -> Vec<&BidsFile> {
self.subjects.values().flat_map(|s| s.meg_files()).collect()
}
/// Get all EEG files in the dataset
pub fn all_eeg_files(&self) -> Vec<&BidsFile> {
self.subjects.values().flat_map(|s| s.eeg_files()).collect()
}
/// Get MEG files for a specific subject and optional session
pub fn get_meg_files(&self, subject: &str, session: Option<&str>) -> Vec<&BidsFile> {
self.subjects
.get(subject)
.map(|s| {
s.sessions
.get(&session.map(|s| s.to_string()))
.map(|ses| ses.meg_files.iter().collect())
.unwrap_or_default()
})
.unwrap_or_default()
}
/// Get EEG files for a specific subject and optional session
pub fn get_eeg_files(&self, subject: &str, session: Option<&str>) -> Vec<&BidsFile> {
self.subjects
.get(subject)
.map(|s| {
s.sessions
.get(&session.map(|s| s.to_string()))
.map(|ses| ses.eeg_files.iter().collect())
.unwrap_or_default()
})
.unwrap_or_default()
}
}
/// Check if a directory is a valid BIDS dataset
pub fn is_bids_dataset(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
path.is_dir() && path.join("dataset_description.json").exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dataset_description_deserialize() {
let json = r#"{
"Name": "My MEG Dataset",
"BIDSVersion": "1.9.0",
"DatasetType": "raw",
"License": "CC0"
}"#;
let desc: DatasetDescription = serde_json::from_str(json).unwrap();
assert_eq!(desc.name, "My MEG Dataset");
assert_eq!(desc.bids_version, "1.9.0");
assert_eq!(desc.dataset_type, Some("raw".to_string()));
assert_eq!(desc.license, Some("CC0".to_string()));
}
#[test]
fn test_is_bids_dataset() {
// This would fail on a real filesystem without a BIDS dataset
assert!(!is_bids_dataset("/nonexistent/path"));
}
}
@@ -0,0 +1,233 @@
//! BIDS Filename Entity Parsing
//!
//! Parses BIDS-compliant filenames to extract entities like subject, session, task, etc.
use std::path::Path;
/// Parsed BIDS filename entities
#[derive(Debug, Clone, Default)]
pub struct FileEntities {
/// Subject label (required)
pub subject: String,
/// Session label (optional)
pub session: Option<String>,
/// Task label (optional)
pub task: Option<String>,
/// Acquisition label (optional)
pub acquisition: Option<String>,
/// Run number (optional)
pub run: Option<u32>,
/// Processing label (optional)
pub processing: Option<String>,
/// Split number for large files (optional)
pub split: Option<u32>,
/// Data type suffix (meg, eeg, ieeg)
pub datatype: Option<String>,
/// File extension
pub extension: Option<String>,
}
impl FileEntities {
/// Check if this is an MEG file
pub fn is_meg(&self) -> bool {
self.datatype.as_deref() == Some("meg")
}
/// Check if this is an EEG file
pub fn is_eeg(&self) -> bool {
self.datatype.as_deref() == Some("eeg")
}
/// Check if this is an iEEG file
pub fn is_ieeg(&self) -> bool {
self.datatype.as_deref() == Some("ieeg")
}
}
/// Parse a BIDS-compliant filename into entities
///
/// # Arguments
///
/// * `filename` - The filename (with or without path) to parse
///
/// # Returns
///
/// `Some(FileEntities)` if the filename matches BIDS conventions, `None` otherwise.
///
/// # Example
///
/// ```
/// use rtx_neuro_io::bids::parse_bids_filename;
///
/// let entities = parse_bids_filename("sub-01_ses-pre_task-rest_meg.fif").unwrap();
/// assert_eq!(entities.subject, "01");
/// assert_eq!(entities.session, Some("pre".to_string()));
/// assert_eq!(entities.task, Some("rest".to_string()));
/// assert_eq!(entities.datatype, Some("meg".to_string()));
/// ```
pub fn parse_bids_filename(filename: &str) -> Option<FileEntities> {
// Extract just the filename if a path was provided
let filename = Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
// Split extension
let (name, extension) = if let Some(pos) = filename.rfind('.') {
let ext = &filename[pos + 1..];
// Strip extension for parsing, including .ds directories
(&filename[..pos], Some(ext.to_string()))
} else {
(filename, None)
};
// Split by underscores
let parts: Vec<&str> = name.split('_').collect();
if parts.is_empty() {
return None;
}
let mut entities = FileEntities {
extension,
..Default::default()
};
// Parse each part
for part in &parts {
if let Some(value) = part.strip_prefix("sub-") {
entities.subject = value.to_string();
} else if let Some(value) = part.strip_prefix("ses-") {
entities.session = Some(value.to_string());
} else if let Some(value) = part.strip_prefix("task-") {
entities.task = Some(value.to_string());
} else if let Some(value) = part.strip_prefix("acq-") {
entities.acquisition = Some(value.to_string());
} else if let Some(value) = part.strip_prefix("run-") {
entities.run = value.parse().ok();
} else if let Some(value) = part.strip_prefix("proc-") {
entities.processing = Some(value.to_string());
} else if let Some(value) = part.strip_prefix("split-") {
entities.split = value.parse().ok();
}
}
// The last part (before extension) should be the datatype
if let Some(last) = parts.last() {
if !last.contains('-') {
match *last {
"meg" | "eeg" | "ieeg" => {
entities.datatype = Some(last.to_string());
}
_ => {}
}
}
}
// Subject is required
if entities.subject.is_empty() {
return None;
}
Some(entities)
}
/// Build a BIDS-compliant filename from entities
pub fn build_bids_filename(entities: &FileEntities) -> String {
let mut parts = vec![format!("sub-{}", entities.subject)];
if let Some(ref ses) = entities.session {
parts.push(format!("ses-{}", ses));
}
if let Some(ref task) = entities.task {
parts.push(format!("task-{}", task));
}
if let Some(ref acq) = entities.acquisition {
parts.push(format!("acq-{}", acq));
}
if let Some(run) = entities.run {
parts.push(format!("run-{:02}", run));
}
if let Some(ref proc) = entities.processing {
parts.push(format!("proc-{}", proc));
}
if let Some(split) = entities.split {
parts.push(format!("split-{:02}", split));
}
if let Some(ref dt) = entities.datatype {
parts.push(dt.clone());
}
let name = parts.join("_");
if let Some(ref ext) = entities.extension {
format!("{}.{}", name, ext)
} else {
name
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_filename() {
let entities = parse_bids_filename("sub-01_task-rest_meg.fif").unwrap();
assert_eq!(entities.subject, "01");
assert_eq!(entities.task, Some("rest".to_string()));
assert_eq!(entities.datatype, Some("meg".to_string()));
assert_eq!(entities.extension, Some("fif".to_string()));
}
#[test]
fn test_parse_full_filename() {
let entities =
parse_bids_filename("sub-control01_ses-001_task-rest_acq-neuromag_run-01_meg.fif")
.unwrap();
assert_eq!(entities.subject, "control01");
assert_eq!(entities.session, Some("001".to_string()));
assert_eq!(entities.task, Some("rest".to_string()));
assert_eq!(entities.acquisition, Some("neuromag".to_string()));
assert_eq!(entities.run, Some(1));
assert_eq!(entities.datatype, Some("meg".to_string()));
}
#[test]
fn test_parse_eeg_filename() {
let entities = parse_bids_filename("sub-02_task-memory_eeg.edf").unwrap();
assert_eq!(entities.subject, "02");
assert_eq!(entities.task, Some("memory".to_string()));
assert!(entities.is_eeg());
assert!(!entities.is_meg());
}
#[test]
fn test_parse_ctf_directory() {
let entities = parse_bids_filename("sub-01_task-rest_meg.ds").unwrap();
assert_eq!(entities.subject, "01");
assert_eq!(entities.datatype, Some("meg".to_string()));
assert_eq!(entities.extension, Some("ds".to_string()));
}
#[test]
fn test_parse_invalid_filename() {
assert!(parse_bids_filename("random_file.txt").is_none());
assert!(parse_bids_filename("no_subject_here.fif").is_none());
}
#[test]
fn test_build_filename() {
let entities = FileEntities {
subject: "01".to_string(),
session: Some("pre".to_string()),
task: Some("rest".to_string()),
datatype: Some("meg".to_string()),
extension: Some("fif".to_string()),
..Default::default()
};
assert_eq!(
build_bids_filename(&entities),
"sub-01_ses-pre_task-rest_meg.fif"
);
}
}
@@ -0,0 +1,38 @@
//! BIDS (Brain Imaging Data Structure) Dataset Support
//!
//! Provides parsing and reading of BIDS-formatted neuroimaging datasets.
//!
//! ## Overview
//!
//! BIDS is a standard for organizing and describing neuroimaging data.
//! This module provides:
//! - Dataset detection and validation
//! - Filename entity parsing (sub-, ses-, task-, etc.)
//! - JSON sidecar and TSV metadata parsing
//! - Integration with existing format readers (EDF, FIF, CTF, BrainVision)
//!
//! ## Example
//!
//! ```rust,ignore
//! use rtx_neuro_io::bids::BidsDataset;
//!
//! let dataset = BidsDataset::open("my_bids_dataset")?;
//! println!("Dataset: {}", dataset.name());
//! println!("Subjects: {:?}", dataset.subjects());
//!
//! // Get MEG files for a subject
//! let files = dataset.get_meg_files("01", None)?;
//! for file in files {
//! println!(" {}", file.path.display());
//! }
//! ```
mod dataset;
mod entities;
mod sidecar;
pub use dataset::{
BidsDataset, BidsFile, BidsSession, BidsSubject, DatasetDescription, is_bids_dataset,
};
pub use entities::{FileEntities, build_bids_filename, parse_bids_filename};
pub use sidecar::{ChannelEntry, ChannelsTsv, EegSidecar, EventEntry, EventsTsv, MegSidecar};
@@ -0,0 +1,392 @@
//! BIDS Sidecar File Parsing
//!
//! Parses JSON sidecar files and TSV metadata files.
use super::super::{IoError, IoResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
/// MEG sidecar JSON structure
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "PascalCase")]
pub struct MegSidecar {
/// Task name (required)
#[serde(default)]
pub task_name: String,
/// Sampling frequency in Hz (required)
#[serde(default)]
pub sampling_frequency: f64,
/// Power line frequency (50 or 60 Hz)
#[serde(default)]
pub power_line_frequency: Option<f64>,
/// Dewar position (e.g., "upright", "supine")
#[serde(default)]
pub dewar_position: Option<String>,
/// Manufacturer name
#[serde(default)]
pub manufacturer: Option<String>,
/// Manufacturer's model name
#[serde(default)]
pub manufacturers_model_name: Option<String>,
/// Software versions
#[serde(default)]
pub software_versions: Option<String>,
/// Task description
#[serde(default)]
pub task_description: Option<String>,
/// Recording type (continuous, epoched)
#[serde(default)]
pub recording_type: Option<String>,
/// Continuous head localization enabled
#[serde(default)]
pub continuous_head_localization: Option<bool>,
/// Associated empty room recording
#[serde(default)]
pub associated_empty_room: Option<String>,
/// Additional fields
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
/// EEG sidecar JSON structure
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "PascalCase")]
pub struct EegSidecar {
/// Task name (required)
#[serde(default)]
pub task_name: String,
/// Sampling frequency in Hz (required)
#[serde(default)]
pub sampling_frequency: f64,
/// Power line frequency (50 or 60 Hz)
#[serde(default)]
pub power_line_frequency: Option<f64>,
/// EEG reference electrode
#[serde(rename = "EEGReference")]
#[serde(default)]
pub eeg_reference: Option<String>,
/// EEG ground electrode
#[serde(rename = "EEGGround")]
#[serde(default)]
pub eeg_ground: Option<String>,
/// Manufacturer name
#[serde(default)]
pub manufacturer: Option<String>,
/// Manufacturer's model name
#[serde(default)]
pub manufacturers_model_name: Option<String>,
/// Task description
#[serde(default)]
pub task_description: Option<String>,
/// Recording type
#[serde(default)]
pub recording_type: Option<String>,
/// Number of EEG channels
#[serde(rename = "EEGChannelCount")]
#[serde(default)]
pub eeg_channel_count: Option<usize>,
/// Additional fields
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
/// Channel entry in channels.tsv
#[derive(Debug, Clone, Default)]
pub struct ChannelEntry {
/// Channel name (required)
pub name: String,
/// Channel type (e.g., MEG, EEG, STIM)
pub channel_type: Option<String>,
/// Units (e.g., T, V, µV)
pub units: Option<String>,
/// X position
pub x: Option<f64>,
/// Y position
pub y: Option<f64>,
/// Z position
pub z: Option<f64>,
/// Sampling frequency (if different per channel)
pub sampling_frequency: Option<f64>,
/// Low cutoff frequency
pub low_cutoff: Option<f64>,
/// High cutoff frequency
pub high_cutoff: Option<f64>,
/// Channel description
pub description: Option<String>,
/// Status (good, bad)
pub status: Option<String>,
}
/// Parsed channels.tsv file
#[derive(Debug, Clone, Default)]
pub struct ChannelsTsv {
/// Column headers
pub headers: Vec<String>,
/// Channel entries
pub channels: Vec<ChannelEntry>,
}
impl ChannelsTsv {
/// Parse a channels.tsv 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 channels.tsv: {}", path.display()),
))
})?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
// Parse header
let header_line = lines
.next()
.ok_or_else(|| IoError::InvalidFormat("Empty channels.tsv file".to_string()))??;
let headers: Vec<String> = header_line.split('\t').map(|s| s.to_string()).collect();
// Find column indices
let name_idx = headers.iter().position(|h| h == "name");
let type_idx = headers.iter().position(|h| h == "type");
let units_idx = headers.iter().position(|h| h == "units");
let x_idx = headers.iter().position(|h| h == "x");
let y_idx = headers.iter().position(|h| h == "y");
let z_idx = headers.iter().position(|h| h == "z");
let sfreq_idx = headers.iter().position(|h| h == "sampling_frequency");
let low_idx = headers.iter().position(|h| h == "low_cutoff");
let high_idx = headers.iter().position(|h| h == "high_cutoff");
let desc_idx = headers.iter().position(|h| h == "description");
let status_idx = headers.iter().position(|h| h == "status");
// Parse data rows
let mut channels = Vec::new();
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let get_field = |idx: Option<usize>| -> Option<String> {
idx.and_then(|i| fields.get(i))
.map(|s| s.to_string())
.filter(|s| !s.is_empty() && s != "n/a" && s != "NaN")
};
let get_f64 = |idx: Option<usize>| -> Option<f64> {
idx.and_then(|i| fields.get(i)).and_then(|s| s.parse().ok())
};
let name = get_field(name_idx).unwrap_or_default();
channels.push(ChannelEntry {
name,
channel_type: get_field(type_idx),
units: get_field(units_idx),
x: get_f64(x_idx),
y: get_f64(y_idx),
z: get_f64(z_idx),
sampling_frequency: get_f64(sfreq_idx),
low_cutoff: get_f64(low_idx),
high_cutoff: get_f64(high_idx),
description: get_field(desc_idx),
status: get_field(status_idx),
});
}
Ok(Self { headers, channels })
}
/// Get list of bad channels
pub fn bad_channels(&self) -> Vec<&str> {
self.channels
.iter()
.filter(|c| c.status.as_deref() == Some("bad"))
.map(|c| c.name.as_str())
.collect()
}
}
/// Event entry in events.tsv
#[derive(Debug, Clone, Default)]
pub struct EventEntry {
/// Event onset in seconds (required)
pub onset: f64,
/// Event duration in seconds (required)
pub duration: f64,
/// Trial type / event name
pub trial_type: Option<String>,
/// Response time
pub response_time: Option<f64>,
/// Stimulus file
pub stim_file: Option<String>,
/// Event value/code
pub value: Option<String>,
}
/// Parsed events.tsv file
#[derive(Debug, Clone, Default)]
pub struct EventsTsv {
/// Column headers
pub headers: Vec<String>,
/// Event entries
pub events: Vec<EventEntry>,
}
impl EventsTsv {
/// Parse an events.tsv 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 events.tsv: {}", path.display()),
))
})?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
// Parse header
let header_line = lines
.next()
.ok_or_else(|| IoError::InvalidFormat("Empty events.tsv file".to_string()))??;
let headers: Vec<String> = header_line.split('\t').map(|s| s.to_string()).collect();
// Find column indices
let onset_idx = headers.iter().position(|h| h == "onset");
let duration_idx = headers.iter().position(|h| h == "duration");
let trial_type_idx = headers.iter().position(|h| h == "trial_type");
let response_time_idx = headers.iter().position(|h| h == "response_time");
let stim_file_idx = headers.iter().position(|h| h == "stim_file");
let value_idx = headers.iter().position(|h| h == "value");
// Parse data rows
let mut events = Vec::new();
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let get_field = |idx: Option<usize>| -> Option<String> {
idx.and_then(|i| fields.get(i))
.map(|s| s.to_string())
.filter(|s| !s.is_empty() && s != "n/a")
};
let get_f64 = |idx: Option<usize>| -> Option<f64> {
idx.and_then(|i| fields.get(i)).and_then(|s| s.parse().ok())
};
let onset = get_f64(onset_idx).unwrap_or(0.0);
let duration = get_f64(duration_idx).unwrap_or(0.0);
events.push(EventEntry {
onset,
duration,
trial_type: get_field(trial_type_idx),
response_time: get_f64(response_time_idx),
stim_file: get_field(stim_file_idx),
value: get_field(value_idx),
});
}
Ok(Self { headers, events })
}
}
/// Load a MEG sidecar JSON file
pub fn load_meg_sidecar(path: impl AsRef<Path>) -> IoResult<MegSidecar> {
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 MEG sidecar: {}", path.display()),
))
})?;
serde_json::from_reader(file)
.map_err(|e| IoError::InvalidFormat(format!("Failed to parse MEG sidecar JSON: {}", e)))
}
/// Load an EEG sidecar JSON file
pub fn load_eeg_sidecar(path: impl AsRef<Path>) -> IoResult<EegSidecar> {
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 EEG sidecar: {}", path.display()),
))
})?;
serde_json::from_reader(file)
.map_err(|e| IoError::InvalidFormat(format!("Failed to parse EEG sidecar JSON: {}", e)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_meg_sidecar_deserialize() {
let json = r#"{
"TaskName": "rest",
"SamplingFrequency": 1000,
"PowerLineFrequency": 50,
"DewarPosition": "upright",
"Manufacturer": "Elekta"
}"#;
let sidecar: MegSidecar = serde_json::from_str(json).unwrap();
assert_eq!(sidecar.task_name, "rest");
assert_eq!(sidecar.sampling_frequency, 1000.0);
assert_eq!(sidecar.power_line_frequency, Some(50.0));
assert_eq!(sidecar.dewar_position, Some("upright".to_string()));
assert_eq!(sidecar.manufacturer, Some("Elekta".to_string()));
}
#[test]
fn test_eeg_sidecar_deserialize() {
let json = r#"{
"TaskName": "memory",
"SamplingFrequency": 500,
"EEGReference": "FCz",
"EEGGround": "AFz"
}"#;
let sidecar: EegSidecar = serde_json::from_str(json).unwrap();
assert_eq!(sidecar.task_name, "memory");
assert_eq!(sidecar.sampling_frequency, 500.0);
assert_eq!(sidecar.eeg_reference, Some("FCz".to_string()));
assert_eq!(sidecar.eeg_ground, Some("AFz".to_string()));
}
}
@@ -0,0 +1,450 @@
//! BrainVision format reader (.vhdr/.vmrk/.eeg files).
//!
//! BrainVision format consists of three files:
//! - `.vhdr` - Header file (INI-like format)
//! - `.vmrk` - Marker file (events)
//! - `.eeg` or `.dat` - Binary data file
use super::{IoError, IoResult, NeuroReader};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::{Path, PathBuf};
/// BrainVision data format
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrainVisionFormat {
/// Binary INT 16 (little endian)
Int16,
/// Binary IEEE float 32
Float32,
/// ASCII (text format)
Ascii,
}
/// BrainVision file header
#[derive(Debug, Clone)]
pub struct BrainVisionHeader {
/// Data file path (relative or absolute)
pub data_file: PathBuf,
/// Marker file path
pub marker_file: Option<PathBuf>,
/// Data format
pub format: BrainVisionFormat,
/// Data orientation (multiplexed or vectorized)
pub multiplexed: bool,
/// Number of channels
pub n_channels: usize,
/// Sampling interval in microseconds
pub sampling_interval_us: f64,
/// Channel information
pub channels: Vec<BrainVisionChannel>,
}
impl BrainVisionHeader {
/// Sampling frequency in Hz
#[must_use]
pub fn sfreq(&self) -> f64 {
1_000_000.0 / self.sampling_interval_us
}
}
/// BrainVision channel information
#[derive(Debug, Clone)]
pub struct BrainVisionChannel {
/// Channel name
pub name: String,
/// Reference channel name
pub reference: Option<String>,
/// Resolution (scaling factor to uV)
pub resolution: f64,
/// Unit (e.g., "µV")
pub unit: String,
}
/// BrainVision marker (event)
#[derive(Debug, Clone)]
pub struct BrainVisionMarker {
/// Marker type (e.g., "Stimulus", "Response")
pub marker_type: String,
/// Marker description
pub description: String,
/// Position in samples (1-based in file, 0-based here)
pub position: usize,
/// Duration in samples
pub duration: usize,
/// Channel (0 = all channels)
pub channel: usize,
}
/// BrainVision file reader
pub struct BrainVisionReader {
/// Header file path
header_path: PathBuf,
/// Parsed header
header: BrainVisionHeader,
/// Markers
markers: Vec<BrainVisionMarker>,
/// Total number of samples (calculated from file size)
n_samples: usize,
}
impl BrainVisionReader {
/// Open a BrainVision header file (.vhdr)
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let header_path = path.as_ref().to_path_buf();
if !header_path.exists() {
return Err(IoError::FileNotFound(header_path.display().to_string()));
}
let header = Self::parse_header(&header_path)?;
let markers = if let Some(ref marker_file) = header.marker_file {
let marker_path = header_path.parent().unwrap().join(marker_file);
Self::parse_markers(&marker_path)?
} else {
Vec::new()
};
// Calculate number of samples from data file size
let data_path = header_path.parent().unwrap().join(&header.data_file);
let file_size = std::fs::metadata(&data_path)?.len() as usize;
let bytes_per_sample = match header.format {
BrainVisionFormat::Int16 => 2,
BrainVisionFormat::Float32 => 4,
BrainVisionFormat::Ascii => {
return Err(IoError::UnsupportedVersion(
"ASCII format not yet supported".to_string(),
));
}
};
let n_samples = file_size / (bytes_per_sample * header.n_channels);
Ok(Self {
header_path,
header,
markers,
n_samples,
})
}
/// Get parsed header
#[must_use]
pub fn header(&self) -> &BrainVisionHeader {
&self.header
}
/// Get markers
#[must_use]
pub fn markers(&self) -> &[BrainVisionMarker] {
&self.markers
}
/// Parse the header file
fn parse_header(path: &Path) -> IoResult<BrainVisionHeader> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut current_section = String::new();
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
current_section = line[1..line.len() - 1].to_string();
sections.insert(current_section.clone(), HashMap::new());
} else if let Some(pos) = line.find('=') {
let key = line[..pos].trim().to_string();
let value = line[pos + 1..].trim().to_string();
if let Some(section) = sections.get_mut(&current_section) {
section.insert(key, value);
}
}
}
// Parse Common Infos
let common = sections
.get("Common Infos")
.ok_or_else(|| IoError::HeaderParse("Missing [Common Infos] section".to_string()))?;
let data_file = common
.get("DataFile")
.ok_or_else(|| IoError::HeaderParse("Missing DataFile".to_string()))?
.into();
let marker_file = common.get("MarkerFile").map(|s| PathBuf::from(s));
let n_channels: usize = common
.get("NumberOfChannels")
.ok_or_else(|| IoError::HeaderParse("Missing NumberOfChannels".to_string()))?
.parse()
.map_err(|_| IoError::HeaderParse("Invalid NumberOfChannels".to_string()))?;
let sampling_interval_us: f64 = common
.get("SamplingInterval")
.ok_or_else(|| IoError::HeaderParse("Missing SamplingInterval".to_string()))?
.parse()
.map_err(|_| IoError::HeaderParse("Invalid SamplingInterval".to_string()))?;
// Parse Binary Infos
let binary = sections.get("Binary Infos");
let format = if let Some(binary) = binary {
match binary.get("BinaryFormat").map(String::as_str) {
Some("INT_16") => BrainVisionFormat::Int16,
Some("IEEE_FLOAT_32") => BrainVisionFormat::Float32,
_ => BrainVisionFormat::Int16,
}
} else {
BrainVisionFormat::Int16
};
let multiplexed = common
.get("DataOrientation")
.map(|s| s == "MULTIPLEXED")
.unwrap_or(true);
// Parse Channel Infos
let channel_info = sections.get("Channel Infos");
let mut channels = Vec::with_capacity(n_channels);
if let Some(ch_info) = channel_info {
for i in 1..=n_channels {
let key = format!("Ch{i}");
if let Some(value) = ch_info.get(&key) {
let parts: Vec<&str> = value.split(',').collect();
let name = parts.first().map(|s| s.trim().to_string()).unwrap_or(key);
let reference = parts.get(1).map(|s| s.trim().to_string());
let resolution: f64 = parts
.get(2)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(1.0);
let unit = parts
.get(3)
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "µV".to_string());
channels.push(BrainVisionChannel {
name,
reference,
resolution,
unit,
});
}
}
}
// Fill missing channels with defaults
while channels.len() < n_channels {
channels.push(BrainVisionChannel {
name: format!("Ch{}", channels.len() + 1),
reference: None,
resolution: 1.0,
unit: "µV".to_string(),
});
}
Ok(BrainVisionHeader {
data_file,
marker_file,
format,
multiplexed,
n_channels,
sampling_interval_us,
channels,
})
}
/// Parse marker file
fn parse_markers(path: &Path) -> IoResult<Vec<BrainVisionMarker>> {
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut markers = Vec::new();
let mut in_marker_section = false;
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.starts_with("[Marker Infos]") {
in_marker_section = true;
continue;
}
if line.starts_with('[') {
in_marker_section = false;
continue;
}
if !in_marker_section || line.is_empty() || line.starts_with(';') {
continue;
}
// Format: Mk<n>=<type>,<description>,<position>,<duration>,<channel>
if let Some(pos) = line.find('=') {
let value = &line[pos + 1..];
let parts: Vec<&str> = value.split(',').collect();
if parts.len() >= 4 {
let marker_type = parts[0].trim().to_string();
let description = parts[1].trim().to_string();
let position: usize = parts[2].trim().parse().unwrap_or(1) - 1; // Convert to 0-based
let duration: usize = parts[3].trim().parse().unwrap_or(1);
let channel: usize = parts
.get(4)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
markers.push(BrainVisionMarker {
marker_type,
description,
position,
duration,
channel,
});
}
}
}
Ok(markers)
}
}
impl NeuroReader for BrainVisionReader {
fn read_header(&mut self) -> IoResult<()> {
// Header is already parsed in open()
Ok(())
}
fn sfreq(&self) -> f64 {
self.header.sfreq()
}
fn n_channels(&self) -> usize {
self.header.n_channels
}
fn n_samples(&self) -> usize {
self.n_samples
}
fn channel_names(&self) -> Vec<String> {
self.header
.channels
.iter()
.map(|c| c.name.clone())
.collect()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.header.sfreq();
let start_sample = (tmin * sfreq).floor() as usize;
let end_sample = (tmax * sfreq).ceil() as usize;
let n_samples = (end_sample - start_sample).min(self.n_samples - start_sample);
let n_channels = self.header.n_channels;
let data_path = self
.header_path
.parent()
.unwrap()
.join(&self.header.data_file);
let mut file = File::open(&data_path)?;
let bytes_per_sample = match self.header.format {
BrainVisionFormat::Int16 => 2,
BrainVisionFormat::Float32 => 4,
BrainVisionFormat::Ascii => {
return Err(IoError::UnsupportedVersion(
"ASCII format not supported".to_string(),
));
}
};
// Seek to start position
let start_byte = start_sample * n_channels * bytes_per_sample;
file.seek(SeekFrom::Start(start_byte as u64))?;
let mut data = vec![0.0; n_channels * n_samples];
if self.header.multiplexed {
// Data is interleaved: ch1_s1, ch2_s1, ... chN_s1, ch1_s2, ...
use byteorder::{LittleEndian, ReadBytesExt};
for s in 0..n_samples {
for ch in 0..n_channels {
let value = match self.header.format {
BrainVisionFormat::Int16 => {
let raw = file.read_i16::<LittleEndian>()?;
f64::from(raw) * self.header.channels[ch].resolution
}
BrainVisionFormat::Float32 => {
let raw = file.read_f32::<LittleEndian>()?;
f64::from(raw) * self.header.channels[ch].resolution
}
BrainVisionFormat::Ascii => unreachable!(),
};
// Store in channel-major order [ch0: s0, s1, ..., ch1: s0, s1, ...]
data[ch * n_samples + s] = value;
}
}
} else {
// Vectorized: all samples for ch1, then all for ch2, etc.
use byteorder::{LittleEndian, ReadBytesExt};
for ch in 0..n_channels {
let ch_start_byte =
ch * self.n_samples * bytes_per_sample + start_sample * bytes_per_sample;
file.seek(SeekFrom::Start(ch_start_byte as u64))?;
for s in 0..n_samples {
let value = match self.header.format {
BrainVisionFormat::Int16 => {
let raw = file.read_i16::<LittleEndian>()?;
f64::from(raw) * self.header.channels[ch].resolution
}
BrainVisionFormat::Float32 => {
let raw = file.read_f32::<LittleEndian>()?;
f64::from(raw) * self.header.channels[ch].resolution
}
BrainVisionFormat::Ascii => unreachable!(),
};
data[ch * n_samples + s] = value;
}
}
}
Ok(data)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sfreq_calculation() {
let header = BrainVisionHeader {
data_file: PathBuf::from("test.eeg"),
marker_file: None,
format: BrainVisionFormat::Int16,
multiplexed: true,
n_channels: 32,
sampling_interval_us: 2000.0, // 500 Hz
channels: Vec::new(),
};
assert!((header.sfreq() - 500.0).abs() < 0.01);
}
}
@@ -0,0 +1,354 @@
//! BTi Config File Parser
//!
//! Parses the ASCII `config` file containing channel definitions and calibrations.
use super::super::{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);
}
}
@@ -0,0 +1,115 @@
//! BTi/4D-Neuroimaging Format Constants
//!
//! File offsets, magic numbers, and type definitions for BTi files.
/// Magic header for BTi PDF (processed data file) version 1
pub const PDF_MAGIC_V1: &[u8; 8] = b"PDF 1";
/// Magic header for BTi PDF version 2
pub const PDF_MAGIC_V2: &[u8; 8] = b"PDF 2";
/// Data types in BTi files
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i16)]
pub enum BtiDataType {
/// 16-bit signed integer
Short = 1,
/// 32-bit signed integer
Long = 2,
/// 32-bit float
Float = 3,
/// 64-bit float
Double = 4,
}
impl TryFrom<i16> for BtiDataType {
type Error = &'static str;
fn try_from(value: i16) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Short),
2 => Ok(Self::Long),
3 => Ok(Self::Float),
4 => Ok(Self::Double),
_ => Err("Unknown BTi data type"),
}
}
}
impl BtiDataType {
/// Size of this data type in bytes
pub fn size(&self) -> usize {
match self {
Self::Short => 2,
Self::Long => 4,
Self::Float => 4,
Self::Double => 8,
}
}
}
// Channel type codes
/// MEG channel
pub const BTI_MEG: i16 = 1;
/// EEG channel
pub const BTI_EEG: i16 = 2;
/// Reference channel
pub const BTI_REF: i16 = 3;
/// External/auxiliary channel
pub const BTI_EXT: i16 = 4;
/// Trigger channel
pub const BTI_TRIG: i16 = 5;
/// Utility channel
pub const BTI_UTIL: i16 = 6;
/// Derived/computed channel
pub const BTI_DERIV: i16 = 7;
/// 3D shape/position channel
pub const BTI_SHAPE: i16 = 8;
/// Response channel
pub const BTI_RESP: i16 = 9;
// Sensor types
/// Magnetometer sensor type
pub const BTI_SENSOR_MAG: i16 = 1;
/// First-order gradiometer sensor type
pub const BTI_SENSOR_GRAD1: i16 = 2;
/// Second-order gradiometer sensor type
pub const BTI_SENSOR_GRAD2: i16 = 3;
// Config file sections
/// Section header for channel definitions
pub const CONFIG_SECTION_CHANNELS: &str = "channels";
/// Section header for weights/SSP
pub const CONFIG_SECTION_WEIGHTS: &str = "weights";
/// Section header for general info
pub const CONFIG_SECTION_INFO: &str = "info";
// Config field names
/// Sampling frequency field
pub const CONFIG_SFREQ: &str = "sample_rate";
/// Number of channels field
pub const CONFIG_NCHAN: &str = "total_chans";
/// Number of epochs field
pub const CONFIG_NEPOCH: &str = "total_epochs";
/// Samples per epoch field
pub const CONFIG_EPOCH_SIZE: &str = "epoch_size";
// Default constants
/// Default number of coils per sensor
pub const DEFAULT_N_COILS: usize = 2;
/// Maximum channel name length
pub const MAX_CHANNEL_NAME: usize = 16;
// File header offsets (PDF file)
/// Offset to data type in PDF header
pub const PDF_DTYPE_OFFSET: usize = 8;
/// Offset to number of channels in PDF header
pub const PDF_NCHAN_OFFSET: usize = 10;
/// Offset to number of epochs in PDF header
pub const PDF_NEPOCH_OFFSET: usize = 12;
/// Offset to samples per epoch in PDF header
pub const PDF_EPOCH_SIZE_OFFSET: usize = 16;
/// Offset to sample rate in PDF header
pub const PDF_SFREQ_OFFSET: usize = 24;
/// Size of PDF header
pub const PDF_HEADER_SIZE: usize = 1024;
@@ -0,0 +1,37 @@
//! 4D-Neuroimaging/BTi MEG File Format Reader
//!
//! Reads data from 4D-Neuroimaging (BTi) MEG systems.
//!
//! ## Directory Structure
//!
//! A BTi dataset typically contains:
//! - `config` - ASCII configuration file with channel info and calibrations
//! - `c,rfDC` or similar - Data file (big-endian int16 or float32)
//! - `hs_file` - Head shape digitization (optional)
//! - `e,*` - Event/marker files (optional)
//!
//! ## Data Format
//!
//! BTi data is stored in big-endian format. The data file contains:
//! - Header with epoch information
//! - Channel data interleaved or channel-major depending on version
//!
//! ## Example
//!
//! ```rust,ignore
//! use rtx_neuro_io::bti::BtiReader;
//!
//! let reader = BtiReader::open("subject_data")?;
//! println!("Channels: {}", reader.n_channels());
//! println!("Sample rate: {} Hz", reader.sfreq());
//!
//! let data = reader.read_data(0.0, 10.0)?; // Read 10 seconds
//! ```
mod config;
mod constants;
mod reader;
pub use config::{BtiChannel, BtiChannelKind, BtiCoilDef, BtiConfig};
pub use constants::*;
pub use reader::BtiReader;
@@ -0,0 +1,376 @@
//! BTi/4D-Neuroimaging MEG Dataset Reader
//!
//! Main reader for BTi directory-based datasets.
use super::super::{IoError, IoResult, NeuroReader};
use byteorder::{BigEndian, ReadBytesExt};
use std::fs::{self, File};
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::config::BtiConfig;
use super::constants::*;
/// BTi MEG dataset reader
///
/// Reads data from 4D-Neuroimaging (BTi) MEG systems.
#[derive(Debug)]
pub struct BtiReader {
/// Path to the data directory
data_path: PathBuf,
/// Parsed configuration
config: BtiConfig,
/// Path to the main data file (PDF)
pdf_path: PathBuf,
/// Data type in the PDF file
data_type: BtiDataType,
/// Channel names (cached)
channel_names: Vec<String>,
/// Data offset in bytes (after header)
data_offset: usize,
}
impl BtiReader {
/// Open a BTi data directory or PDF file
///
/// # Arguments
///
/// * `path` - Path to either:
/// - A directory containing `config` and data files
/// - A direct path to a PDF data file (e.g., `c,rfDC`)
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let path = path.as_ref();
// Determine if path is directory or file
let (data_dir, pdf_path) = if path.is_dir() {
// Find the data file in the directory
let pdf_path = Self::find_data_file(path)?;
(path.to_path_buf(), pdf_path)
} else {
// Use the file directly, parent as data dir
let data_dir = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
(data_dir, path.to_path_buf())
};
// Try to load config file
let config_path = data_dir.join("config");
let mut config = if config_path.exists() {
BtiConfig::from_file(&config_path)?
} else {
// Create minimal config from PDF header
Self::config_from_pdf(&pdf_path)?
};
// Read PDF header for additional info
let (data_type, data_offset) = Self::read_pdf_header(&pdf_path)?;
// Update config from PDF if needed
if config.epoch_size == 0 {
let file_size = fs::metadata(&pdf_path)?.len();
let data_size = file_size - data_offset as u64;
let n_samples = data_size as usize / (config.n_channels * data_type.size());
config.epoch_size = n_samples / config.n_epochs.max(1);
}
// Cache channel names
let channel_names = if config.channels.is_empty() {
(0..config.n_channels)
.map(|i| format!("MEG{:03}", i + 1))
.collect()
} else {
config.channels.iter().map(|c| c.name.clone()).collect()
};
Ok(Self {
data_path: data_dir,
config,
pdf_path,
data_type,
channel_names,
data_offset,
})
}
/// Find the main data file in a BTi directory
fn find_data_file(dir: &Path) -> IoResult<PathBuf> {
// Common BTi data file patterns
let patterns = [
"c,rfDC", // Most common continuous data file
"c,rfhp", // High-pass filtered continuous
"e,rfDC", // Event-related data
"c,rf", // Generic continuous
"pdf", // Processed data file
];
for pattern in &patterns {
let path = dir.join(pattern);
if path.exists() {
return Ok(path);
}
}
// Try to find any file starting with 'c,' or 'e,'
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("c,") || name_str.starts_with("e,") {
return Ok(entry.path());
}
}
Err(IoError::FileNotFound(format!(
"No BTi data file found in {}",
dir.display()
)))
}
/// Create config from PDF header
fn config_from_pdf(pdf_path: &Path) -> IoResult<BtiConfig> {
let file = File::open(pdf_path)?;
let mut reader = BufReader::new(file);
// Read magic header
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
let _version = if &magic == PDF_MAGIC_V1 {
1
} else if &magic == PDF_MAGIC_V2 {
2
} else {
// Try to continue anyway with default assumptions
1
};
// Read header fields (big-endian)
reader.seek(SeekFrom::Start(PDF_NCHAN_OFFSET as u64))?;
let n_channels = reader.read_i16::<BigEndian>()? as usize;
reader.seek(SeekFrom::Start(PDF_NEPOCH_OFFSET as u64))?;
let n_epochs = reader.read_i32::<BigEndian>()? as usize;
reader.seek(SeekFrom::Start(PDF_EPOCH_SIZE_OFFSET as u64))?;
let epoch_size = reader.read_i32::<BigEndian>()? as usize;
reader.seek(SeekFrom::Start(PDF_SFREQ_OFFSET as u64))?;
let sfreq = reader.read_f64::<BigEndian>()?;
Ok(BtiConfig::from_pdf_header(
sfreq.max(1.0),
n_channels.max(1),
n_epochs.max(1),
epoch_size,
))
}
/// Read PDF header to determine data type and offset
fn read_pdf_header(pdf_path: &Path) -> IoResult<(BtiDataType, usize)> {
let file = File::open(pdf_path)?;
let mut reader = BufReader::new(file);
// Read magic header
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
// Read data type
reader.seek(SeekFrom::Start(PDF_DTYPE_OFFSET as u64))?;
let dtype_code = reader.read_i16::<BigEndian>()?;
let data_type = BtiDataType::try_from(dtype_code).unwrap_or(BtiDataType::Short);
Ok((data_type, PDF_HEADER_SIZE))
}
/// Get configuration info
pub fn config(&self) -> &BtiConfig {
&self.config
}
/// Get path to the data directory
pub fn path(&self) -> &Path {
&self.data_path
}
/// Get path to the PDF data file
pub fn pdf_path(&self) -> &Path {
&self.pdf_path
}
/// Read raw data from the PDF file
///
/// Returns data in channel-major format: [ch0_s0, ch0_s1, ..., ch1_s0, ...]
pub fn read_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.config.sfreq;
let n_channels = self.config.n_channels;
let total_samples = self.config.n_samples();
// Convert time to sample indices
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
if start_sample >= end_sample {
return Ok(Vec::new());
}
let n_samples = end_sample - start_sample;
// Allocate output buffer (channel-major format)
let mut data = vec![0.0f64; n_channels * n_samples];
// Get calibration factors
let cals: Vec<f64> = if self.config.channels.is_empty() {
vec![1.0; n_channels]
} else {
self.config.channels.iter().map(|c| c.cal).collect()
};
// Open data file
let file = File::open(&self.pdf_path)?;
let mut reader = BufReader::new(file);
// Seek to start of data range
// BTi data is typically stored as: all channels for sample 0, all channels for sample 1, etc.
let sample_size = n_channels * self.data_type.size();
let data_offset = self.data_offset + start_sample * sample_size;
reader.seek(SeekFrom::Start(data_offset as u64))?;
// Read samples based on data type
match self.data_type {
BtiDataType::Short => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw_value = reader.read_i16::<BigEndian>()?;
data[ch * n_samples + s] = raw_value as f64 * cals[ch];
}
}
}
BtiDataType::Long => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw_value = reader.read_i32::<BigEndian>()?;
data[ch * n_samples + s] = raw_value as f64 * cals[ch];
}
}
}
BtiDataType::Float => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw_value = reader.read_f32::<BigEndian>()?;
data[ch * n_samples + s] = raw_value as f64 * cals[ch];
}
}
}
BtiDataType::Double => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw_value = reader.read_f64::<BigEndian>()?;
data[ch * n_samples + s] = raw_value * cals[ch];
}
}
}
}
Ok(data)
}
/// Get number of MEG channels
pub fn n_meg_channels(&self) -> usize {
self.config
.channels
.iter()
.filter(|c| matches!(c.kind, super::config::BtiChannelKind::Meg))
.count()
}
/// Get number of EEG channels
pub fn n_eeg_channels(&self) -> usize {
self.config
.channels
.iter()
.filter(|c| matches!(c.kind, super::config::BtiChannelKind::Eeg))
.count()
}
}
impl NeuroReader for BtiReader {
fn read_header(&mut self) -> IoResult<()> {
// Header is already parsed in open()
Ok(())
}
fn sfreq(&self) -> f64 {
self.config.sfreq
}
fn n_channels(&self) -> usize {
self.config.n_channels
}
fn n_samples(&self) -> usize {
self.config.n_samples()
}
fn channel_names(&self) -> Vec<String> {
self.channel_names.clone()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
self.read_data(tmin, tmax)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_data_file_patterns() {
// Test the expected file patterns
let patterns = ["c,rfDC", "c,rfhp", "e,rfDC", "c,rf", "pdf"];
assert_eq!(patterns[0], "c,rfDC");
assert_eq!(patterns[1], "c,rfhp");
assert!(patterns[0].starts_with("c,"));
}
#[test]
fn test_time_to_sample_conversion() {
let sfreq: f64 = 1017.25;
let tmin: f64 = 0.5;
let tmax: f64 = 1.5;
let total_samples: usize = 5000;
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
assert_eq!(start_sample, 508);
assert_eq!(end_sample, 1526);
}
#[test]
fn test_sample_size_calculation() {
let n_channels = 148;
let short_size = n_channels * BtiDataType::Short.size();
assert_eq!(short_size, 148 * 2);
let float_size = n_channels * BtiDataType::Float.size();
assert_eq!(float_size, 148 * 4);
}
#[test]
fn test_channel_major_indexing() {
// Test channel-major indexing pattern
let n_channels = 3;
let n_samples = 10;
let ch = 1;
let s = 5;
let idx = ch * n_samples + s;
assert_eq!(idx, 15);
}
}
@@ -0,0 +1,192 @@
//! CTF MEG Format Constants
//!
//! Constants for parsing CTF MEG data files.
/// MEG4 file magic header for version 4.1
pub const MEG4_MAGIC_V41: &[u8; 8] = b"MEG41CP\0";
/// MEG4 file magic header for version 4.2
pub const MEG4_MAGIC_V42: &[u8; 8] = b"MEG42CP\0";
/// RES4 file magic header for version 4.1
pub const RES4_MAGIC_V41: &[u8; 8] = b"MEG41RS\0";
/// RES4 file magic header for version 4.2
pub const RES4_MAGIC_V42: &[u8; 8] = b"MEG42RS\0";
/// Maximum number of coils per sensor
pub const MAX_COILS: usize = 8;
/// Maximum channel name length
pub const MAX_CHANNEL_NAME: usize = 32;
/// Size of the MEG4 header in bytes
pub const MEG4_HEADER_SIZE: usize = 8;
/// Size of each sample in bytes (int32)
pub const SAMPLE_SIZE: usize = 4;
/// Maximum MEG4 file size (2GB limit)
pub const MAX_MEG4_FILE_SIZE: u64 = 2_147_483_648;
// ============================================================================
// Channel type constants
// ============================================================================
/// MEG magnetometer/gradiometer channel
pub const CTF_MEG_CH: i16 = 0;
/// Reference MEG channel
pub const CTF_REF_MEG_CH: i16 = 1;
/// EEG channel
pub const CTF_EEG_CH: i16 = 2;
/// Stimulus/trigger channel
pub const CTF_STIM_CH: i16 = 3;
/// Analog to digital channel
pub const CTF_ADC_CH: i16 = 4;
/// Head localization coil channel
pub const CTF_HLC_CH: i16 = 5;
/// Digital input channel
pub const CTF_DIN_CH: i16 = 6;
/// Digital auxiliary channel
pub const CTF_DAC_CH: i16 = 7;
/// SCLK channel (system clock)
pub const CTF_SCLK_CH: i16 = 8;
/// SAM (synthetic aperture magnetometry) channel
pub const CTF_SAM_CH: i16 = 9;
/// Virtual channel
pub const CTF_VIRTUAL_CH: i16 = 10;
/// System channel
pub const CTF_SYS_CH: i16 = 11;
// ============================================================================
// Sensor type constants
// ============================================================================
/// Reference magnetometer
pub const CTF_REF_MAG: i16 = 0;
/// Reference gradiometer
pub const CTF_REF_GRAD: i16 = 1;
/// CTF 275 system MEG sensor
pub const CTF_275_MAG: i16 = 2;
/// CTF 275 system reference
pub const CTF_275_REF: i16 = 3;
// ============================================================================
// Gradient order constants
// ============================================================================
/// No gradient compensation
pub const CTF_NO_GRAD: i16 = 0;
/// First order gradient compensation
pub const CTF_G1BR: i16 = 1;
/// Second order gradient compensation
pub const CTF_G2BR: i16 = 2;
/// Third order gradient compensation
pub const CTF_G3BR: i16 = 3;
// ============================================================================
// RES4 file structure sizes (based on CTF specification)
// ============================================================================
/// Size of general header section
pub const RES4_GENERAL_HEADER_SIZE: usize = 1844;
/// Size of each channel info entry
pub const RES4_CHANNEL_INFO_SIZE: usize = 1360;
/// Offset to number of channels in header
pub const RES4_NCHAN_OFFSET: usize = 1288;
/// Offset to sampling frequency in header
pub const RES4_SFREQ_OFFSET: usize = 1296;
/// Offset to number of samples in header
pub const RES4_NSAMP_OFFSET: usize = 1304;
/// Offset to number of trials in header
pub const RES4_NTRIALS_OFFSET: usize = 1312;
/// Offset to channel info section
pub const RES4_CHANNEL_INFO_OFFSET: usize = 1844;
// ============================================================================
// Channel info offsets within RES4_CHANNEL_INFO_SIZE block
// ============================================================================
/// Offset to channel name (32 bytes string)
pub const CH_NAME_OFFSET: usize = 0;
/// Offset to channel type (i16)
pub const CH_TYPE_OFFSET: usize = 32;
/// Offset to sensor type (i16)
pub const CH_SENSOR_TYPE_OFFSET: usize = 34;
/// Offset to proper gain (f64)
pub const CH_PROPER_GAIN_OFFSET: usize = 48;
/// Offset to q_gain (f64)
pub const CH_Q_GAIN_OFFSET: usize = 56;
/// Offset to io_gain (f64)
pub const CH_IO_GAIN_OFFSET: usize = 64;
/// Offset to io_offset (f64)
pub const CH_IO_OFFSET_OFFSET: usize = 72;
/// Offset to number of coils (i16)
pub const CH_NUM_COILS_OFFSET: usize = 80;
/// Offset to gradient order (i16)
pub const CH_GRAD_ORDER_OFFSET: usize = 82;
/// Offset to first coil position (starts coil array)
pub const CH_COILS_OFFSET: usize = 96;
/// Size of each coil info entry
pub const COIL_INFO_SIZE: usize = 112;
// ============================================================================
// Coil info offsets within COIL_INFO_SIZE block
// ============================================================================
/// Offset to coil position X (f64)
pub const COIL_POS_X_OFFSET: usize = 0;
/// Offset to coil position Y (f64)
pub const COIL_POS_Y_OFFSET: usize = 8;
/// Offset to coil position Z (f64)
pub const COIL_POS_Z_OFFSET: usize = 16;
/// Offset to coil orientation X (f64)
pub const COIL_ORI_X_OFFSET: usize = 24;
/// Offset to coil orientation Y (f64)
pub const COIL_ORI_Y_OFFSET: usize = 32;
/// Offset to coil orientation Z (f64)
pub const COIL_ORI_Z_OFFSET: usize = 40;
/// Offset to coil area (f64)
pub const COIL_AREA_OFFSET: usize = 48;
/// Offset to coil turns (i32)
pub const COIL_TURNS_OFFSET: usize = 56;
@@ -0,0 +1,32 @@
//! CTF MEG File Format Reader
//!
//! Reads CTF Systems MEG data stored in `.ds` directories.
//!
//! ## Directory Structure
//!
//! A CTF dataset is a directory with `.ds` extension containing:
//! - `*.meg4` - Main MEG data file(s) (big-endian int32)
//! - `*.res4` - Resource/header file with acquisition parameters
//! - `*.hc` - Head coil positions (optional)
//! - `MarkerFile.mrk` - Event markers (optional)
//! - `BadChannels` - Bad channel list (optional)
//!
//! ## Example
//!
//! ```rust,ignore
//! use rtx_neuro_io::ctf::CtfReader;
//!
//! let reader = CtfReader::open("experiment.ds")?;
//! println!("Channels: {}", reader.n_channels());
//! println!("Sample rate: {} Hz", reader.sfreq());
//!
//! let data = reader.read_data(0.0, 10.0)?; // Read 10 seconds
//! ```
mod constants;
mod reader;
mod res4;
pub use constants::*;
pub use reader::CtfReader;
pub use res4::{CoilInfo, CtfChannel, CtfChannelKind, Res4Header};
@@ -0,0 +1,324 @@
//! CTF MEG Dataset Reader
//!
//! Main reader for CTF .ds directories.
use super::super::{IoError, IoResult, NeuroReader};
use byteorder::{BigEndian, ReadBytesExt};
use std::fs::{self, File};
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::constants::*;
use super::res4::Res4Header;
/// CTF MEG dataset reader
///
/// Reads data from CTF .ds directories containing MEG recordings.
#[derive(Debug)]
pub struct CtfReader {
/// Path to the .ds directory
ds_path: PathBuf,
/// Parsed RES4 header
res4: Res4Header,
/// Paths to MEG4 data files (in order)
meg4_files: Vec<PathBuf>,
/// Samples per MEG4 file
samples_per_file: Vec<usize>,
/// Channel names (cached)
channel_names: Vec<String>,
}
impl CtfReader {
/// Open a CTF .ds directory
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let ds_path = path.as_ref().to_path_buf();
// Validate it's a directory with .ds extension
if !ds_path.is_dir() {
return Err(IoError::InvalidFormat(format!(
"CTF path is not a directory: {}",
ds_path.display()
)));
}
let ds_name = ds_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| IoError::InvalidFormat("Invalid .ds directory name".to_string()))?;
if !ds_name.ends_with(".ds") {
return Err(IoError::InvalidFormat(format!(
"Directory does not have .ds extension: {}",
ds_name
)));
}
// Get the base name (without .ds)
let base_name = &ds_name[..ds_name.len() - 3];
// Find the .res4 file
let res4_path = ds_path.join(format!("{}.res4", base_name));
if !res4_path.exists() {
return Err(IoError::FileNotFound(format!(
"RES4 file not found: {}",
res4_path.display()
)));
}
// Parse RES4 header
let res4 = Res4Header::from_file(&res4_path)?;
// Find all MEG4 files
let meg4_files = Self::find_meg4_files(&ds_path, base_name)?;
if meg4_files.is_empty() {
return Err(IoError::FileNotFound(format!(
"No MEG4 data files found in {}",
ds_path.display()
)));
}
// Calculate samples per file
let samples_per_file = Self::calculate_samples_per_file(&meg4_files, res4.n_channels)?;
// Cache channel names
let channel_names = res4.channels.iter().map(|c| c.name.clone()).collect();
Ok(Self {
ds_path,
res4,
meg4_files,
samples_per_file,
channel_names,
})
}
/// Find all MEG4 files in order
fn find_meg4_files(ds_path: &Path, base_name: &str) -> IoResult<Vec<PathBuf>> {
let mut files = Vec::new();
// First file: base_name.meg4
let first_file = ds_path.join(format!("{}.meg4", base_name));
if first_file.exists() {
files.push(first_file);
}
// Additional files: base_name.1_meg4, base_name.2_meg4, etc.
let mut index = 1;
loop {
let next_file = ds_path.join(format!("{}.{}_meg4", base_name, index));
if next_file.exists() {
files.push(next_file);
index += 1;
} else {
break;
}
}
Ok(files)
}
/// Calculate number of samples per MEG4 file
fn calculate_samples_per_file(
meg4_files: &[PathBuf],
n_channels: usize,
) -> IoResult<Vec<usize>> {
let mut samples_per_file = Vec::with_capacity(meg4_files.len());
for path in meg4_files {
let metadata = fs::metadata(path)?;
let file_size = metadata.len();
// Subtract header, then calculate samples
let data_size = file_size.saturating_sub(MEG4_HEADER_SIZE as u64);
let n_samples = (data_size as usize) / (n_channels * SAMPLE_SIZE);
samples_per_file.push(n_samples);
}
Ok(samples_per_file)
}
/// Get RES4 header info
pub fn info(&self) -> &Res4Header {
&self.res4
}
/// Get path to the .ds directory
pub fn path(&self) -> &Path {
&self.ds_path
}
/// Read raw data from MEG4 files
///
/// Returns data in channel-major format: [ch0_s0, ch0_s1, ..., ch1_s0, ...]
pub fn read_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.res4.sfreq;
let n_channels = self.res4.n_channels;
let total_samples = self.res4.n_samples;
// Convert time to sample indices
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
if start_sample >= end_sample {
return Ok(Vec::new());
}
let n_samples = end_sample - start_sample;
// Allocate output buffer (channel-major format)
let mut data = vec![0.0f64; n_channels * n_samples];
// Collect scaling factors
let scales: Vec<f64> = self.res4.channels.iter().map(|c| c.scale()).collect();
// Determine which files contain our sample range
let mut current_file_start = 0usize;
for (file_idx, &file_samples) in self.samples_per_file.iter().enumerate() {
let current_file_end = current_file_start + file_samples;
// Check if this file overlaps with our range
if current_file_end > start_sample && current_file_start < end_sample {
// Calculate overlap
let file_start = start_sample.saturating_sub(current_file_start);
let file_end = (end_sample - current_file_start).min(file_samples);
let output_start = current_file_start.saturating_sub(start_sample);
// Read from this file
self.read_meg4_range(
file_idx,
file_start,
file_end,
&mut data,
output_start,
n_samples,
&scales,
)?;
}
current_file_start = current_file_end;
if current_file_start >= end_sample {
break;
}
}
Ok(data)
}
/// Read a range of samples from a specific MEG4 file
fn read_meg4_range(
&self,
file_idx: usize,
file_start: usize,
file_end: usize,
output: &mut [f64],
output_start: usize,
output_stride: usize,
scales: &[f64],
) -> IoResult<()> {
let path = &self.meg4_files[file_idx];
let file = File::open(path)?;
let mut reader = BufReader::new(file);
// Validate MEG4 header
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
if &magic != MEG4_MAGIC_V41 && &magic != MEG4_MAGIC_V42 {
return Err(IoError::InvalidFormat(format!(
"Invalid MEG4 magic header in {}",
path.display()
)));
}
let n_channels = self.res4.n_channels;
let n_read = file_end - file_start;
// Seek to start of data range
// Data is stored as: all channels for sample 0, all channels for sample 1, etc.
let data_offset = MEG4_HEADER_SIZE + file_start * n_channels * SAMPLE_SIZE;
reader.seek(SeekFrom::Start(data_offset as u64))?;
// Read samples
for s in 0..n_read {
for ch in 0..n_channels {
let raw_value = reader.read_i32::<BigEndian>()?;
let scaled_value = raw_value as f64 * scales[ch];
output[ch * output_stride + output_start + s] = scaled_value;
}
}
Ok(())
}
}
impl NeuroReader for CtfReader {
fn read_header(&mut self) -> IoResult<()> {
// Header is already parsed in open()
Ok(())
}
fn sfreq(&self) -> f64 {
self.res4.sfreq
}
fn n_channels(&self) -> usize {
self.res4.n_channels
}
fn n_samples(&self) -> usize {
self.res4.n_samples
}
fn channel_names(&self) -> Vec<String> {
self.channel_names.clone()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
self.read_data(tmin, tmax)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_meg4_file_pattern() {
// Test the file naming pattern logic
let base = "experiment";
let files = vec![
format!("{}.meg4", base),
format!("{}.1_meg4", base),
format!("{}.2_meg4", base),
];
assert_eq!(files[0], "experiment.meg4");
assert_eq!(files[1], "experiment.1_meg4");
assert_eq!(files[2], "experiment.2_meg4");
}
#[test]
fn test_samples_calculation() {
// Test sample calculation from file size
let file_size: u64 = MEG4_HEADER_SIZE as u64 + (100 * 300 * SAMPLE_SIZE) as u64;
let n_channels = 300;
let data_size = file_size - MEG4_HEADER_SIZE as u64;
let n_samples = (data_size as usize) / (n_channels * SAMPLE_SIZE);
assert_eq!(n_samples, 100);
}
#[test]
fn test_time_to_sample_conversion() {
let sfreq: f64 = 1200.0;
let tmin: f64 = 0.5;
let tmax: f64 = 1.5;
let total_samples: usize = 2400;
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
assert_eq!(start_sample, 600);
assert_eq!(end_sample, 1800);
}
}
@@ -0,0 +1,391 @@
//! CTF RES4 Resource File Parser
//!
//! Parses the `.res4` file containing acquisition parameters and channel info.
use super::super::{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"
);
}
}
@@ -0,0 +1,619 @@
//! EDF (European Data Format) and BDF (BioSemi) file reader.
//!
//! EDF is a simple and well-documented format for storing multichannel
//! biosignal data. BDF is Biosemi's 24-bit variant.
//!
//! ## Format Specification
//!
//! - EDF: 16-bit signed integers, header + data blocks
//! - EDF+: Extended with annotations support
//! - BDF: 24-bit signed integers (BioSemi systems)
//!
//! Reference: https://www.edfplus.info/specs/edf.html
use super::{IoError, IoResult, NeuroReader};
use byteorder::{LittleEndian, ReadBytesExt};
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
/// EDF/BDF file format version
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdfVersion {
/// Standard EDF (16-bit)
Edf,
/// EDF+ Continuous
EdfPlusContinuous,
/// EDF+ Discontinuous
EdfPlusDiscontinuous,
/// BDF (24-bit BioSemi)
Bdf,
/// BDF+ Continuous
BdfPlusContinuous,
/// BDF+ Discontinuous
BdfPlusDiscontinuous,
}
impl EdfVersion {
/// Bytes per sample for this format
#[must_use]
pub fn bytes_per_sample(&self) -> usize {
match self {
Self::Edf | Self::EdfPlusContinuous | Self::EdfPlusDiscontinuous => 2,
Self::Bdf | Self::BdfPlusContinuous | Self::BdfPlusDiscontinuous => 3,
}
}
/// Whether this is a BDF format
#[must_use]
pub fn is_bdf(&self) -> bool {
matches!(
self,
Self::Bdf | Self::BdfPlusContinuous | Self::BdfPlusDiscontinuous
)
}
/// Whether this is an EDF+ or BDF+ format
#[must_use]
pub fn is_plus(&self) -> bool {
matches!(
self,
Self::EdfPlusContinuous
| Self::EdfPlusDiscontinuous
| Self::BdfPlusContinuous
| Self::BdfPlusDiscontinuous
)
}
}
/// Channel-specific header information
#[derive(Debug, Clone)]
pub struct EdfChannelHeader {
/// Channel label (e.g., "Fp1", "EEG Fp1-Ref")
pub label: String,
/// Transducer type
pub transducer: String,
/// Physical dimension (e.g., "uV")
pub physical_dim: String,
/// Physical minimum value
pub physical_min: f64,
/// Physical maximum value
pub physical_max: f64,
/// Digital minimum value
pub digital_min: i32,
/// Digital maximum value
pub digital_max: i32,
/// Pre-filtering description
pub prefiltering: String,
/// Number of samples in each data record
pub n_samples: usize,
}
impl EdfChannelHeader {
/// Compute scale factor to convert digital to physical values
#[must_use]
pub fn scale(&self) -> f64 {
let digital_range = (self.digital_max - self.digital_min) as f64;
let physical_range = self.physical_max - self.physical_min;
if digital_range.abs() < 1e-10 {
1.0
} else {
physical_range / digital_range
}
}
/// Compute offset for digital to physical conversion
#[must_use]
pub fn offset(&self) -> f64 {
self.physical_min - self.scale() * self.digital_min as f64
}
/// Convert digital value to physical value
#[must_use]
pub fn to_physical(&self, digital: i32) -> f64 {
self.scale() * digital as f64 + self.offset()
}
}
/// EDF file header
#[derive(Debug, Clone)]
pub struct EdfHeader {
/// File format version
pub version: EdfVersion,
/// Patient information
pub patient_id: String,
/// Recording information
pub recording_id: String,
/// Recording start date/time
pub start_datetime: NaiveDateTime,
/// Number of bytes in header
pub header_bytes: usize,
/// Number of data records
pub n_records: usize,
/// Duration of each data record in seconds
pub record_duration: f64,
/// Number of signals (channels)
pub n_signals: usize,
/// Per-channel headers
pub channels: Vec<EdfChannelHeader>,
}
impl EdfHeader {
/// Total number of samples per channel
#[must_use]
pub fn total_samples(&self) -> usize {
if self.channels.is_empty() {
0
} else {
self.n_records * self.channels[0].n_samples
}
}
/// Total duration in seconds
#[must_use]
pub fn duration(&self) -> f64 {
self.n_records as f64 * self.record_duration
}
/// Sampling frequency (assumes all channels have same rate)
#[must_use]
pub fn sfreq(&self) -> f64 {
if self.channels.is_empty() || self.record_duration == 0.0 {
0.0
} else {
self.channels[0].n_samples as f64 / self.record_duration
}
}
}
/// EDF/BDF file reader
pub struct EdfReader {
/// File path
path: PathBuf,
/// File handle
file: BufReader<File>,
/// Parsed header
header: EdfHeader,
}
impl EdfReader {
/// Open an EDF/BDF file
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let path = path.as_ref().to_path_buf();
let file = File::open(&path)?;
let file = BufReader::new(file);
let mut reader = Self {
path,
file,
header: EdfHeader {
version: EdfVersion::Edf,
patient_id: String::new(),
recording_id: String::new(),
start_datetime: NaiveDateTime::default(),
header_bytes: 0,
n_records: 0,
record_duration: 0.0,
n_signals: 0,
channels: Vec::new(),
},
};
reader.read_header()?;
Ok(reader)
}
/// Get the file header
#[must_use]
pub fn header(&self) -> &EdfHeader {
&self.header
}
/// Parse the main header (first 256 bytes)
fn parse_main_header(&mut self) -> IoResult<()> {
let mut buf = [0u8; 256];
self.file.read_exact(&mut buf)?;
// Version (8 bytes)
let version_byte = buf[0];
self.header.version = match version_byte {
0 => {
let reserved = String::from_utf8_lossy(&buf[192..196]).trim().to_string();
if reserved.starts_with("EDF+C") {
EdfVersion::EdfPlusContinuous
} else if reserved.starts_with("EDF+D") {
EdfVersion::EdfPlusDiscontinuous
} else {
EdfVersion::Edf
}
}
0xFF => {
let reserved = String::from_utf8_lossy(&buf[192..196]).trim().to_string();
if reserved.starts_with("BDF+C") {
EdfVersion::BdfPlusContinuous
} else if reserved.starts_with("BDF+D") {
EdfVersion::BdfPlusDiscontinuous
} else {
EdfVersion::Bdf
}
}
_ => {
return Err(IoError::InvalidFormat(format!(
"Unknown version byte: {version_byte}"
)));
}
};
// Patient ID (80 bytes)
self.header.patient_id = String::from_utf8_lossy(&buf[8..88]).trim().to_string();
// Recording ID (80 bytes)
self.header.recording_id = String::from_utf8_lossy(&buf[88..168]).trim().to_string();
// Start date (8 bytes: dd.mm.yy)
let date_str = String::from_utf8_lossy(&buf[168..176]).trim().to_string();
// Start time (8 bytes: hh.mm.ss)
let time_str = String::from_utf8_lossy(&buf[176..184]).trim().to_string();
self.header.start_datetime = parse_datetime(&date_str, &time_str)?;
// Header bytes (8 bytes)
let header_bytes_str = String::from_utf8_lossy(&buf[184..192]).trim().to_string();
self.header.header_bytes = header_bytes_str
.parse()
.map_err(|_| IoError::HeaderParse("Invalid header bytes".to_string()))?;
// Reserved (44 bytes) - already used for version detection
// Number of data records (8 bytes)
let n_records_str = String::from_utf8_lossy(&buf[236..244]).trim().to_string();
self.header.n_records = n_records_str
.parse()
.map_err(|_| IoError::HeaderParse("Invalid number of records".to_string()))?;
// Duration of data record (8 bytes)
let duration_str = String::from_utf8_lossy(&buf[244..252]).trim().to_string();
self.header.record_duration = duration_str
.parse()
.map_err(|_| IoError::HeaderParse("Invalid record duration".to_string()))?;
// Number of signals (4 bytes)
let n_signals_str = String::from_utf8_lossy(&buf[252..256]).trim().to_string();
self.header.n_signals = n_signals_str
.parse()
.map_err(|_| IoError::HeaderParse("Invalid number of signals".to_string()))?;
Ok(())
}
/// Parse per-channel headers
fn parse_channel_headers(&mut self) -> IoResult<()> {
let ns = self.header.n_signals;
// Calculate total bytes needed for channel headers
let total_bytes = ns * (16 + 80 + 8 + 8 + 8 + 8 + 8 + 80 + 8 + 32);
let mut buf = vec![0u8; total_bytes];
self.file.read_exact(&mut buf)?;
let mut offset = 0;
// Label (16 bytes each)
let mut labels = Vec::with_capacity(ns);
for _ in 0..ns {
labels.push(
String::from_utf8_lossy(&buf[offset..offset + 16])
.trim()
.to_string(),
);
offset += 16;
}
// Transducer type (80 bytes each)
let mut transducers = Vec::with_capacity(ns);
for _ in 0..ns {
transducers.push(
String::from_utf8_lossy(&buf[offset..offset + 80])
.trim()
.to_string(),
);
offset += 80;
}
// Physical dimension (8 bytes each)
let mut physical_dims = Vec::with_capacity(ns);
for _ in 0..ns {
physical_dims.push(
String::from_utf8_lossy(&buf[offset..offset + 8])
.trim()
.to_string(),
);
offset += 8;
}
// Physical minimum (8 bytes each)
let mut physical_mins = Vec::with_capacity(ns);
for _ in 0..ns {
let s = String::from_utf8_lossy(&buf[offset..offset + 8])
.trim()
.to_string();
physical_mins.push(s.parse::<f64>().unwrap_or(0.0));
offset += 8;
}
// Physical maximum (8 bytes each)
let mut physical_maxs = Vec::with_capacity(ns);
for _ in 0..ns {
let s = String::from_utf8_lossy(&buf[offset..offset + 8])
.trim()
.to_string();
physical_maxs.push(s.parse::<f64>().unwrap_or(0.0));
offset += 8;
}
// Digital minimum (8 bytes each)
let mut digital_mins = Vec::with_capacity(ns);
for _ in 0..ns {
let s = String::from_utf8_lossy(&buf[offset..offset + 8])
.trim()
.to_string();
digital_mins.push(s.parse::<i32>().unwrap_or(-32768));
offset += 8;
}
// Digital maximum (8 bytes each)
let mut digital_maxs = Vec::with_capacity(ns);
for _ in 0..ns {
let s = String::from_utf8_lossy(&buf[offset..offset + 8])
.trim()
.to_string();
digital_maxs.push(s.parse::<i32>().unwrap_or(32767));
offset += 8;
}
// Prefiltering (80 bytes each)
let mut prefilters = Vec::with_capacity(ns);
for _ in 0..ns {
prefilters.push(
String::from_utf8_lossy(&buf[offset..offset + 80])
.trim()
.to_string(),
);
offset += 80;
}
// Number of samples per record (8 bytes each)
let mut n_samples = Vec::with_capacity(ns);
for _ in 0..ns {
let s = String::from_utf8_lossy(&buf[offset..offset + 8])
.trim()
.to_string();
n_samples.push(s.parse::<usize>().unwrap_or(0));
offset += 8;
}
// Reserved (32 bytes each) - skip
// Build channel headers
self.header.channels = (0..ns)
.map(|i| EdfChannelHeader {
label: labels[i].clone(),
transducer: transducers[i].clone(),
physical_dim: physical_dims[i].clone(),
physical_min: physical_mins[i],
physical_max: physical_maxs[i],
digital_min: digital_mins[i],
digital_max: digital_maxs[i],
prefiltering: prefilters[i].clone(),
n_samples: n_samples[i],
})
.collect();
Ok(())
}
/// Read data for a specific channel and time range
pub fn read_channel(&mut self, channel: usize, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
if channel >= self.header.n_signals {
return Err(IoError::ChannelNotFound(format!(
"Channel {channel} not found (max: {})",
self.header.n_signals - 1
)));
}
let sfreq = self.header.sfreq();
let start_sample = (tmin * sfreq).floor() as usize;
let end_sample = (tmax * sfreq).ceil() as usize;
let n_samples = end_sample - start_sample;
let ch_header = &self.header.channels[channel];
let bytes_per_sample = self.header.version.bytes_per_sample();
// Calculate which records contain the requested samples
let samples_per_record = ch_header.n_samples;
let start_record = start_sample / samples_per_record;
let end_record = (end_sample + samples_per_record - 1) / samples_per_record;
// Calculate bytes per record (all channels)
let bytes_per_record: usize = self
.header
.channels
.iter()
.map(|c| c.n_samples * bytes_per_sample)
.sum();
// Offset to channel data within each record
let channel_offset_in_record: usize = self.header.channels[..channel]
.iter()
.map(|c| c.n_samples * bytes_per_sample)
.sum();
let mut data = Vec::with_capacity(n_samples);
// Read each record
for record in start_record..end_record.min(self.header.n_records) {
let record_offset = self.header.header_bytes + record * bytes_per_record;
let channel_data_offset = record_offset + channel_offset_in_record;
self.file
.seek(SeekFrom::Start(channel_data_offset as u64))?;
// Read samples for this channel in this record
for _ in 0..samples_per_record {
let digital = if bytes_per_sample == 2 {
self.file.read_i16::<LittleEndian>()? as i32
} else {
// 24-bit BDF
let mut buf = [0u8; 3];
self.file.read_exact(&mut buf)?;
let val =
i32::from(buf[0]) | (i32::from(buf[1]) << 8) | (i32::from(buf[2]) << 16);
// Sign extend
if val & 0x800000 != 0 {
val | !0xFFFFFF
} else {
val
}
};
data.push(ch_header.to_physical(digital));
}
}
// Trim to requested range
let sample_offset = start_sample % samples_per_record;
let start_idx = sample_offset;
let end_idx = (start_idx + n_samples).min(data.len());
Ok(data[start_idx..end_idx].to_vec())
}
}
impl NeuroReader for EdfReader {
fn read_header(&mut self) -> IoResult<()> {
self.file.seek(SeekFrom::Start(0))?;
self.parse_main_header()?;
self.parse_channel_headers()?;
Ok(())
}
fn sfreq(&self) -> f64 {
self.header.sfreq()
}
fn n_channels(&self) -> usize {
self.header.n_signals
}
fn n_samples(&self) -> usize {
self.header.total_samples()
}
fn channel_names(&self) -> Vec<String> {
self.header
.channels
.iter()
.map(|c| c.label.clone())
.collect()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.header.sfreq();
let n_samples = ((tmax - tmin) * sfreq).ceil() as usize;
let n_channels = self.header.n_signals;
let mut data = Vec::with_capacity(n_channels * n_samples);
for ch in 0..n_channels {
let ch_data = self.read_channel(ch, tmin, tmax)?;
data.extend(ch_data);
}
Ok(data)
}
}
/// Parse EDF date and time strings
fn parse_datetime(date_str: &str, time_str: &str) -> IoResult<NaiveDateTime> {
// Date format: dd.mm.yy
let parts: Vec<&str> = date_str.split('.').collect();
if parts.len() != 3 {
return Err(IoError::HeaderParse(format!("Invalid date: {date_str}")));
}
let day: u32 = parts[0].parse().unwrap_or(1);
let month: u32 = parts[1].parse().unwrap_or(1);
let mut year: i32 = parts[2].parse().unwrap_or(0);
// EDF uses 2-digit years; assume 00-84 is 2000-2084, 85-99 is 1985-1999
if year < 85 {
year += 2000;
} else if year < 100 {
year += 1900;
}
// Time format: hh.mm.ss
let parts: Vec<&str> = time_str.split('.').collect();
if parts.len() != 3 {
return Err(IoError::HeaderParse(format!("Invalid time: {time_str}")));
}
let hour: u32 = parts[0].parse().unwrap_or(0);
let min: u32 = parts[1].parse().unwrap_or(0);
let sec: u32 = parts[2].parse().unwrap_or(0);
let date = NaiveDate::from_ymd_opt(year, month, day)
.ok_or_else(|| IoError::HeaderParse(format!("Invalid date: {date_str}")))?;
let time = NaiveTime::from_hms_opt(hour, min, sec)
.ok_or_else(|| IoError::HeaderParse(format!("Invalid time: {time_str}")))?;
Ok(NaiveDateTime::new(date, time))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Timelike};
#[test]
fn test_edf_channel_scaling() {
let ch = EdfChannelHeader {
label: "EEG".to_string(),
transducer: "".to_string(),
physical_dim: "uV".to_string(),
physical_min: -3200.0,
physical_max: 3200.0,
digital_min: -32768,
digital_max: 32767,
prefiltering: "".to_string(),
n_samples: 256,
};
// Digital 0 should be close to physical 0
let phys = ch.to_physical(0);
assert!((phys - 0.0488).abs() < 0.01);
// Digital max should be close to physical max
let phys_max = ch.to_physical(32767);
assert!((phys_max - 3200.0).abs() < 1.0);
}
#[test]
fn test_datetime_parsing() {
let dt = parse_datetime("01.02.23", "10.30.45").unwrap();
assert_eq!(dt.year(), 2023);
assert_eq!(dt.month(), 2);
assert_eq!(dt.day(), 1);
assert_eq!(dt.hour(), 10);
assert_eq!(dt.minute(), 30);
assert_eq!(dt.second(), 45);
}
#[test]
fn test_version_detection() {
assert_eq!(EdfVersion::Edf.bytes_per_sample(), 2);
assert_eq!(EdfVersion::Bdf.bytes_per_sample(), 3);
assert!(EdfVersion::Bdf.is_bdf());
assert!(!EdfVersion::Edf.is_bdf());
assert!(EdfVersion::EdfPlusContinuous.is_plus());
}
}
@@ -0,0 +1,132 @@
//! EGI Format Constants
//!
//! File offsets, magic numbers, and type definitions for EGI files.
/// Magic header for EGI simple binary format
pub const EGI_RAW_MAGIC: &[u8; 4] = b"VERS";
/// Header line start for version
pub const EGI_VERSION_TAG: &str = "Version";
/// Header line start for sample rate
pub const EGI_SFREQ_TAG: &str = "Sample Rate";
/// Header line start for channel count
pub const EGI_NCHAN_TAG: &str = "Number of Channels";
/// Header line start for gain
pub const EGI_GAIN_TAG: &str = "Gain";
/// Header line start for number of samples
pub const EGI_NSAMP_TAG: &str = "Number of Samples";
/// Header line start for precision
pub const EGI_PRECISION_TAG: &str = "Precision";
/// Header line start for number of categories
pub const EGI_NCATS_TAG: &str = "Number of Categories";
/// Header line start for category name
pub const EGI_CATEGORY_TAG: &str = "Category";
// Data types
/// Float32 precision
pub const EGI_DTYPE_FLOAT: i16 = 4;
/// Int16 precision
pub const EGI_DTYPE_INT16: i16 = 2;
/// Float64 precision
pub const EGI_DTYPE_DOUBLE: i16 = 8;
// EGI sensor net sizes
/// 32-channel net
pub const EGI_NET_32: usize = 32;
/// 64-channel net
pub const EGI_NET_64: usize = 64;
/// 128-channel net
pub const EGI_NET_128: usize = 128;
/// 256-channel net
pub const EGI_NET_256: usize = 256;
// MFF file names
/// MFF info file
pub const MFF_INFO_FILE: &str = "info.xml";
/// MFF signal file pattern
pub const MFF_SIGNAL_PREFIX: &str = "signal";
/// MFF coordinates file
pub const MFF_COORDS_FILE: &str = "coordinates.xml";
/// MFF categories file
pub const MFF_CATEGORIES_FILE: &str = "categories.xml";
/// MFF events file
pub const MFF_EVENTS_FILE: &str = "Events.xml";
/// Data type enumeration for EGI files
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EgiDataType {
/// 16-bit signed integer
Int16,
/// 32-bit float
Float32,
/// 64-bit float
Float64,
}
impl TryFrom<i16> for EgiDataType {
type Error = &'static str;
fn try_from(value: i16) -> Result<Self, Self::Error> {
match value {
EGI_DTYPE_INT16 => Ok(Self::Int16),
EGI_DTYPE_FLOAT => Ok(Self::Float32),
EGI_DTYPE_DOUBLE => Ok(Self::Float64),
_ => Err("Unknown EGI data type"),
}
}
}
impl EgiDataType {
/// Size of this data type in bytes
pub fn size(&self) -> usize {
match self {
Self::Int16 => 2,
Self::Float32 => 4,
Self::Float64 => 8,
}
}
/// Create from byte count
pub fn from_bytes(bytes: usize) -> Self {
match bytes {
2 => Self::Int16,
8 => Self::Float64,
_ => Self::Float32,
}
}
}
/// Channel type for EGI
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EgiChannelType {
/// Standard EEG electrode
Eeg,
/// Reference electrode (e.g., Cz)
Ref,
/// Trigger/event channel
Event,
/// PNS (photoplethysmograph, etc.)
Pns,
/// Other auxiliary channel
Other,
}
impl EgiChannelType {
/// Get string representation
pub fn as_str(&self) -> &'static str {
match self {
Self::Eeg => "EEG",
Self::Ref => "REF",
Self::Event => "EVENT",
Self::Pns => "PNS",
Self::Other => "OTHER",
}
}
}
@@ -0,0 +1,438 @@
//! EGI Header Parser
//!
//! Parses both simple RAW format and MFF format headers.
use super::super::{IoError, IoResult};
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use super::constants::*;
/// EGI file format type
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EgiFormat {
/// Simple binary RAW format
Raw,
/// MFF directory format
Mff,
}
/// EGI channel kind
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EgiChannelKind {
/// EEG electrode
Eeg,
/// Reference electrode
Ref,
/// Event/trigger channel
Event,
/// Peripheral channel (PNS, etc.)
Pns,
/// Unknown channel type
Unknown,
}
impl EgiChannelKind {
/// Get string representation
pub fn as_str(&self) -> &'static str {
match self {
Self::Eeg => "EEG",
Self::Ref => "REF",
Self::Event => "EVENT",
Self::Pns => "PNS",
Self::Unknown => "UNKNOWN",
}
}
}
/// EGI channel information
#[derive(Debug, Clone)]
pub struct EgiChannel {
/// Channel name (e.g., "E1", "E2", or "Cz")
pub name: String,
/// Channel index (0-based)
pub index: usize,
/// Channel type
pub kind: EgiChannelKind,
/// Calibration factor
pub cal: f64,
}
impl EgiChannel {
/// Get unit string
pub fn units(&self) -> &'static str {
match self.kind {
EgiChannelKind::Eeg | EgiChannelKind::Ref => "uV",
EgiChannelKind::Event => "V",
_ => "AU",
}
}
}
/// Parsed EGI header
#[derive(Debug, Clone)]
pub struct EgiHeader {
/// File format type
pub format: EgiFormat,
/// Format version
pub version: u32,
/// Sampling frequency in Hz
pub sfreq: f64,
/// Number of channels
pub n_channels: usize,
/// Number of samples
pub n_samples: usize,
/// Data type
pub data_type: EgiDataType,
/// Gain/calibration
pub gain: f64,
/// Number of event categories
pub n_categories: usize,
/// Category names
pub categories: Vec<String>,
/// Channel definitions
pub channels: Vec<EgiChannel>,
/// Header size in bytes (offset to data)
pub header_size: usize,
}
impl EgiHeader {
/// Parse a simple RAW format header
pub fn from_raw_file(path: impl AsRef<Path>) -> IoResult<Self> {
let path = path.as_ref();
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut version = 0u32;
let mut sfreq = 0.0f64;
let mut n_channels = 0usize;
let mut n_samples = 0usize;
let mut gain = 1.0f64;
let mut precision = 4i16;
let mut n_categories = 0usize;
let mut categories = Vec::new();
let mut header_lines = 0usize;
// Read ASCII header lines
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
header_lines += 1;
let line = line.trim();
// Empty line marks end of header in some versions
if line.is_empty() && header_lines > 5 {
break;
}
// Check for binary data start (usually starts with non-ASCII)
if line
.as_bytes()
.first()
.map(|&b| b < 32 || b > 126)
.unwrap_or(false)
{
break;
}
// Parse key-value pairs
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
k if k.starts_with(EGI_VERSION_TAG) => {
version = value.parse().unwrap_or(0);
}
k if k.starts_with(EGI_SFREQ_TAG) => {
sfreq = value.parse().unwrap_or(0.0);
}
k if k.starts_with(EGI_NCHAN_TAG) => {
n_channels = value.parse().unwrap_or(0);
}
k if k.starts_with(EGI_NSAMP_TAG) => {
n_samples = value.parse().unwrap_or(0);
}
k if k.starts_with(EGI_GAIN_TAG) => {
gain = value.parse().unwrap_or(1.0);
}
k if k.starts_with(EGI_PRECISION_TAG) => {
precision = value.parse().unwrap_or(4);
}
k if k.starts_with(EGI_NCATS_TAG) => {
n_categories = value.parse().unwrap_or(0);
}
k if k.starts_with(EGI_CATEGORY_TAG) => {
categories.push(value.to_string());
}
_ => {}
}
}
// Safety limit on header lines
if header_lines > 1000 {
return Err(IoError::InvalidFormat("EGI header too long".to_string()));
}
}
// Determine data type from precision
let data_type = EgiDataType::from_bytes(precision as usize);
// Calculate header size (approximate)
let header_size = Self::find_data_offset(path, n_channels, data_type)?;
// Generate channel names
let channels = Self::generate_channels(n_channels, gain);
Ok(Self {
format: EgiFormat::Raw,
version,
sfreq,
n_channels,
n_samples,
data_type,
gain,
n_categories,
categories,
channels,
header_size,
})
}
/// Parse an MFF directory
pub fn from_mff_dir(path: impl AsRef<Path>) -> IoResult<Self> {
let path = path.as_ref();
if !path.is_dir() {
return Err(IoError::InvalidFormat(
"MFF path is not a directory".to_string(),
));
}
// Read info.xml
let info_path = path.join(MFF_INFO_FILE);
let (sfreq, n_channels) = if info_path.exists() {
Self::parse_info_xml(&info_path)?
} else {
(256.0, 0)
};
// Find signal files and determine n_samples
let mut signal_files = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(MFF_SIGNAL_PREFIX) && name.ends_with(".bin") {
signal_files.push(entry.path());
}
}
signal_files.sort();
// Determine n_samples and n_channels from first signal file
let (n_samples, actual_n_channels, data_type) = if let Some(sig_path) = signal_files.first()
{
Self::parse_signal_file(sig_path)?
} else {
return Err(IoError::FileNotFound(
"No signal files found in MFF directory".to_string(),
));
};
let n_channels = if n_channels > 0 {
n_channels
} else {
actual_n_channels
};
// Generate channel names
let channels = Self::generate_channels(n_channels, 1.0);
Ok(Self {
format: EgiFormat::Mff,
version: 0,
sfreq,
n_channels,
n_samples,
data_type,
gain: 1.0,
n_categories: 0,
categories: Vec::new(),
channels,
header_size: 0, // MFF has no header offset (separate files)
})
}
/// Find the data offset in a RAW file
fn find_data_offset(path: &Path, n_channels: usize, data_type: EgiDataType) -> IoResult<usize> {
let file = File::open(path)?;
let file_size = file.metadata()?.len() as usize;
// Read first 64KB to find header end
let mut reader = BufReader::new(file);
let mut buf = vec![0u8; 65536.min(file_size)];
reader.read_exact(&mut buf)?;
// Look for transition from ASCII to binary
// The header is ASCII text, data is binary
for (i, window) in buf.windows(4).enumerate() {
// Look for patterns that indicate binary data start
// In float data, we often see bytes outside ASCII range
let non_ascii_count = window.iter().filter(|&&b| b < 32 || b > 126).count();
if non_ascii_count >= 2 && i > 100 {
// Align to data type boundary
let aligned = (i / data_type.size()) * data_type.size();
return Ok(aligned);
}
}
// Fallback: estimate from file size
let data_size = n_channels * data_type.size();
if data_size > 0 && file_size > data_size {
let estimated_samples = (file_size - 1024) / data_size;
if estimated_samples > 0 {
return Ok(file_size - estimated_samples * data_size);
}
}
// Default header size
Ok(1024)
}
/// Parse info.xml from MFF
fn parse_info_xml(path: &Path) -> IoResult<(f64, usize)> {
let content = fs::read_to_string(path)?;
// Simple XML parsing for key values
let sfreq = Self::extract_xml_value(&content, "samplingRate")
.and_then(|s| s.parse().ok())
.unwrap_or(256.0);
let n_channels = Self::extract_xml_value(&content, "numberOfChannels")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
Ok((sfreq, n_channels))
}
/// Extract value from simple XML
fn extract_xml_value(content: &str, tag: &str) -> Option<String> {
let open_tag = format!("<{}>", tag);
let close_tag = format!("</{}>", tag);
if let Some(start) = content.find(&open_tag) {
let value_start = start + open_tag.len();
if let Some(end) = content[value_start..].find(&close_tag) {
return Some(content[value_start..value_start + end].trim().to_string());
}
}
None
}
/// Parse signal file to get dimensions
fn parse_signal_file(path: &Path) -> IoResult<(usize, usize, EgiDataType)> {
let metadata = fs::metadata(path)?;
let file_size = metadata.len() as usize;
// MFF signal files are typically float32
let data_type = EgiDataType::Float32;
// Read a small header to determine channel count
// MFF signal files may have a small header
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut header = [0u8; 4];
reader.read_exact(&mut header)?;
// Check if first 4 bytes look like a channel count
let possible_nchan = u32::from_le_bytes(header) as usize;
let (n_channels, header_offset) = if possible_nchan > 0 && possible_nchan < 1000 {
(possible_nchan, 4)
} else {
// Assume 256 channels as default for standard EGI nets
(256, 0)
};
let data_size = file_size - header_offset;
let n_samples = data_size / (n_channels * data_type.size());
Ok((n_samples, n_channels, data_type))
}
/// Generate default channel names
fn generate_channels(n_channels: usize, gain: f64) -> Vec<EgiChannel> {
(0..n_channels)
.map(|i| {
let (name, kind) = if i == 0 {
("Cz".to_string(), EgiChannelKind::Ref)
} else {
(format!("E{}", i), EgiChannelKind::Eeg)
};
EgiChannel {
name,
index: i,
kind,
cal: gain,
}
})
.collect()
}
/// 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_egi_format_types() {
assert_eq!(EgiFormat::Raw, EgiFormat::Raw);
assert_ne!(EgiFormat::Raw, EgiFormat::Mff);
}
#[test]
fn test_channel_kind_str() {
assert_eq!(EgiChannelKind::Eeg.as_str(), "EEG");
assert_eq!(EgiChannelKind::Ref.as_str(), "REF");
assert_eq!(EgiChannelKind::Event.as_str(), "EVENT");
}
#[test]
fn test_generate_channels() {
let channels = EgiHeader::generate_channels(5, 1.0);
assert_eq!(channels.len(), 5);
assert_eq!(channels[0].name, "Cz");
assert_eq!(channels[0].kind, EgiChannelKind::Ref);
assert_eq!(channels[1].name, "E1");
assert_eq!(channels[1].kind, EgiChannelKind::Eeg);
}
#[test]
fn test_data_type_from_bytes() {
assert_eq!(EgiDataType::from_bytes(2), EgiDataType::Int16);
assert_eq!(EgiDataType::from_bytes(4), EgiDataType::Float32);
assert_eq!(EgiDataType::from_bytes(8), EgiDataType::Float64);
}
#[test]
fn test_xml_value_extraction() {
let xml = "<root><samplingRate>500</samplingRate></root>";
let value = EgiHeader::extract_xml_value(xml, "samplingRate");
assert_eq!(value, Some("500".to_string()));
let missing = EgiHeader::extract_xml_value(xml, "notFound");
assert_eq!(missing, None);
}
}
@@ -0,0 +1,46 @@
//! EGI (Electrical Geodesics, Inc.) EEG File Format Reader
//!
//! Reads data from EGI/Philips Geodesic EEG systems.
//!
//! ## File Types
//!
//! - `.raw` - Simple binary format with ASCII header
//! - `.mff` - MFF (Meta File Format) directory structure
//!
//! ## Data Format
//!
//! ### Simple RAW Format
//! The .raw format has an ASCII header followed by binary data:
//! - Header contains version, sample rate, channel count, etc.
//! - Data is big-endian float32 or int16
//!
//! ### MFF Format
//! MFF is a directory containing:
//! - `info.xml` - Session information
//! - `signal1.bin`, `signal2.bin`, ... - Binary signal files
//! - `coordinates.xml` - Sensor positions (optional)
//! - `categories.xml` - Event categories
//!
//! ## Example
//!
//! ```rust,ignore
//! use rtx_neuro_io::egi::EgiReader;
//!
//! // Read simple RAW format
//! let reader = EgiReader::open("recording.raw")?;
//! println!("Channels: {}", reader.n_channels());
//! println!("Sample rate: {} Hz", reader.sfreq());
//!
//! let data = reader.read_data(0.0, 10.0)?; // Read 10 seconds
//!
//! // Read MFF format
//! let mff_reader = EgiReader::open("recording.mff")?;
//! ```
mod constants;
mod header;
mod reader;
pub use constants::*;
pub use header::{EgiChannel, EgiChannelKind, EgiFormat, EgiHeader};
pub use reader::EgiReader;
@@ -0,0 +1,325 @@
//! EGI File Reader
//!
//! Main reader for EGI .raw and .mff files.
use super::super::{IoError, IoResult, NeuroReader};
use byteorder::{BigEndian, LittleEndian, ReadBytesExt};
use std::fs::{self, File};
use std::io::{BufReader, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::constants::*;
use super::header::{EgiFormat, EgiHeader};
/// EGI file reader
///
/// Reads data from EGI/Philips Geodesic EEG files.
#[derive(Debug)]
pub struct EgiReader {
/// Path to the file or directory
path: PathBuf,
/// Parsed header
header: EgiHeader,
/// Channel names (cached)
channel_names: Vec<String>,
/// Signal file paths (for MFF format)
signal_files: Vec<PathBuf>,
}
impl EgiReader {
/// Open an EGI file (.raw) or directory (.mff)
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let path = path.as_ref();
if !path.exists() {
return Err(IoError::FileNotFound(format!(
"EGI file not found: {}",
path.display()
)));
}
let (header, signal_files) = if path.is_dir() {
// MFF directory format
let header = EgiHeader::from_mff_dir(path)?;
let signal_files = Self::find_signal_files(path)?;
(header, signal_files)
} else {
// Simple RAW format
let header = EgiHeader::from_raw_file(path)?;
let signal_files = vec![path.to_path_buf()];
(header, signal_files)
};
let channel_names = header.channels.iter().map(|c| c.name.clone()).collect();
Ok(Self {
path: path.to_path_buf(),
header,
channel_names,
signal_files,
})
}
/// Find signal files in MFF directory
fn find_signal_files(dir: &Path) -> IoResult<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(MFF_SIGNAL_PREFIX) && name.ends_with(".bin") {
files.push(entry.path());
}
}
files.sort();
Ok(files)
}
/// Get header information
pub fn header(&self) -> &EgiHeader {
&self.header
}
/// Get path to the file/directory
pub fn path(&self) -> &Path {
&self.path
}
/// Read raw data
///
/// Returns data in channel-major format: [ch0_s0, ch0_s1, ..., ch1_s0, ...]
pub fn read_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
match self.header.format {
EgiFormat::Raw => self.read_raw_data_internal(tmin, tmax),
EgiFormat::Mff => self.read_mff_data(tmin, tmax),
}
}
/// Read data from simple RAW format
fn read_raw_data_internal(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.header.sfreq;
let n_channels = self.header.n_channels;
let total_samples = self.header.n_samples;
// Convert time to sample indices
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
if start_sample >= end_sample {
return Ok(Vec::new());
}
let n_samples = end_sample - start_sample;
// Allocate output buffer (channel-major format)
let mut data = vec![0.0f64; n_channels * n_samples];
// Get calibration factors
let cals: Vec<f64> = self.header.channels.iter().map(|c| c.cal).collect();
// Open data file
let file = File::open(&self.signal_files[0])?;
let mut reader = BufReader::new(file);
// EGI RAW data is typically stored as: all channels for sample 0, etc.
// Data is big-endian
let sample_size = n_channels * self.header.data_type.size();
let seek_pos = self.header.header_size + start_sample * sample_size;
reader.seek(SeekFrom::Start(seek_pos as u64))?;
// Read samples based on data type
match self.header.data_type {
EgiDataType::Int16 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_i16::<BigEndian>()?;
data[ch * n_samples + s] = raw as f64 * cals[ch];
}
}
}
EgiDataType::Float32 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_f32::<BigEndian>()?;
data[ch * n_samples + s] = raw as f64 * cals[ch];
}
}
}
EgiDataType::Float64 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_f64::<BigEndian>()?;
data[ch * n_samples + s] = raw * cals[ch];
}
}
}
}
Ok(data)
}
/// Read data from MFF format
fn read_mff_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.header.sfreq;
let n_channels = self.header.n_channels;
let total_samples = self.header.n_samples;
// Convert time to sample indices
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
if start_sample >= end_sample {
return Ok(Vec::new());
}
let n_samples = end_sample - start_sample;
// Allocate output buffer (channel-major format)
let mut data = vec![0.0f64; n_channels * n_samples];
// MFF stores data in signal*.bin files
// Each file may contain all channels for a segment
if self.signal_files.is_empty() {
return Err(IoError::FileNotFound("No signal files found".to_string()));
}
// Read from first signal file (simplified - assumes single file)
let file = File::open(&self.signal_files[0])?;
let mut reader = BufReader::new(file);
// MFF signal files are typically little-endian float32
let sample_size = n_channels * self.header.data_type.size();
let seek_pos = start_sample * sample_size;
reader.seek(SeekFrom::Start(seek_pos as u64))?;
// Read samples
match self.header.data_type {
EgiDataType::Float32 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_f32::<LittleEndian>()?;
data[ch * n_samples + s] = raw as f64;
}
}
}
EgiDataType::Float64 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_f64::<LittleEndian>()?;
data[ch * n_samples + s] = raw;
}
}
}
EgiDataType::Int16 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_i16::<LittleEndian>()?;
data[ch * n_samples + s] = raw as f64;
}
}
}
}
Ok(data)
}
/// Get net size (channel count category)
pub fn net_size(&self) -> &'static str {
match self.header.n_channels {
n if n <= EGI_NET_32 => "32",
n if n <= EGI_NET_64 => "64",
n if n <= EGI_NET_128 => "128",
_ => "256",
}
}
}
impl NeuroReader for EgiReader {
fn read_header(&mut self) -> IoResult<()> {
// Header is already parsed in open()
Ok(())
}
fn sfreq(&self) -> f64 {
self.header.sfreq
}
fn n_channels(&self) -> usize {
self.header.n_channels
}
fn n_samples(&self) -> usize {
self.header.n_samples
}
fn channel_names(&self) -> Vec<String> {
self.channel_names.clone()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
self.read_data(tmin, tmax)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_time_to_sample_conversion() {
let sfreq: f64 = 256.0;
let tmin: f64 = 0.5;
let tmax: f64 = 1.5;
let total_samples: usize = 1000;
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
assert_eq!(start_sample, 128);
assert_eq!(end_sample, 384);
}
#[test]
fn test_sample_size_calculation() {
let n_channels = 128;
let int16_size = n_channels * EgiDataType::Int16.size();
assert_eq!(int16_size, 128 * 2);
let float32_size = n_channels * EgiDataType::Float32.size();
assert_eq!(float32_size, 128 * 4);
}
#[test]
fn test_net_size_detection() {
// Helper to create minimal reader for testing
fn net_size_for_channels(n: usize) -> &'static str {
match n {
n if n <= EGI_NET_32 => "32",
n if n <= EGI_NET_64 => "64",
n if n <= EGI_NET_128 => "128",
_ => "256",
}
}
assert_eq!(net_size_for_channels(32), "32");
assert_eq!(net_size_for_channels(64), "64");
assert_eq!(net_size_for_channels(128), "128");
assert_eq!(net_size_for_channels(256), "256");
assert_eq!(net_size_for_channels(65), "128");
}
#[test]
fn test_channel_major_indexing() {
let n_channels = 4;
let n_samples = 100;
// Channel 2, sample 50
let ch = 2;
let s = 50;
let idx = ch * n_samples + s;
assert_eq!(idx, 250);
}
}
@@ -0,0 +1,308 @@
//! FIFF Constants - Tag and Block IDs
//!
//! Constants from the FIFF specification used by Elekta/Neuromag MEG systems.
//! Reference: <https://github.com/mne-tools/fiff-constants>
// ============================================================================
// Tag Kinds (FIFF_*)
// ============================================================================
/// File ID tag - identifies the file
pub const FIFF_FILE_ID: i32 = 100;
/// Directory pointer tag
pub const FIFF_DIR_POINTER: i32 = 101;
/// Free block list
pub const FIFF_FREE_LIST: i32 = 106;
/// Next file pointer (for split files)
pub const FIFF_NOP: i32 = 107;
/// Parent file ID
pub const FIFF_PARENT_FILE_ID: i32 = 108;
/// Parent block ID
pub const FIFF_PARENT_BLOCK_ID: i32 = 109;
/// Block ID
pub const FIFF_BLOCK_ID: i32 = 103;
/// Block start marker
pub const FIFF_BLOCK_START: i32 = 104;
/// Block end marker
pub const FIFF_BLOCK_END: i32 = 105;
// Measurement Information Tags
/// Number of channels
pub const FIFF_NCHAN: i32 = 200;
/// Sampling frequency
pub const FIFF_SFREQ: i32 = 201;
/// Data pack (compression)
pub const FIFF_DATA_PACK: i32 = 202;
/// Channel info struct
pub const FIFF_CH_INFO: i32 = 203;
/// Measurement date
pub const FIFF_MEAS_DATE: i32 = 204;
/// Subject info
pub const FIFF_SUBJECT_ID: i32 = 400;
/// Subject first name
pub const FIFF_SUBJ_FIRST_NAME: i32 = 401;
/// Subject middle name
pub const FIFF_SUBJ_MIDDLE_NAME: i32 = 402;
/// Subject last name
pub const FIFF_SUBJ_LAST_NAME: i32 = 403;
/// Subject birthday
pub const FIFF_SUBJ_BIRTH_DAY: i32 = 404;
/// Subject sex
pub const FIFF_SUBJ_SEX: i32 = 405;
/// Subject hand
pub const FIFF_SUBJ_HAND: i32 = 406;
/// Subject weight
pub const FIFF_SUBJ_WEIGHT: i32 = 407;
/// Subject height
pub const FIFF_SUBJ_HEIGHT: i32 = 408;
// Data Tags
/// First sample index
pub const FIFF_FIRST_SAMPLE: i32 = 208;
/// Last sample index
pub const FIFF_LAST_SAMPLE: i32 = 209;
/// Data buffer
pub const FIFF_DATA_BUFFER: i32 = 300;
/// Data skip (for gaps)
pub const FIFF_DATA_SKIP: i32 = 301;
/// Epoch data
pub const FIFF_EPOCH: i32 = 302;
/// Data skip samples
pub const FIFF_DATA_SKIP_SAMP: i32 = 303;
// Event Tags
/// Event channel
pub const FIFF_EVENT_CHANNEL: i32 = 600;
/// Event list
pub const FIFF_EVENT_LIST: i32 = 601;
/// Event bits
pub const FIFF_EVENT_BITS: i32 = 602;
/// Event filename
pub const FIFF_EVENT_FILENAME: i32 = 603;
// Coordinate System Tags
/// Coordinate transformation
pub const FIFF_COORD_TRANS: i32 = 222;
/// Digitizer point
pub const FIFF_DIG_POINT: i32 = 213;
/// HPI result
pub const FIFF_HPI_RESULT: i32 = 215;
// Channel Information
/// Channel kind (MEG, EEG, etc.)
pub const FIFF_CH_KIND: i32 = 206;
/// Channel calibration
pub const FIFF_CH_CAL: i32 = 207;
// Project Information
/// Project ID
pub const FIFF_PROJ_ID: i32 = 500;
/// Project name
pub const FIFF_PROJ_NAME: i32 = 501;
/// Project aim
pub const FIFF_PROJ_AIM: i32 = 502;
/// Project experimenter
pub const FIFF_PROJ_PERSONS: i32 = 503;
/// Project comment
pub const FIFF_PROJ_COMMENT: i32 = 504;
// Description Tags
/// Description
pub const FIFF_DESCRIPTION: i32 = 700;
/// Experimenter
pub const FIFF_EXPERIMENTER: i32 = 701;
/// Comment
pub const FIFF_COMMENT: i32 = 702;
// Bad Channel Tags
/// Bad channel list
pub const FIFF_BAD_CHS: i32 = 303;
// Acquisition Skip Tags
/// Acquisition skip annotation
pub const FIFF_BAD_ACQ_SKIP: i32 = 304;
// ============================================================================
// Block Kinds (FIFFB_*)
// ============================================================================
/// Root block
pub const FIFFB_ROOT: i32 = 999;
/// Measurement block
pub const FIFFB_MEAS: i32 = 100;
/// Measurement info block
pub const FIFFB_MEAS_INFO: i32 = 101;
/// Raw data block
pub const FIFFB_RAW_DATA: i32 = 102;
/// Processed data block
pub const FIFFB_PROCESSED_DATA: i32 = 103;
/// Evoked data block
pub const FIFFB_EVOKED: i32 = 104;
/// Aspect block
pub const FIFFB_ASPECT: i32 = 105;
/// Subject block
pub const FIFFB_SUBJECT: i32 = 106;
/// Isotrak block (digitizer)
pub const FIFFB_ISOTRAK: i32 = 107;
/// HPI measurement block
pub const FIFFB_HPI_MEAS: i32 = 108;
/// HPI result block
pub const FIFFB_HPI_RESULT: i32 = 109;
/// Continuous HPI block
pub const FIFFB_CONTINUOUS_HPI: i32 = 112;
/// HPI coil block
pub const FIFFB_HPI_COIL: i32 = 110;
/// Project block
pub const FIFFB_PROJECT: i32 = 111;
/// SSS (Signal Space Separation) info
pub const FIFFB_SSS_INFO: i32 = 502;
/// SSS calibration
pub const FIFFB_SSS_CAL: i32 = 503;
/// Events block
pub const FIFFB_EVENTS: i32 = 113;
// ============================================================================
// Data Types (FIFFT_*)
// ============================================================================
/// Void (unknown)
pub const FIFFT_VOID: i32 = 0;
/// Byte
pub const FIFFT_BYTE: i32 = 1;
/// 16-bit signed integer
pub const FIFFT_SHORT: i32 = 2;
/// 32-bit signed integer
pub const FIFFT_INT: i32 = 3;
/// 32-bit float
pub const FIFFT_FLOAT: i32 = 4;
/// 64-bit float
pub const FIFFT_DOUBLE: i32 = 5;
/// Julian date
pub const FIFFT_JULIAN: i32 = 6;
/// Unsigned 16-bit integer
pub const FIFFT_USHORT: i32 = 7;
/// Unsigned 32-bit integer
pub const FIFFT_UINT: i32 = 8;
/// Unsigned 64-bit integer
pub const FIFFT_ULONG: i32 = 9;
/// String (null-terminated)
pub const FIFFT_STRING: i32 = 10;
/// 64-bit signed integer
pub const FIFFT_LONG: i32 = 11;
/// DAU pack (compressed short)
pub const FIFFT_DAU_PACK13: i32 = 13;
/// DAU pack (compressed short)
pub const FIFFT_DAU_PACK14: i32 = 14;
/// DAU pack (compressed short)
pub const FIFFT_DAU_PACK16: i32 = 16;
/// Complex float (2 floats)
pub const FIFFT_COMPLEX_FLOAT: i32 = 20;
/// Complex double (2 doubles)
pub const FIFFT_COMPLEX_DOUBLE: i32 = 21;
/// Old pack format
pub const FIFFT_OLD_PACK: i32 = 23;
/// Channel info struct
pub const FIFFT_CH_INFO_STRUCT: i32 = 30;
/// ID struct
pub const FIFFT_ID_STRUCT: i32 = 31;
/// Directory entry struct
pub const FIFFT_DIR_ENTRY_STRUCT: i32 = 32;
/// Digitizer point struct
pub const FIFFT_DIG_POINT_STRUCT: i32 = 33;
/// Channel position struct
pub const FIFFT_CH_POS_STRUCT: i32 = 34;
/// Coordinate transformation struct
pub const FIFFT_COORD_TRANS_STRUCT: i32 = 35;
/// Digitizer string
pub const FIFFT_DIG_STRING: i32 = 36;
/// Stream segment
pub const FIFFT_STREAM_SEGMENT: i32 = 37;
/// Matrix of integers
pub const FIFFT_INT_MATRIX: i32 = 40;
/// Sparse integer matrix (CCS)
pub const FIFFT_INT_CCS_MATRIX: i32 = 41;
/// Matrix of floats
pub const FIFFT_FLOAT_MATRIX: i32 = 42;
/// Sparse float matrix (CCS)
pub const FIFFT_FLOAT_CCS_MATRIX: i32 = 43;
/// Matrix of doubles
pub const FIFFT_DOUBLE_MATRIX: i32 = 44;
/// Sparse double matrix (CCS)
pub const FIFFT_DOUBLE_CCS_MATRIX: i32 = 45;
// ============================================================================
// Channel Kinds (FIFFV_*)
// ============================================================================
/// MEG magnetometer
pub const FIFFV_MEG_CH: i32 = 1;
/// EEG channel
pub const FIFFV_EEG_CH: i32 = 2;
/// Stimulus channel
pub const FIFFV_STIM_CH: i32 = 3;
/// EOG channel
pub const FIFFV_EOG_CH: i32 = 202;
/// EMG channel
pub const FIFFV_EMG_CH: i32 = 302;
/// ECG channel
pub const FIFFV_ECG_CH: i32 = 402;
/// Miscellaneous channel
pub const FIFFV_MISC_CH: i32 = 502;
/// System channel
pub const FIFFV_SYS_CH: i32 = 602;
/// IAS (internal active shielding) channel
pub const FIFFV_IAS_CH: i32 = 902;
/// External trigger channel
pub const FIFFV_EXCI_CH: i32 = 55;
/// cHPI channel
pub const FIFFV_CHPI_CH: i32 = 57;
/// Dipole wave channel
pub const FIFFV_DIPOLE_WAVE: i32 = 1000;
/// Goodness of fit channel
pub const FIFFV_GOODNESS_FIT: i32 = 1001;
/// Reference MEG channel
pub const FIFFV_REF_MEG_CH: i32 = 301;
// ============================================================================
// Coil Types
// ============================================================================
/// Unknown coil
pub const FIFFV_COIL_UNKNOWN: i32 = 0;
/// Point magnetometer
pub const FIFFV_COIL_POINT_MAGNETOMETER: i32 = 1;
/// Axial gradiometer
pub const FIFFV_COIL_AXIAL_GRAD: i32 = 2;
/// Planar gradiometer
pub const FIFFV_COIL_PLANAR_GRAD: i32 = 3;
/// Elekta VectorView magnetometer
pub const FIFFV_COIL_VV_MAG: i32 = 3012;
/// Elekta VectorView Type 1 planar gradiometer
pub const FIFFV_COIL_VV_PLANAR_W: i32 = 3022;
/// Elekta VectorView Type 2 planar gradiometer
pub const FIFFV_COIL_VV_PLANAR_T1: i32 = 3023;
/// Elekta VectorView Type 3 planar gradiometer
pub const FIFFV_COIL_VV_PLANAR_T2: i32 = 3024;
/// CTF axial gradiometer 1st order
pub const FIFFV_COIL_CTF_GRAD: i32 = 5001;
/// CTF reference magnetometer
pub const FIFFV_COIL_CTF_REF_MAG: i32 = 5002;
/// CTF reference gradiometer
pub const FIFFV_COIL_CTF_REF_GRAD: i32 = 5003;
// ============================================================================
// Magic Number
// ============================================================================
/// FIFF file magic number (first 4 bytes should be tag structure)
/// First tag should be FIFF_FILE_ID with FIFFT_ID_STRUCT type
pub const FIFF_MAGIC: [u8; 4] = [0x00, 0x00, 0x01, 0x00]; // FILE_ID in little-endian
/// Size of a tag header (kind + type + size + next)
pub const FIFF_TAG_HEADER_SIZE: usize = 16;
/// Size of channel info struct
pub const FIFF_CH_INFO_SIZE: usize = 80;
/// Size of ID struct
pub const FIFF_ID_SIZE: usize = 20;
@@ -0,0 +1,35 @@
//! FIF (FIFF - Functional Imaging File Format) reader.
//!
//! FIF is the native format for Elekta/Neuromag MEG systems.
//! It uses a tag-based hierarchical structure with blocks containing
//! measurement info, raw data, and various metadata.
//!
//! ## Format Overview
//!
//! - **Tag-Based**: Data organized as Type-Length-Value (TLV) tags
//! - **Hierarchical**: Nested blocks (MEAS_INFO, RAW_DATA, etc.)
//! - **Binary**: Little-endian, multiple data types
//! - **Split Files**: Large recordings split across multiple files
//!
//! ## Usage
//!
//! ```rust,ignore
//! use rtx_neuro_core::io::fif::FifReader;
//!
//! let reader = FifReader::open("sample_raw.fif")?;
//! let info = reader.info();
//! let data = reader.read_raw_data(0.0, 10.0)?;
//! ```
//!
//! ## References
//!
//! - MNE-Python FIFF implementation
//! - <https://github.com/mne-tools/fiff-constants>
mod constants;
mod reader;
mod tag;
pub use constants::*;
pub use reader::{FifChannel, FifInfo, FifReader};
pub use tag::{FifDataType, FifTag};
@@ -0,0 +1,590 @@
//! FIF File Reader
//!
//! Main reader for Elekta/Neuromag FIF files.
use super::super::{IoError, IoResult, NeuroReader};
use byteorder::{BigEndian, ReadBytesExt};
use chrono::{DateTime, NaiveDateTime};
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::constants::*;
use super::tag::{FifDataType, FifId, FifTag};
/// Channel kind enumeration
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelKind {
/// MEG magnetometer
Meg,
/// EEG channel
Eeg,
/// Stimulus channel
Stim,
/// EOG channel
Eog,
/// EMG channel
Emg,
/// ECG channel
Ecg,
/// Miscellaneous channel
Misc,
/// Reference MEG channel
RefMeg,
/// Unknown/Other
Other(i32),
}
impl From<i32> for ChannelKind {
fn from(value: i32) -> Self {
match value {
FIFFV_MEG_CH => Self::Meg,
FIFFV_EEG_CH => Self::Eeg,
FIFFV_STIM_CH => Self::Stim,
FIFFV_EOG_CH => Self::Eog,
FIFFV_EMG_CH => Self::Emg,
FIFFV_ECG_CH => Self::Ecg,
FIFFV_MISC_CH => Self::Misc,
FIFFV_REF_MEG_CH => Self::RefMeg,
other => Self::Other(other),
}
}
}
impl ChannelKind {
/// Convert to string representation
pub fn as_str(&self) -> &'static str {
match self {
Self::Meg => "MEG",
Self::Eeg => "EEG",
Self::Stim => "STIM",
Self::Eog => "EOG",
Self::Emg => "EMG",
Self::Ecg => "ECG",
Self::Misc => "MISC",
Self::RefMeg => "REF_MEG",
Self::Other(_) => "OTHER",
}
}
}
/// Channel information from FIF file
#[derive(Debug, Clone)]
pub struct FifChannel {
/// Channel name
pub name: String,
/// Channel kind (MEG, EEG, etc.)
pub kind: ChannelKind,
/// Raw channel kind ID
pub kind_id: i32,
/// Coil type for MEG
pub coil_type: i32,
/// Channel position [x, y, z]
pub loc: [f32; 12],
/// Calibration factor
pub cal: f32,
/// Range multiplier
pub range: f32,
/// Unit (e.g., "T", "V")
pub unit: String,
/// Unit multiplier (e.g., 1e-15 for fT)
pub unit_mul: i32,
/// Logical channel number
pub logno: i32,
/// Scan number
pub scanno: i32,
}
impl FifChannel {
/// Parse channel info from raw bytes (80 bytes, big-endian)
fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() < FIFF_CH_INFO_SIZE {
return None;
}
let scanno = i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let logno = i32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let kind_id = i32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let range = f32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]);
let cal = f32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let coil_type = i32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
// Location data (12 floats at offset 24)
let mut loc = [0.0f32; 12];
for i in 0..12 {
let offset = 24 + i * 4;
loc[i] = f32::from_be_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
]);
}
// Unit at offset 72
let unit = i32::from_be_bytes([bytes[72], bytes[73], bytes[74], bytes[75]]);
let unit_mul = i32::from_be_bytes([bytes[76], bytes[77], bytes[78], bytes[79]]);
let unit_str = match unit {
201 => "T", // Tesla (MEG)
202 => "V", // Volt (EEG)
107 => "T/m", // Tesla per meter (gradiometer)
_ => "",
};
Some(Self {
name: String::new(), // Set later from separate tag
kind: ChannelKind::from(kind_id),
kind_id,
coil_type,
loc,
cal,
range,
unit: unit_str.to_string(),
unit_mul,
logno,
scanno,
})
}
/// Get full scaling factor (cal * range)
pub fn scale(&self) -> f64 {
(self.cal as f64) * (self.range as f64)
}
}
/// Measurement information from FIF file
#[derive(Debug, Clone)]
pub struct FifInfo {
/// File path
pub path: PathBuf,
/// Number of channels
pub n_channels: usize,
/// Sampling frequency (Hz)
pub sfreq: f64,
/// Measurement date/time
pub meas_date: Option<NaiveDateTime>,
/// First sample index
pub first_sample: i64,
/// Last sample index
pub last_sample: i64,
/// Channel information
pub channels: Vec<FifChannel>,
/// Subject ID
pub subject_id: Option<String>,
/// Description
pub description: Option<String>,
/// Experimenter
pub experimenter: Option<String>,
/// File ID
pub file_id: Option<FifId>,
}
impl FifInfo {
/// Total number of samples
pub fn n_samples(&self) -> usize {
(self.last_sample - self.first_sample + 1) as usize
}
/// Total duration in seconds
pub fn duration(&self) -> f64 {
self.n_samples() as f64 / self.sfreq
}
/// Get channel names
pub fn channel_names(&self) -> Vec<String> {
self.channels.iter().map(|c| c.name.clone()).collect()
}
}
/// Data buffer location in file
#[derive(Debug, Clone)]
struct DataBuffer {
/// File offset
offset: u64,
/// Data type
data_type: FifDataType,
/// Raw data type ID
data_type_id: i32,
/// Size in bytes
size: i32,
/// First sample of this buffer
first_sample: i64,
/// Number of samples
n_samples: usize,
}
/// FIF file reader
pub struct FifReader {
/// File path
path: PathBuf,
/// File handle
file: BufReader<File>,
/// Measurement information
info: FifInfo,
/// Data buffer locations
data_buffers: Vec<DataBuffer>,
/// Channel names (separate from ch_info)
channel_names: Vec<String>,
}
impl FifReader {
/// Open a FIF file
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let path = path.as_ref().to_path_buf();
let file = File::open(&path)?;
let file = BufReader::new(file);
let mut reader = Self {
path: path.clone(),
file,
info: FifInfo {
path,
n_channels: 0,
sfreq: 0.0,
meas_date: None,
first_sample: 0,
last_sample: 0,
channels: Vec::new(),
subject_id: None,
description: None,
experimenter: None,
file_id: None,
},
data_buffers: Vec::new(),
channel_names: Vec::new(),
};
reader.read_header()?;
Ok(reader)
}
/// Get the measurement info
pub fn info(&self) -> &FifInfo {
&self.info
}
/// Parse the file structure and extract measurement info
fn parse_file(&mut self) -> IoResult<()> {
self.file.seek(SeekFrom::Start(0))?;
// Read first tag (should be FILE_ID)
let first_tag = FifTag::read(&mut self.file)?;
if first_tag.kind != FIFF_FILE_ID {
return Err(IoError::InvalidFormat(format!(
"Expected FILE_ID tag, got kind {}",
first_tag.kind
)));
}
// Parse file ID
if first_tag.data_type == FifDataType::IdStruct {
self.info.file_id = FifId::from_bytes(&first_tag.data);
}
// Track current block
let mut block_stack: Vec<i32> = Vec::new();
let mut current_first_sample: i64 = 0;
let mut in_raw_data = false;
// Scan through file
loop {
let tag = match FifTag::read(&mut self.file) {
Ok(t) => t,
Err(_) => break, // EOF or read error
};
match tag.kind {
FIFF_BLOCK_START => {
if let Some(block_id) = tag.as_int() {
block_stack.push(block_id);
if block_id == FIFFB_RAW_DATA {
in_raw_data = true;
}
}
}
FIFF_BLOCK_END => {
if let Some(block_id) = tag.as_int() {
if block_stack.last() == Some(&block_id) {
block_stack.pop();
}
if block_id == FIFFB_RAW_DATA {
in_raw_data = false;
}
}
}
FIFF_NCHAN => {
if let Some(n) = tag.as_int() {
self.info.n_channels = n as usize;
}
}
FIFF_SFREQ => {
if let Some(sf) = tag.as_float() {
self.info.sfreq = sf as f64;
}
}
FIFF_MEAS_DATE => {
if tag.data.len() >= 8 {
let secs = i32::from_be_bytes([
tag.data[0],
tag.data[1],
tag.data[2],
tag.data[3],
]);
let usecs = i32::from_be_bytes([
tag.data[4],
tag.data[5],
tag.data[6],
tag.data[7],
]);
if let Some(dt) = DateTime::from_timestamp(secs as i64, usecs as u32 * 1000)
{
self.info.meas_date = Some(dt.naive_utc());
}
}
}
FIFF_CH_INFO => {
if let Some(ch) = FifChannel::from_bytes(&tag.data) {
self.info.channels.push(ch);
}
}
FIFF_FIRST_SAMPLE => {
if let Some(fs) = tag.as_int() {
self.info.first_sample = fs as i64;
current_first_sample = fs as i64;
}
}
FIFF_LAST_SAMPLE => {
if let Some(ls) = tag.as_int() {
self.info.last_sample = ls as i64;
}
}
FIFF_DATA_BUFFER if in_raw_data => {
// Calculate number of samples in this buffer
let n_channels = self.info.n_channels;
let elem_size = tag.data_type.element_size().unwrap_or(4);
let n_samples = if n_channels > 0 {
tag.size as usize / (n_channels * elem_size)
} else {
0
};
self.data_buffers.push(DataBuffer {
offset: tag.file_offset + FIFF_TAG_HEADER_SIZE as u64,
data_type: tag.data_type,
data_type_id: tag.data_type_id,
size: tag.size,
first_sample: current_first_sample,
n_samples,
});
current_first_sample += n_samples as i64;
}
FIFF_DESCRIPTION => {
self.info.description = tag.as_string();
}
FIFF_EXPERIMENTER => {
self.info.experimenter = tag.as_string();
}
_ => {}
}
// Check for next tag
if tag.next == -1 {
break;
}
}
// If last_sample not set, calculate from data buffers
if self.info.last_sample == 0 && !self.data_buffers.is_empty() {
if let Some(last_buf) = self.data_buffers.last() {
self.info.last_sample = last_buf.first_sample + last_buf.n_samples as i64 - 1;
}
}
// Generate channel names if not set
for (i, ch) in self.info.channels.iter_mut().enumerate() {
if ch.name.is_empty() {
ch.name = format!("{}_{:03}", ch.kind.as_str(), i + 1);
}
}
Ok(())
}
/// Read raw data for a time range
pub fn read_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.info.sfreq;
let start_sample = (tmin * sfreq).floor() as i64 + self.info.first_sample;
let end_sample = (tmax * sfreq).ceil() as i64 + self.info.first_sample;
let n_channels = self.info.n_channels;
let n_samples = (end_sample - start_sample) as usize;
if n_channels == 0 || n_samples == 0 {
return Ok(Vec::new());
}
// Allocate output (channel-major: [ch0_s0, ch0_s1, ..., ch1_s0, ch1_s1, ...])
let mut data = vec![0.0f64; n_channels * n_samples];
// Collect buffer read info first to avoid borrow issues
let read_ops: Vec<_> = self
.data_buffers
.iter()
.filter_map(|buf| {
let buf_start = buf.first_sample;
let buf_end = buf.first_sample + buf.n_samples as i64;
// Check overlap
if buf_end <= start_sample || buf_start >= end_sample {
return None;
}
// Calculate overlap range
let read_start = start_sample.max(buf_start);
let read_end = end_sample.min(buf_end);
let n_read = (read_end - read_start) as usize;
// Offset within buffer
let buf_offset = (read_start - buf_start) as usize;
// Offset in output
let out_offset = (read_start - start_sample) as usize;
Some((buf.offset, buf.data_type, buf_offset, n_read, out_offset))
})
.collect();
// Now read data from file
for (file_offset, data_type, buf_offset, n_read, out_offset) in read_ops {
self.read_buffer_data_at(
file_offset,
data_type,
buf_offset,
n_read,
out_offset,
n_samples,
&mut data,
)?;
}
// Apply scaling
let scales: Vec<_> = self.info.channels.iter().map(|c| c.scale()).collect();
for (ch_idx, scale) in scales.iter().enumerate() {
let ch_offset = ch_idx * n_samples;
for s in 0..n_samples {
data[ch_offset + s] *= scale;
}
}
Ok(data)
}
/// Read data from a specific file location
fn read_buffer_data_at(
&mut self,
base_offset: u64,
data_type: FifDataType,
buf_offset: usize,
n_samples: usize,
out_offset: usize,
out_n_samples: usize,
data: &mut [f64],
) -> IoResult<()> {
let n_channels = self.info.n_channels;
let elem_size = data_type.element_size().unwrap_or(4);
// Seek to start of data
let file_offset = base_offset + (buf_offset * n_channels * elem_size) as u64;
self.file.seek(SeekFrom::Start(file_offset))?;
// Read samples
for s in 0..n_samples {
for ch in 0..n_channels {
let value = match data_type {
FifDataType::Float => self.file.read_f32::<BigEndian>()? as f64,
FifDataType::Double => self.file.read_f64::<BigEndian>()?,
FifDataType::Short => self.file.read_i16::<BigEndian>()? as f64,
FifDataType::Int => self.file.read_i32::<BigEndian>()? as f64,
_ => {
// Skip unknown data types
let mut skip = vec![0u8; elem_size];
self.file.read_exact(&mut skip)?;
0.0
}
};
let idx = ch * out_n_samples + out_offset + s;
if idx < data.len() {
data[idx] = value;
}
}
}
Ok(())
}
}
impl NeuroReader for FifReader {
fn read_header(&mut self) -> IoResult<()> {
self.parse_file()
}
fn sfreq(&self) -> f64 {
self.info.sfreq
}
fn n_channels(&self) -> usize {
self.info.n_channels
}
fn n_samples(&self) -> usize {
self.info.n_samples()
}
fn channel_names(&self) -> Vec<String> {
self.info.channel_names()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
self.read_data(tmin, tmax)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_channel_kind_conversion() {
assert_eq!(ChannelKind::from(FIFFV_MEG_CH), ChannelKind::Meg);
assert_eq!(ChannelKind::from(FIFFV_EEG_CH), ChannelKind::Eeg);
assert_eq!(ChannelKind::from(FIFFV_STIM_CH), ChannelKind::Stim);
assert_eq!(ChannelKind::from(999), ChannelKind::Other(999));
}
#[test]
fn test_channel_kind_str() {
assert_eq!(ChannelKind::Meg.as_str(), "MEG");
assert_eq!(ChannelKind::Eeg.as_str(), "EEG");
assert_eq!(ChannelKind::Other(123).as_str(), "OTHER");
}
#[test]
fn test_fif_channel_scale() {
let ch = FifChannel {
name: "MEG 0113".to_string(),
kind: ChannelKind::Meg,
kind_id: FIFFV_MEG_CH,
coil_type: FIFFV_COIL_VV_MAG,
loc: [0.0; 12],
cal: 1e-15,
range: 1.0,
unit: "T".to_string(),
unit_mul: 0,
logno: 1,
scanno: 1,
};
assert!((ch.scale() - 1e-15).abs() < 1e-20);
}
}
@@ -0,0 +1,429 @@
//! FIFF Tag parsing
//!
//! Tags are the fundamental data unit in FIF files.
//! Each tag has a header (16 bytes) followed by data.
use super::super::{IoError, IoResult};
use byteorder::{BigEndian, ReadBytesExt};
use std::io::{Read, Seek, SeekFrom};
use super::constants::*;
/// FIFF data type enumeration
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FifDataType {
/// Unknown/void type
Void,
/// Byte (u8)
Byte,
/// 16-bit signed integer
Short,
/// 32-bit signed integer
Int,
/// 32-bit float
Float,
/// 64-bit float
Double,
/// Julian date
Julian,
/// Unsigned 16-bit
UShort,
/// Unsigned 32-bit
UInt,
/// String
String,
/// 64-bit signed integer
Long,
/// Complex float (2 x f32)
ComplexFloat,
/// Complex double (2 x f64)
ComplexDouble,
/// Channel info struct
ChInfoStruct,
/// ID struct
IdStruct,
/// Directory entry struct
DirEntryStruct,
/// Digitizer point struct
DigPointStruct,
/// Coordinate transform struct
CoordTransStruct,
/// Float matrix
FloatMatrix,
/// Double matrix
DoubleMatrix,
/// Unknown type with raw ID
Unknown(i32),
}
impl From<i32> for FifDataType {
fn from(value: i32) -> Self {
match value {
FIFFT_VOID => Self::Void,
FIFFT_BYTE => Self::Byte,
FIFFT_SHORT => Self::Short,
FIFFT_INT => Self::Int,
FIFFT_FLOAT => Self::Float,
FIFFT_DOUBLE => Self::Double,
FIFFT_JULIAN => Self::Julian,
FIFFT_USHORT => Self::UShort,
FIFFT_UINT => Self::UInt,
FIFFT_STRING => Self::String,
FIFFT_LONG => Self::Long,
FIFFT_COMPLEX_FLOAT => Self::ComplexFloat,
FIFFT_COMPLEX_DOUBLE => Self::ComplexDouble,
FIFFT_CH_INFO_STRUCT => Self::ChInfoStruct,
FIFFT_ID_STRUCT => Self::IdStruct,
FIFFT_DIR_ENTRY_STRUCT => Self::DirEntryStruct,
FIFFT_DIG_POINT_STRUCT => Self::DigPointStruct,
FIFFT_COORD_TRANS_STRUCT => Self::CoordTransStruct,
FIFFT_FLOAT_MATRIX => Self::FloatMatrix,
FIFFT_DOUBLE_MATRIX => Self::DoubleMatrix,
other => Self::Unknown(other),
}
}
}
impl FifDataType {
/// Get the size in bytes for a single element of this type
pub fn element_size(&self) -> Option<usize> {
match self {
Self::Void => None,
Self::Byte => Some(1),
Self::Short | Self::UShort => Some(2),
Self::Int | Self::UInt | Self::Float | Self::Julian => Some(4),
Self::Double | Self::Long | Self::ComplexFloat => Some(8),
Self::ComplexDouble => Some(16),
Self::String => Some(1), // Per character
Self::ChInfoStruct => Some(FIFF_CH_INFO_SIZE),
Self::IdStruct => Some(FIFF_ID_SIZE),
_ => None,
}
}
}
/// A FIFF tag with header and data
#[derive(Debug, Clone)]
pub struct FifTag {
/// Tag kind (type identifier)
pub kind: i32,
/// Data type
pub data_type: FifDataType,
/// Raw data type ID
pub data_type_id: i32,
/// Size of data in bytes
pub size: i32,
/// Offset to next tag (-1 if last, or offset for directory)
pub next: i32,
/// Raw tag data
pub data: Vec<u8>,
/// File offset where this tag was read
pub file_offset: u64,
}
impl FifTag {
/// Read a tag from a reader at current position
pub fn read<R: Read + Seek>(reader: &mut R) -> IoResult<Self> {
let file_offset = reader.stream_position()?;
// Read tag header (16 bytes, big-endian)
let kind = reader.read_i32::<BigEndian>()?;
let data_type_id = reader.read_i32::<BigEndian>()?;
let size = reader.read_i32::<BigEndian>()?;
let next = reader.read_i32::<BigEndian>()?;
// Validate size
if size < 0 {
return Err(IoError::InvalidFormat(format!(
"Negative tag size at offset {file_offset}: {size}"
)));
}
// Read data (if any)
let data = if size > 0 {
let mut buf = vec![0u8; size as usize];
reader.read_exact(&mut buf)?;
buf
} else {
Vec::new()
};
Ok(Self {
kind,
data_type: FifDataType::from(data_type_id),
data_type_id,
size,
next,
data,
file_offset,
})
}
/// Read only the tag header (skip data)
pub fn read_header<R: Read + Seek>(reader: &mut R) -> IoResult<Self> {
let file_offset = reader.stream_position()?;
let kind = reader.read_i32::<BigEndian>()?;
let data_type_id = reader.read_i32::<BigEndian>()?;
let size = reader.read_i32::<BigEndian>()?;
let next = reader.read_i32::<BigEndian>()?;
// Skip data
if size > 0 {
reader.seek(SeekFrom::Current(size as i64))?;
}
Ok(Self {
kind,
data_type: FifDataType::from(data_type_id),
data_type_id,
size,
next,
data: Vec::new(),
file_offset,
})
}
/// Check if this is a block start tag
pub fn is_block_start(&self) -> bool {
self.kind == FIFF_BLOCK_START
}
/// Check if this is a block end tag
pub fn is_block_end(&self) -> bool {
self.kind == FIFF_BLOCK_END
}
/// Get block ID if this is a block start/end tag
pub fn block_id(&self) -> Option<i32> {
if self.is_block_start() || self.is_block_end() {
self.as_int()
} else {
None
}
}
/// Parse data as a single i32
pub fn as_int(&self) -> Option<i32> {
if self.data.len() >= 4 {
Some(i32::from_be_bytes([
self.data[0],
self.data[1],
self.data[2],
self.data[3],
]))
} else {
None
}
}
/// Parse data as a single f32
pub fn as_float(&self) -> Option<f32> {
if self.data.len() >= 4 {
Some(f32::from_be_bytes([
self.data[0],
self.data[1],
self.data[2],
self.data[3],
]))
} else {
None
}
}
/// Parse data as a single f64
pub fn as_double(&self) -> Option<f64> {
if self.data.len() >= 8 {
Some(f64::from_be_bytes([
self.data[0],
self.data[1],
self.data[2],
self.data[3],
self.data[4],
self.data[5],
self.data[6],
self.data[7],
]))
} else {
None
}
}
/// Parse data as a string (null-terminated)
pub fn as_string(&self) -> Option<String> {
// Find null terminator or use full length
let end = self
.data
.iter()
.position(|&b| b == 0)
.unwrap_or(self.data.len());
String::from_utf8(self.data[..end].to_vec()).ok()
}
/// Parse data as array of i32
pub fn as_int_array(&self) -> Vec<i32> {
self.data
.chunks_exact(4)
.map(|chunk| i32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
/// Parse data as array of f32
pub fn as_float_array(&self) -> Vec<f32> {
self.data
.chunks_exact(4)
.map(|chunk| f32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
/// Parse data as array of f64
pub fn as_double_array(&self) -> Vec<f64> {
self.data
.chunks_exact(8)
.map(|chunk| {
f64::from_be_bytes([
chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
])
})
.collect()
}
/// Parse data as array of i16
pub fn as_short_array(&self) -> Vec<i16> {
self.data
.chunks_exact(2)
.map(|chunk| i16::from_be_bytes([chunk[0], chunk[1]]))
.collect()
}
}
/// Directory entry for navigating FIF file
#[derive(Debug, Clone)]
pub struct FifDirEntry {
/// Tag kind
pub kind: i32,
/// Data type
pub data_type: i32,
/// Size in bytes
pub size: i32,
/// File offset
pub pos: u64,
}
impl FifDirEntry {
/// Parse from raw bytes (big-endian format: 4 i32s)
pub fn from_bytes(bytes: &[u8], default_pos: u64) -> Option<Self> {
if bytes.len() < 16 {
return None;
}
Some(Self {
kind: i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
data_type: i32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
size: i32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
pos: i32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as u64,
})
}
}
/// FIFF file ID structure (20 bytes)
#[derive(Debug, Clone)]
pub struct FifId {
/// Version (major.minor)
pub version: (i16, i16),
/// Machine ID (4 bytes)
pub machid: [i32; 2],
/// Timestamp (seconds since epoch)
pub secs: i32,
/// Microseconds
pub usecs: i32,
}
impl FifId {
/// Parse from raw bytes (big-endian)
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() < 20 {
return None;
}
Some(Self {
version: (
i16::from_be_bytes([bytes[0], bytes[1]]),
i16::from_be_bytes([bytes[2], bytes[3]]),
),
machid: [
i32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
i32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
],
secs: i32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
usecs: i32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_data_type_from_id() {
assert_eq!(FifDataType::from(FIFFT_INT), FifDataType::Int);
assert_eq!(FifDataType::from(FIFFT_FLOAT), FifDataType::Float);
assert_eq!(FifDataType::from(FIFFT_STRING), FifDataType::String);
assert_eq!(FifDataType::from(9999), FifDataType::Unknown(9999));
}
#[test]
fn test_element_size() {
assert_eq!(FifDataType::Byte.element_size(), Some(1));
assert_eq!(FifDataType::Short.element_size(), Some(2));
assert_eq!(FifDataType::Int.element_size(), Some(4));
assert_eq!(FifDataType::Float.element_size(), Some(4));
assert_eq!(FifDataType::Double.element_size(), Some(8));
}
#[test]
fn test_tag_as_int() {
let tag = FifTag {
kind: FIFF_NCHAN,
data_type: FifDataType::Int,
data_type_id: FIFFT_INT,
size: 4,
next: -1,
data: vec![0x00, 0x00, 0x01, 0x00], // 256 in big-endian
file_offset: 0,
};
assert_eq!(tag.as_int(), Some(256));
}
#[test]
fn test_tag_as_string() {
let tag = FifTag {
kind: FIFF_DESCRIPTION,
data_type: FifDataType::String,
data_type_id: FIFFT_STRING,
size: 12,
next: -1,
data: b"Hello World\0".to_vec(),
file_offset: 0,
};
assert_eq!(tag.as_string(), Some("Hello World".to_string()));
}
#[test]
fn test_fif_id_parsing() {
// Version 1.3, some machine ID, timestamp
let bytes = [
0x00, 0x01, // major version 1
0x00, 0x03, // minor version 3
0x00, 0x00, 0x00, 0x01, // machid[0]
0x00, 0x00, 0x00, 0x02, // machid[1]
0x00, 0x00, 0x00, 0x64, // secs (100)
0x00, 0x00, 0x00, 0x00, // usecs (0)
];
let id = FifId::from_bytes(&bytes).unwrap();
assert_eq!(id.version, (1, 3));
assert_eq!(id.machid, [1, 2]);
assert_eq!(id.secs, 100);
}
}
@@ -0,0 +1,148 @@
//! KIT/Yokogawa Format Constants
//!
//! File offsets, magic numbers, and type definitions for KIT files.
/// Magic bytes for KIT continuous file (.con)
pub const KIT_CON_MAGIC: &[u8; 16] = b"KIT-SYSTEM CON\x00";
/// Magic bytes for KIT segmented file (.sqd)
pub const KIT_SQD_MAGIC: &[u8; 16] = b"KIT-SYSTEM SQD\x00";
/// Version 1 identifier
pub const KIT_VERSION_1: u32 = 1;
/// Version 2 identifier
pub const KIT_VERSION_2: u32 = 2;
// Header offsets
/// Offset to version number
pub const KIT_VERSION_OFFSET: usize = 16;
/// Offset to system ID
pub const KIT_SYSTEM_ID_OFFSET: usize = 20;
/// Offset to number of channels
pub const KIT_NCHAN_OFFSET: usize = 24;
/// Offset to number of samples
pub const KIT_NSAMP_OFFSET: usize = 28;
/// Offset to sampling rate (int in older versions)
pub const KIT_SFREQ_INT_OFFSET: usize = 32;
/// Offset to sampling rate (float in newer versions)
pub const KIT_SFREQ_FLOAT_OFFSET: usize = 36;
/// Offset to number of epochs
pub const KIT_NEPOCH_OFFSET: usize = 44;
/// Offset to samples per epoch
pub const KIT_EPOCH_SIZE_OFFSET: usize = 48;
/// Offset to data type
pub const KIT_DTYPE_OFFSET: usize = 52;
/// Offset to channel info start
pub const KIT_CHANNEL_INFO_OFFSET: usize = 256;
/// Size of each channel info block
pub const KIT_CHANNEL_INFO_SIZE: usize = 128;
/// Offset to data start in file
pub const KIT_DATA_OFFSET: usize = 16384;
// Channel info offsets (within channel block)
/// Offset to channel name
pub const CH_NAME_OFFSET: usize = 0;
/// Maximum channel name length
pub const MAX_CHANNEL_NAME: usize = 32;
/// Offset to channel type
pub const CH_TYPE_OFFSET: usize = 32;
/// Offset to sensor type
pub const CH_SENSOR_OFFSET: usize = 34;
/// Offset to calibration factor
pub const CH_CAL_OFFSET: usize = 40;
/// Offset to position X
pub const CH_POS_X_OFFSET: usize = 48;
/// Offset to position Y
pub const CH_POS_Y_OFFSET: usize = 56;
/// Offset to position Z
pub const CH_POS_Z_OFFSET: usize = 64;
/// Offset to orientation X
pub const CH_ORI_X_OFFSET: usize = 72;
/// Offset to orientation Y
pub const CH_ORI_Y_OFFSET: usize = 80;
/// Offset to orientation Z
pub const CH_ORI_Z_OFFSET: usize = 88;
// Channel types
/// MEG axial gradiometer
pub const KIT_CH_MEG_AXIAL: i16 = 1;
/// MEG planar gradiometer
pub const KIT_CH_MEG_PLANAR: i16 = 2;
/// EEG channel
pub const KIT_CH_EEG: i16 = 3;
/// Reference channel
pub const KIT_CH_REF: i16 = 4;
/// Auxiliary/ADC channel
pub const KIT_CH_AUX: i16 = 5;
/// Trigger channel
pub const KIT_CH_TRIGGER: i16 = 6;
/// Digital input channel
pub const KIT_CH_DIGITAL: i16 = 7;
/// Magnetic stimulus channel
pub const KIT_CH_STIM_MAG: i16 = 8;
// Sensor types
/// Magnetometer
pub const KIT_SENSOR_MAG: i16 = 1;
/// Axial gradiometer
pub const KIT_SENSOR_GRAD_AXIAL: i16 = 2;
/// Planar gradiometer
pub const KIT_SENSOR_GRAD_PLANAR: i16 = 3;
// Data types
/// 16-bit signed integer
pub const KIT_DTYPE_INT16: i16 = 1;
/// 32-bit signed integer
pub const KIT_DTYPE_INT32: i16 = 2;
/// 32-bit float
pub const KIT_DTYPE_FLOAT32: i16 = 3;
/// 64-bit float
pub const KIT_DTYPE_FLOAT64: i16 = 4;
// System IDs
/// KIT-157 system
pub const KIT_SYSTEM_157: u32 = 157;
/// KIT-208 system
pub const KIT_SYSTEM_208: u32 = 208;
/// KIT-64 system
pub const KIT_SYSTEM_64: u32 = 64;
/// Data type enumeration for KIT files
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KitDataType {
/// 16-bit signed integer
Int16,
/// 32-bit signed integer
Int32,
/// 32-bit float
Float32,
/// 64-bit float
Float64,
}
impl TryFrom<i16> for KitDataType {
type Error = &'static str;
fn try_from(value: i16) -> Result<Self, Self::Error> {
match value {
KIT_DTYPE_INT16 => Ok(Self::Int16),
KIT_DTYPE_INT32 => Ok(Self::Int32),
KIT_DTYPE_FLOAT32 => Ok(Self::Float32),
KIT_DTYPE_FLOAT64 => Ok(Self::Float64),
_ => Err("Unknown KIT data type"),
}
}
}
impl KitDataType {
/// Size of this data type in bytes
pub fn size(&self) -> usize {
match self {
Self::Int16 => 2,
Self::Int32 => 4,
Self::Float32 => 4,
Self::Float64 => 8,
}
}
}
@@ -0,0 +1,372 @@
//! KIT Header Parser
//!
//! Parses the binary header from KIT/Yokogawa files.
use super::super::{IoError, IoResult};
use byteorder::{LittleEndian, ReadBytesExt};
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::Path;
use super::constants::*;
/// KIT channel type enumeration
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KitChannelKind {
/// MEG axial gradiometer
MegAxial,
/// MEG planar gradiometer
MegPlanar,
/// EEG channel
Eeg,
/// Reference MEG channel
Ref,
/// Auxiliary/ADC channel
Aux,
/// Trigger channel
Trigger,
/// Digital input channel
Digital,
/// Magnetic stimulus channel
StimMag,
/// Unknown channel type
Unknown(i16),
}
impl From<i16> for KitChannelKind {
fn from(value: i16) -> Self {
match value {
KIT_CH_MEG_AXIAL => Self::MegAxial,
KIT_CH_MEG_PLANAR => Self::MegPlanar,
KIT_CH_EEG => Self::Eeg,
KIT_CH_REF => Self::Ref,
KIT_CH_AUX => Self::Aux,
KIT_CH_TRIGGER => Self::Trigger,
KIT_CH_DIGITAL => Self::Digital,
KIT_CH_STIM_MAG => Self::StimMag,
other => Self::Unknown(other),
}
}
}
impl KitChannelKind {
/// Get string representation
pub fn as_str(&self) -> &'static str {
match self {
Self::MegAxial => "MEG_AXIAL",
Self::MegPlanar => "MEG_PLANAR",
Self::Eeg => "EEG",
Self::Ref => "REF",
Self::Aux => "AUX",
Self::Trigger => "TRIGGER",
Self::Digital => "DIGITAL",
Self::StimMag => "STIM_MAG",
Self::Unknown(_) => "UNKNOWN",
}
}
/// Check if this is a MEG channel
pub fn is_meg(&self) -> bool {
matches!(self, Self::MegAxial | Self::MegPlanar)
}
}
/// KIT channel information
#[derive(Debug, Clone)]
pub struct KitChannel {
/// Channel name
pub name: String,
/// Channel index (0-based)
pub index: usize,
/// Channel type
pub kind: KitChannelKind,
/// Sensor type code
pub sensor_type: i16,
/// Calibration factor (raw to physical units)
pub cal: f64,
/// Position in 3D space (x, y, z) in meters
pub position: [f64; 3],
/// Orientation unit vector (x, y, z)
pub orientation: [f64; 3],
}
impl KitChannel {
/// Get unit string based on channel type
pub fn units(&self) -> &'static str {
match self.kind {
KitChannelKind::MegAxial | KitChannelKind::MegPlanar | KitChannelKind::Ref => "T",
KitChannelKind::Eeg => "V",
KitChannelKind::Trigger | KitChannelKind::Digital => "V",
_ => "AU",
}
}
}
/// KIT system information
#[derive(Debug, Clone)]
pub struct KitSystemInfo {
/// System ID (157, 208, 64, etc.)
pub system_id: u32,
/// File format version
pub version: u32,
/// System name string
pub name: String,
}
impl KitSystemInfo {
/// Get human-readable system name
pub fn system_name(&self) -> &'static str {
match self.system_id {
KIT_SYSTEM_157 => "KIT-157",
KIT_SYSTEM_208 => "KIT-208",
KIT_SYSTEM_64 => "KIT-64",
_ => "Unknown",
}
}
}
/// Parsed KIT header
#[derive(Debug, Clone)]
pub struct KitHeader {
/// System information
pub system: KitSystemInfo,
/// Number of channels
pub n_channels: usize,
/// Sampling frequency in Hz
pub sfreq: f64,
/// Number of samples per channel
pub n_samples: usize,
/// Number of epochs
pub n_epochs: usize,
/// Samples per epoch
pub epoch_size: usize,
/// Data type
pub data_type: KitDataType,
/// Channel definitions
pub channels: Vec<KitChannel>,
}
impl KitHeader {
/// Parse a KIT file header
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 KIT file: {}", path.display()),
))
})?;
let mut reader = BufReader::new(file);
// Read and validate magic header
let mut magic = [0u8; 16];
reader.read_exact(&mut magic)?;
// Check if it's CON or SQD format
let is_con = &magic[..15] == &KIT_CON_MAGIC[..15];
let is_sqd = &magic[..15] == &KIT_SQD_MAGIC[..15];
if !is_con && !is_sqd {
return Err(IoError::InvalidFormat(format!(
"Invalid KIT file header: {:?}",
String::from_utf8_lossy(&magic)
)));
}
// Read version
reader.seek(SeekFrom::Start(KIT_VERSION_OFFSET as u64))?;
let version = reader.read_u32::<LittleEndian>()?;
// Read system ID
reader.seek(SeekFrom::Start(KIT_SYSTEM_ID_OFFSET as u64))?;
let system_id = reader.read_u32::<LittleEndian>()?;
// Read number of channels
reader.seek(SeekFrom::Start(KIT_NCHAN_OFFSET as u64))?;
let n_channels = reader.read_u32::<LittleEndian>()? as usize;
// Read number of samples
reader.seek(SeekFrom::Start(KIT_NSAMP_OFFSET as u64))?;
let n_samples = reader.read_u32::<LittleEndian>()? as usize;
// Read sampling frequency
// Try float first (newer format), fall back to int (older format)
reader.seek(SeekFrom::Start(KIT_SFREQ_FLOAT_OFFSET as u64))?;
let sfreq_float = reader.read_f32::<LittleEndian>()?;
let sfreq = if sfreq_float > 0.0 && sfreq_float < 100000.0 {
sfreq_float as f64
} else {
reader.seek(SeekFrom::Start(KIT_SFREQ_INT_OFFSET as u64))?;
reader.read_i32::<LittleEndian>()? as f64
};
// Read number of epochs
reader.seek(SeekFrom::Start(KIT_NEPOCH_OFFSET as u64))?;
let n_epochs = reader.read_u32::<LittleEndian>()?.max(1) as usize;
// Read epoch size
reader.seek(SeekFrom::Start(KIT_EPOCH_SIZE_OFFSET as u64))?;
let epoch_size = reader.read_u32::<LittleEndian>()? as usize;
// Read data type
reader.seek(SeekFrom::Start(KIT_DTYPE_OFFSET as u64))?;
let dtype_code = reader.read_i16::<LittleEndian>()?;
let data_type = KitDataType::try_from(dtype_code).unwrap_or(KitDataType::Int16);
// Read channel information
let mut channels = Vec::with_capacity(n_channels);
for ch_idx in 0..n_channels {
let ch_offset = KIT_CHANNEL_INFO_OFFSET + ch_idx * KIT_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();
let name = if name.is_empty() {
format!("CH{:03}", ch_idx + 1)
} else {
name
};
// Read channel type
reader.seek(SeekFrom::Start((ch_offset + CH_TYPE_OFFSET) as u64))?;
let kind_code = reader.read_i16::<LittleEndian>()?;
// Read sensor type
reader.seek(SeekFrom::Start((ch_offset + CH_SENSOR_OFFSET) as u64))?;
let sensor_type = reader.read_i16::<LittleEndian>()?;
// Read calibration
reader.seek(SeekFrom::Start((ch_offset + CH_CAL_OFFSET) as u64))?;
let cal = reader.read_f64::<LittleEndian>()?;
let cal = if cal.abs() < 1e-30 { 1.0 } else { cal };
// Read position
reader.seek(SeekFrom::Start((ch_offset + CH_POS_X_OFFSET) as u64))?;
let pos_x = reader.read_f64::<LittleEndian>()?;
reader.seek(SeekFrom::Start((ch_offset + CH_POS_Y_OFFSET) as u64))?;
let pos_y = reader.read_f64::<LittleEndian>()?;
reader.seek(SeekFrom::Start((ch_offset + CH_POS_Z_OFFSET) as u64))?;
let pos_z = reader.read_f64::<LittleEndian>()?;
// Read orientation
reader.seek(SeekFrom::Start((ch_offset + CH_ORI_X_OFFSET) as u64))?;
let ori_x = reader.read_f64::<LittleEndian>()?;
reader.seek(SeekFrom::Start((ch_offset + CH_ORI_Y_OFFSET) as u64))?;
let ori_y = reader.read_f64::<LittleEndian>()?;
reader.seek(SeekFrom::Start((ch_offset + CH_ORI_Z_OFFSET) as u64))?;
let ori_z = reader.read_f64::<LittleEndian>()?;
channels.push(KitChannel {
name,
index: ch_idx,
kind: KitChannelKind::from(kind_code),
sensor_type,
cal,
position: [pos_x, pos_y, pos_z],
orientation: [ori_x, ori_y, ori_z],
});
}
let system = KitSystemInfo {
system_id,
version,
name: if is_con {
"CON".to_string()
} else {
"SQD".to_string()
},
};
Ok(Self {
system,
n_channels,
sfreq,
n_samples,
n_epochs,
epoch_size,
data_type,
channels,
})
}
/// Get duration in seconds
pub fn duration(&self) -> f64 {
self.n_samples as f64 / self.sfreq
}
/// Get number of MEG channels
pub fn n_meg_channels(&self) -> usize {
self.channels.iter().filter(|c| c.kind.is_meg()).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_channel_kind_from_id() {
assert_eq!(
KitChannelKind::from(KIT_CH_MEG_AXIAL),
KitChannelKind::MegAxial
);
assert_eq!(
KitChannelKind::from(KIT_CH_MEG_PLANAR),
KitChannelKind::MegPlanar
);
assert_eq!(KitChannelKind::from(KIT_CH_EEG), KitChannelKind::Eeg);
assert_eq!(
KitChannelKind::from(KIT_CH_TRIGGER),
KitChannelKind::Trigger
);
assert_eq!(KitChannelKind::from(999), KitChannelKind::Unknown(999));
}
#[test]
fn test_channel_kind_str() {
assert_eq!(KitChannelKind::MegAxial.as_str(), "MEG_AXIAL");
assert_eq!(KitChannelKind::MegPlanar.as_str(), "MEG_PLANAR");
assert_eq!(KitChannelKind::Eeg.as_str(), "EEG");
}
#[test]
fn test_channel_is_meg() {
assert!(KitChannelKind::MegAxial.is_meg());
assert!(KitChannelKind::MegPlanar.is_meg());
assert!(!KitChannelKind::Eeg.is_meg());
assert!(!KitChannelKind::Trigger.is_meg());
}
#[test]
fn test_data_type_size() {
assert_eq!(KitDataType::Int16.size(), 2);
assert_eq!(KitDataType::Int32.size(), 4);
assert_eq!(KitDataType::Float32.size(), 4);
assert_eq!(KitDataType::Float64.size(), 8);
}
#[test]
fn test_system_info_name() {
let info = KitSystemInfo {
system_id: KIT_SYSTEM_157,
version: 1,
name: "CON".to_string(),
};
assert_eq!(info.system_name(), "KIT-157");
let info208 = KitSystemInfo {
system_id: KIT_SYSTEM_208,
version: 1,
name: "CON".to_string(),
};
assert_eq!(info208.system_name(), "KIT-208");
}
}
@@ -0,0 +1,38 @@
//! Yokogawa/KIT/Ricoh MEG File Format Reader
//!
//! Reads data from Yokogawa/KIT MEG systems (now Ricoh).
//!
//! ## File Types
//!
//! - `.con` - Continuous data file (single file per recording)
//! - `.sqd` - Segmented/epoched data file
//! - `.mrk` - Marker/event file (optional)
//!
//! ## Data Format
//!
//! KIT files are little-endian with a fixed header structure followed by
//! channel data. The format supports multiple acquisition systems:
//! - KIT-157 (157 channels)
//! - KIT-208 (208 channels)
//! - KIT-64 (64 channels)
//! - Ricoh MEG systems
//!
//! ## Example
//!
//! ```rust,ignore
//! use rtx_neuro_io::kit::KitReader;
//!
//! let reader = KitReader::open("recording.con")?;
//! println!("Channels: {}", reader.n_channels());
//! println!("Sample rate: {} Hz", reader.sfreq());
//!
//! let data = reader.read_data(0.0, 10.0)?; // Read 10 seconds
//! ```
mod constants;
mod header;
mod reader;
pub use constants::*;
pub use header::{KitChannel, KitChannelKind, KitHeader, KitSystemInfo};
pub use reader::KitReader;
@@ -0,0 +1,279 @@
//! KIT/Yokogawa MEG File Reader
//!
//! Main reader for KIT .con and .sqd files.
use super::super::{IoError, IoResult, NeuroReader};
use byteorder::{LittleEndian, ReadBytesExt};
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::constants::*;
use super::header::KitHeader;
/// KIT MEG file reader
///
/// Reads data from Yokogawa/KIT MEG files (.con, .sqd).
#[derive(Debug)]
pub struct KitReader {
/// Path to the data file
file_path: PathBuf,
/// Parsed header
header: KitHeader,
/// Channel names (cached)
channel_names: Vec<String>,
/// Data offset in bytes
data_offset: usize,
}
impl KitReader {
/// Open a KIT data file (.con or .sqd)
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
let file_path = path.as_ref().to_path_buf();
if !file_path.exists() {
return Err(IoError::FileNotFound(format!(
"KIT file not found: {}",
file_path.display()
)));
}
// Check extension
let ext = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
if ext != "con" && ext != "sqd" {
return Err(IoError::InvalidFormat(format!(
"Invalid KIT file extension: .{} (expected .con or .sqd)",
ext
)));
}
// Parse header
let header = KitHeader::from_file(&file_path)?;
// Cache channel names
let channel_names = header.channels.iter().map(|c| c.name.clone()).collect();
Ok(Self {
file_path,
header,
channel_names,
data_offset: KIT_DATA_OFFSET,
})
}
/// Get header information
pub fn header(&self) -> &KitHeader {
&self.header
}
/// Get path to the data file
pub fn path(&self) -> &Path {
&self.file_path
}
/// Read raw data
///
/// Returns data in channel-major format: [ch0_s0, ch0_s1, ..., ch1_s0, ...]
pub fn read_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
let sfreq = self.header.sfreq;
let n_channels = self.header.n_channels;
let total_samples = self.header.n_samples;
// Convert time to sample indices
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
if start_sample >= end_sample {
return Ok(Vec::new());
}
let n_samples = end_sample - start_sample;
// Allocate output buffer (channel-major format)
let mut data = vec![0.0f64; n_channels * n_samples];
// Get calibration factors
let cals: Vec<f64> = self.header.channels.iter().map(|c| c.cal).collect();
// Open data file
let file = File::open(&self.file_path)?;
let mut reader = BufReader::new(file);
// KIT data is stored as: all channels for sample 0, all channels for sample 1, etc.
let sample_size = n_channels * self.header.data_type.size();
let seek_pos = self.data_offset + start_sample * sample_size;
reader.seek(SeekFrom::Start(seek_pos as u64))?;
// Read samples based on data type
match self.header.data_type {
KitDataType::Int16 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_i16::<LittleEndian>()?;
data[ch * n_samples + s] = raw as f64 * cals[ch];
}
}
}
KitDataType::Int32 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_i32::<LittleEndian>()?;
data[ch * n_samples + s] = raw as f64 * cals[ch];
}
}
}
KitDataType::Float32 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_f32::<LittleEndian>()?;
data[ch * n_samples + s] = raw as f64 * cals[ch];
}
}
}
KitDataType::Float64 => {
for s in 0..n_samples {
for ch in 0..n_channels {
let raw = reader.read_f64::<LittleEndian>()?;
data[ch * n_samples + s] = raw * cals[ch];
}
}
}
}
Ok(data)
}
/// Read marker file if present
pub fn read_markers(&self) -> IoResult<Vec<KitMarker>> {
let mrk_path = self.file_path.with_extension("mrk");
if !mrk_path.exists() {
return Ok(Vec::new());
}
let file = File::open(&mrk_path)?;
let mut reader = BufReader::new(file);
// Simple marker file parsing
// Format varies, but typically contains: sample, code, channel
let mut markers = Vec::new();
let mut buf = [0u8; 12];
while reader.read_exact(&mut buf).is_ok() {
let sample = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
let code = i32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
let _channel = i32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
markers.push(KitMarker {
sample,
code,
time: sample as f64 / self.header.sfreq,
});
}
Ok(markers)
}
/// Get number of MEG channels
pub fn n_meg_channels(&self) -> usize {
self.header.n_meg_channels()
}
}
/// A marker/event from a KIT recording
#[derive(Debug, Clone)]
pub struct KitMarker {
/// Sample index
pub sample: usize,
/// Event code
pub code: i32,
/// Time in seconds
pub time: f64,
}
impl NeuroReader for KitReader {
fn read_header(&mut self) -> IoResult<()> {
// Header is already parsed in open()
Ok(())
}
fn sfreq(&self) -> f64 {
self.header.sfreq
}
fn n_channels(&self) -> usize {
self.header.n_channels
}
fn n_samples(&self) -> usize {
self.header.n_samples
}
fn channel_names(&self) -> Vec<String> {
self.channel_names.clone()
}
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
self.read_data(tmin, tmax)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_time_to_sample_conversion() {
let sfreq: f64 = 1000.0;
let tmin: f64 = 0.5;
let tmax: f64 = 1.5;
let total_samples: usize = 5000;
let start_sample = ((tmin * sfreq).floor() as usize).min(total_samples);
let end_sample = ((tmax * sfreq).ceil() as usize).min(total_samples);
assert_eq!(start_sample, 500);
assert_eq!(end_sample, 1500);
}
#[test]
fn test_sample_size_calculation() {
let n_channels = 208;
let int16_size = n_channels * KitDataType::Int16.size();
assert_eq!(int16_size, 208 * 2);
let float32_size = n_channels * KitDataType::Float32.size();
assert_eq!(float32_size, 208 * 4);
}
#[test]
fn test_channel_major_indexing() {
let n_channels = 5;
let n_samples = 100;
// Channel 2, sample 50
let ch = 2;
let s = 50;
let idx = ch * n_samples + s;
assert_eq!(idx, 250);
}
#[test]
fn test_marker_time_calculation() {
let marker = KitMarker {
sample: 1000,
code: 1,
time: 1.0,
};
assert_eq!(marker.sample, 1000);
assert_eq!(marker.time, 1.0);
}
}
@@ -0,0 +1,202 @@
//! # I/O Module - Neuroimaging File Format Readers
//!
//! File format readers and writers for neuroimaging data.
//!
//! ## Supported Formats
//!
//! ### EEG Formats
//! - **EDF/EDF+** - European Data Format (most common research format)
//! - **BDF** - BioSemi 24-bit variant
//! - **BrainVision** - .vhdr/.vmrk/.eeg files
//! - **EGI** - Electrical Geodesics (.raw, .mff)
//!
//! ### MEG Formats
//! - **FIF** - Elekta/Neuromag format
//! - **CTF** - CTF MEG Systems (.ds directories)
//! - **BTi/4D** - 4D-Neuroimaging/BTi systems
//! - **KIT** - Yokogawa/KIT/Ricoh systems (.con, .sqd)
//!
//! ### Dataset Formats
//! - **BIDS** - Brain Imaging Data Structure (directory-based)
//!
//! ## Usage
//!
//! ```rust,ignore
//! use rtx_neuro_core::io::edf::EdfReader;
//! use rtx_neuro_core::io::fif::FifReader;
//! use rtx_neuro_core::io::ctf::CtfReader;
//!
//! // Read EDF file
//! let reader = EdfReader::open("recording.edf")?;
//! let data = reader.read_data(0.0, 10.0)?; // Read 10 seconds
//!
//! // Read FIF file (Elekta/Neuromag MEG)
//! let reader = FifReader::open("sample_raw.fif")?;
//! let data = reader.read_data(0.0, 10.0)?;
//!
//! // Read CTF dataset (directory-based)
//! let reader = CtfReader::open("experiment.ds")?;
//! let data = reader.read_data(0.0, 10.0)?;
//!
//! // Open BIDS dataset
//! use rtx_neuro_core::io::bids::BidsDataset;
//! let dataset = BidsDataset::open("my_bids_dataset")?;
//! println!("Dataset: {}", dataset.name());
//! for subject in dataset.subject_labels() {
//! println!(" Subject: {}", subject);
//! }
//! ```
use std::path::Path;
pub mod bids;
pub mod brainvision;
pub mod bti;
pub mod ctf;
pub mod edf;
pub mod egi;
pub mod fif;
pub mod kit;
// Re-export main types
pub use bids::{
BidsDataset, BidsFile, BidsSession, BidsSubject, DatasetDescription, FileEntities,
is_bids_dataset, parse_bids_filename,
};
pub use brainvision::BrainVisionReader;
pub use bti::{BtiChannel, BtiChannelKind, BtiConfig, BtiReader};
pub use ctf::{CtfChannel, CtfChannelKind, CtfReader, Res4Header};
pub use edf::{EdfHeader, EdfReader};
pub use egi::{EgiChannel, EgiChannelKind, EgiFormat, EgiHeader, EgiReader};
pub use fif::{FifChannel, FifInfo, FifReader};
pub use kit::{KitChannel, KitChannelKind, KitHeader, KitReader};
/// Error types for I/O operations
#[derive(Debug, thiserror::Error)]
pub enum IoError {
/// File I/O error
#[error("File I/O error: {0}")]
Io(#[from] std::io::Error),
/// Invalid file format
#[error("Invalid file format: {0}")]
InvalidFormat(String),
/// Unsupported format version
#[error("Unsupported format version: {0}")]
UnsupportedVersion(String),
/// Header parsing error
#[error("Header parsing error: {0}")]
HeaderParse(String),
/// Data parsing error
#[error("Data parsing error: {0}")]
DataParse(String),
/// File not found
#[error("File not found: {0}")]
FileNotFound(String),
/// Channel not found
#[error("Channel not found: {0}")]
ChannelNotFound(String),
}
/// Result type for I/O operations
pub type IoResult<T> = Result<T, IoError>;
/// Trait for file format readers
pub trait NeuroReader {
/// Read file header/metadata
fn read_header(&mut self) -> IoResult<()>;
/// Get sampling frequency in Hz
fn sfreq(&self) -> f64;
/// Get number of channels
fn n_channels(&self) -> usize;
/// Get total number of samples per channel
fn n_samples(&self) -> usize;
/// Get channel names
fn channel_names(&self) -> Vec<String>;
/// Read raw data for specified time range
/// Returns data as [n_channels x n_samples]
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>>;
/// Read all data
fn read_all_data(&mut self) -> IoResult<Vec<f64>> {
let duration = self.n_samples() as f64 / self.sfreq();
self.read_raw_data(0.0, duration)
}
}
/// Detect file format from path
pub fn detect_format(path: impl AsRef<Path>) -> Option<FileFormat> {
let path = path.as_ref();
// Check for directory-based formats
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".ds") {
return Some(FileFormat::Ctf);
}
if name.ends_with(".mff") {
return Some(FileFormat::Egi);
}
// Check for BTi directory (contains config file)
let config_path = path.join("config");
if config_path.exists() {
return Some(FileFormat::Bti);
}
}
}
// Check file extension
let ext = path.extension()?.to_str()?.to_lowercase();
match ext.as_str() {
"edf" => Some(FileFormat::Edf),
"bdf" => Some(FileFormat::Bdf),
"vhdr" => Some(FileFormat::BrainVision),
"set" => Some(FileFormat::EegLab),
"fif" => Some(FileFormat::Fif),
"ds" => Some(FileFormat::Ctf),
"cnt" => Some(FileFormat::Neuroscan),
"nwb" => Some(FileFormat::Nwb),
"con" | "sqd" => Some(FileFormat::Kit),
"raw" => Some(FileFormat::Egi),
"mff" => Some(FileFormat::Egi),
_ => None,
}
}
/// Supported file formats
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
/// EDF (European Data Format)
Edf,
/// BDF (BioSemi 24-bit)
Bdf,
/// BrainVision (.vhdr/.vmrk/.eeg)
BrainVision,
/// EEGLAB (.set)
EegLab,
/// Elekta/Neuromag FIF
Fif,
/// CTF MEG
Ctf,
/// 4D-Neuroimaging/BTi MEG
Bti,
/// Yokogawa/KIT/Ricoh MEG
Kit,
/// EGI (.raw, .mff)
Egi,
/// Neuroscan (.cnt)
Neuroscan,
/// Neurodata Without Borders
Nwb,
}