use anyhow::{bail, Context, Result};
use ndarray::Array2;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct EdfHeader {
pub version: String,
pub patient_id: String,
pub recording_id: String,
pub start_date: String,
pub start_time: String,
pub header_bytes: usize,
pub reserved: String,
pub num_records: usize,
pub record_duration: f64,
pub num_signals: usize,
pub signals: Vec<SignalHeader>,
pub sample_rate: f32,
pub is_edfplus: bool,
}
#[derive(Debug, Clone)]
pub struct SignalHeader {
pub label: String,
pub transducer: String,
pub physical_dimension: String,
pub physical_min: f64,
pub physical_max: f64,
pub digital_min: f64,
pub digital_max: f64,
pub prefiltering: String,
pub samples_per_record: usize,
pub reserved: String,
}
impl SignalHeader {
pub fn cal(&self) -> f64 {
let phys_range = self.physical_max - self.physical_min;
let dig_range = self.digital_max - self.digital_min;
if dig_range == 0.0 { 1.0 } else { phys_range / dig_range }
}
pub fn offset(&self) -> f64 {
self.physical_min - self.digital_min * self.cal()
}
pub fn unit_scale(&self) -> f64 {
let dim = self.physical_dimension.trim().to_lowercase();
if dim == "uv" || dim == "µv" || dim == "\u{03bc}v" {
1e-6
} else if dim == "mv" {
1e-3
} else {
1.0 }
}
}
#[derive(Debug, Clone)]
pub struct EdfAnnotation {
pub onset: f64,
pub duration: f64,
pub description: String,
}
pub struct RawEdf {
pub header: EdfHeader,
pub path: std::path::PathBuf,
pub annotations: Vec<EdfAnnotation>,
}
pub fn open_raw_edf<P: AsRef<Path>>(path: P) -> Result<RawEdf> {
let path = path.as_ref().to_path_buf();
let mut file = std::fs::File::open(&path)
.with_context(|| format!("opening EDF file: {}", path.display()))?;
let header = read_header(&mut file)?;
let annotations = if header.is_edfplus {
read_annotations(&mut file, &header)?
} else {
vec![]
};
Ok(RawEdf { header, path, annotations })
}
impl RawEdf {
pub fn read_all_data(&self) -> Result<Array2<f32>> {
let mut file = std::fs::File::open(&self.path)?;
read_data(&mut file, &self.header)
}
pub fn channel_names(&self) -> Vec<String> {
self.header.signals.iter()
.filter(|s| !is_annotation_channel(&s.label))
.map(|s| s.label.clone())
.collect()
}
pub fn num_channels(&self) -> usize {
self.header.signals.iter()
.filter(|s| !is_annotation_channel(&s.label))
.count()
}
pub fn num_samples(&self) -> usize {
let max_spr = self.header.signals.iter()
.filter(|s| !is_annotation_channel(&s.label))
.map(|s| s.samples_per_record)
.max()
.unwrap_or(0);
self.header.num_records * max_spr
}
}
fn is_annotation_channel(label: &str) -> bool {
let l = label.trim().to_lowercase();
l.contains("edf annotation") || l.contains("edf+annotation")
|| l == "edf annotations" || l == "bdf annotations"
}
fn read_fixed_str<R: Read>(reader: &mut R, n: usize) -> Result<String> {
let mut buf = vec![0u8; n];
reader.read_exact(&mut buf)?;
Ok(String::from_utf8_lossy(&buf).trim_end().to_string())
}
fn read_fixed_f64<R: Read>(reader: &mut R, n: usize) -> Result<f64> {
let s = read_fixed_str(reader, n)?;
let s = s.replace(',', ".");
s.trim().parse::<f64>()
.with_context(|| format!("parsing float from EDF header: '{s}'"))
}
fn read_fixed_usize<R: Read>(reader: &mut R, n: usize) -> Result<usize> {
let s = read_fixed_str(reader, n)?;
s.trim().parse::<usize>()
.with_context(|| format!("parsing int from EDF header: '{s}'"))
}
fn read_header<R: Read>(reader: &mut R) -> Result<EdfHeader> {
let version = read_fixed_str(reader, 8)?;
let patient_id = read_fixed_str(reader, 80)?;
let recording_id = read_fixed_str(reader, 80)?;
let start_date = read_fixed_str(reader, 8)?;
let start_time = read_fixed_str(reader, 8)?;
let header_bytes = read_fixed_usize(reader, 8)?;
let reserved = read_fixed_str(reader, 44)?;
let num_records = read_fixed_usize(reader, 8)?;
let record_duration = read_fixed_f64(reader, 8)?;
let num_signals = read_fixed_usize(reader, 4)?;
let is_edfplus = reserved.contains("EDF+");
let mut labels = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
labels.push(read_fixed_str(reader, 16)?);
}
let mut transducers = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
transducers.push(read_fixed_str(reader, 80)?);
}
let mut phys_dims = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
phys_dims.push(read_fixed_str(reader, 8)?);
}
let mut phys_mins = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
phys_mins.push(read_fixed_f64(reader, 8)?);
}
let mut phys_maxs = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
phys_maxs.push(read_fixed_f64(reader, 8)?);
}
let mut dig_mins = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
dig_mins.push(read_fixed_f64(reader, 8)?);
}
let mut dig_maxs = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
dig_maxs.push(read_fixed_f64(reader, 8)?);
}
let mut prefilterings = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
prefilterings.push(read_fixed_str(reader, 80)?);
}
let mut samples_per_record = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
samples_per_record.push(read_fixed_usize(reader, 8)?);
}
let mut sig_reserved = Vec::with_capacity(num_signals);
for _ in 0..num_signals {
sig_reserved.push(read_fixed_str(reader, 32)?);
}
let mut signals = Vec::with_capacity(num_signals);
for i in 0..num_signals {
signals.push(SignalHeader {
label: labels[i].clone(),
transducer: transducers[i].clone(),
physical_dimension: phys_dims[i].clone(),
physical_min: phys_mins[i],
physical_max: phys_maxs[i],
digital_min: dig_mins[i],
digital_max: dig_maxs[i],
prefiltering: prefilterings[i].clone(),
samples_per_record: samples_per_record[i],
reserved: sig_reserved[i].clone(),
});
}
let max_spr = signals.iter()
.filter(|s| !is_annotation_channel(&s.label))
.map(|s| s.samples_per_record)
.max()
.unwrap_or(1);
let sample_rate = if record_duration > 0.0 {
max_spr as f32 / record_duration as f32
} else {
max_spr as f32
};
Ok(EdfHeader {
version,
patient_id,
recording_id,
start_date,
start_time,
header_bytes,
reserved,
num_records,
record_duration,
num_signals,
signals,
sample_rate,
is_edfplus,
})
}
fn read_data<R: Read + Seek>(reader: &mut R, header: &EdfHeader) -> Result<Array2<f32>> {
reader.seek(SeekFrom::Start(header.header_bytes as u64))?;
let sig_indices: Vec<usize> = (0..header.num_signals)
.filter(|&i| !is_annotation_channel(&header.signals[i].label))
.collect();
if sig_indices.is_empty() {
bail!("No non-annotation channels found in EDF file");
}
let max_spr = sig_indices.iter()
.map(|&i| header.signals[i].samples_per_record)
.max()
.unwrap_or(1);
let n_ch = sig_indices.len();
let n_total = header.num_records * max_spr;
let mut data = Array2::<f32>::zeros((n_ch, n_total));
let record_samples: usize = header.signals.iter()
.map(|s| s.samples_per_record)
.sum();
let mut record_buf = vec![0i16; record_samples];
let mut byte_buf = vec![0u8; record_samples * 2];
for rec in 0..header.num_records {
reader.read_exact(&mut byte_buf)?;
for (i, chunk) in byte_buf.chunks_exact(2).enumerate() {
record_buf[i] = i16::from_le_bytes([chunk[0], chunk[1]]);
}
let mut offset = 0;
let mut out_ch = 0;
for sig_idx in 0..header.num_signals {
let spr = header.signals[sig_idx].samples_per_record;
if sig_indices.contains(&sig_idx) {
let sig = &header.signals[sig_idx];
let cal = sig.cal();
let off = sig.offset();
let unit = sig.unit_scale();
let dst_start = rec * max_spr;
if spr == max_spr {
for j in 0..spr {
let digital = record_buf[offset + j] as f64;
let physical = (digital * cal + off) * unit;
data[[out_ch, dst_start + j]] = physical as f32;
}
} else {
for j in 0..max_spr {
let src_j = (j * spr) / max_spr;
let digital = record_buf[offset + src_j] as f64;
let physical = (digital * cal + off) * unit;
data[[out_ch, dst_start + j]] = physical as f32;
}
}
out_ch += 1;
}
offset += spr;
}
}
Ok(data)
}
fn read_annotations<R: Read + Seek>(reader: &mut R, header: &EdfHeader) -> Result<Vec<EdfAnnotation>> {
reader.seek(SeekFrom::Start(header.header_bytes as u64))?;
let tal_indices: Vec<usize> = (0..header.num_signals)
.filter(|&i| is_annotation_channel(&header.signals[i].label))
.collect();
if tal_indices.is_empty() {
return Ok(vec![]);
}
let record_samples: usize = header.signals.iter()
.map(|s| s.samples_per_record)
.sum();
let record_bytes = record_samples * 2;
let mut annotations = Vec::new();
let mut record_buf = vec![0u8; record_bytes];
for _rec in 0..header.num_records {
reader.read_exact(&mut record_buf)?;
for &tal_idx in &tal_indices {
let mut byte_offset = 0;
for i in 0..tal_idx {
byte_offset += header.signals[i].samples_per_record * 2;
}
let tal_bytes = header.signals[tal_idx].samples_per_record * 2;
let tal_data = &record_buf[byte_offset..byte_offset + tal_bytes];
let tal_str = String::from_utf8_lossy(tal_data);
for entry in tal_str.split('\x00') {
if entry.is_empty() { continue; }
parse_tal_entry(entry, &mut annotations);
}
}
}
Ok(annotations)
}
fn parse_tal_entry(entry: &str, annotations: &mut Vec<EdfAnnotation>) {
let parts: Vec<&str> = entry.split('\x14').collect();
if parts.is_empty() { return; }
let time_part = parts[0];
if time_part.is_empty() { return; }
let (onset_str, dur_str) = if let Some(pos) = time_part.find('\x15') {
(&time_part[..pos], &time_part[pos+1..])
} else {
(time_part, "")
};
let onset = match onset_str.replace('+', "").trim().parse::<f64>() {
Ok(v) => v,
Err(_) => return,
};
let duration = if dur_str.is_empty() {
0.0
} else {
dur_str.parse::<f64>().unwrap_or(0.0)
};
for &desc in &parts[1..] {
if desc.is_empty() { continue; }
annotations.push(EdfAnnotation {
onset,
duration,
description: desc.to_string(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signal_header_cal_offset() {
let sig = SignalHeader {
label: "EEG FP1".into(),
transducer: String::new(),
physical_dimension: "uV".into(),
physical_min: -3200.0,
physical_max: 3200.0,
digital_min: -32768.0,
digital_max: 32767.0,
prefiltering: String::new(),
samples_per_record: 256,
reserved: String::new(),
};
let cal = sig.cal();
approx::assert_abs_diff_eq!(cal, 6400.0 / 65535.0, epsilon = 1e-6);
}
#[test]
fn test_unit_scale() {
let mut sig = SignalHeader {
label: String::new(), transducer: String::new(),
physical_dimension: "uV".into(),
physical_min: 0.0, physical_max: 0.0,
digital_min: 0.0, digital_max: 0.0,
prefiltering: String::new(), samples_per_record: 0,
reserved: String::new(),
};
assert_eq!(sig.unit_scale(), 1e-6);
sig.physical_dimension = "mV".into();
assert_eq!(sig.unit_scale(), 1e-3);
sig.physical_dimension = "V".into();
assert_eq!(sig.unit_scale(), 1.0);
}
#[test]
fn test_annotation_channel_detection() {
assert!(is_annotation_channel("EDF Annotations"));
assert!(is_annotation_channel("EDF Annotations "));
assert!(!is_annotation_channel("EEG FP1-REF"));
}
#[test]
fn test_parse_tal_entry() {
let mut anns = Vec::new();
parse_tal_entry("+0.0\x15\x14", &mut anns);
assert_eq!(anns.len(), 0);
parse_tal_entry("+1.5\x152.0\x14Seizure onset\x14", &mut anns);
assert_eq!(anns.len(), 1);
approx::assert_abs_diff_eq!(anns[0].onset, 1.5, epsilon = 1e-9);
approx::assert_abs_diff_eq!(anns[0].duration, 2.0, epsilon = 1e-9);
assert_eq!(anns[0].description, "Seizure onset");
}
}