//! 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 crate::{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, } 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, /// Parsed header header: EdfHeader, } impl EdfReader { /// Open an EDF/BDF file pub fn open(path: impl AsRef) -> IoResult { 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; let bytes_per_field = 16; let fields = 10; // label, transducer, dim, phys_min, phys_max, dig_min, dig_max, prefilter, samples, reserved // 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::().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::().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::().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::().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::().unwrap_or(0)); offset += 8; } // Reserved (32 bytes each) - skip // offset += ns * 32; // 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> { 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::()? 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 { self.header .channels .iter() .map(|c| c.label.clone()) .collect() } fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult> { 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 { // 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()); } }