Initial commit
This commit is contained in:
@@ -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(¤t_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,
|
||||
}
|
||||
Reference in New Issue
Block a user