Files
rustytorch/crates/specialized/rtx-neuro-io/src/edf.rs
T
2026-03-04 00:08:42 +00:00

623 lines
20 KiB
Rust

//! 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<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;
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::<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
// 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<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());
}
}