Initial commit
This commit is contained in:
@@ -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 crate::{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 crate::{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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user