use std::io::{self, Error, ErrorKind, Read};
use std::path::Path;
#[derive(Clone, Debug)]
pub struct EdfSignal {
pub fs: f64,
pub channels: Vec<Vec<i64>>,
pub labels: Vec<String>,
}
const HEADER_BLOCK: usize = 256;
const ANNOTATION_LABEL: &str = "EDF Annotations";
fn take<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> io::Result<&'a [u8]> {
let end = pos
.checked_add(len)
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF header offset overflow"))?;
if end > buf.len() {
return Err(Error::new(
ErrorKind::UnexpectedEof,
"EDF header truncated: not enough bytes for field",
));
}
let field = &buf[*pos..end];
*pos = end;
Ok(field)
}
fn field_str(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).trim().to_string()
}
fn parse_i64(bytes: &[u8], what: &str) -> io::Result<i64> {
let s = field_str(bytes);
s.parse::<i64>().map_err(|_| {
Error::new(
ErrorKind::InvalidData,
format!("EDF: malformed integer field {what:?}: {s:?}"),
)
})
}
fn parse_f64(bytes: &[u8], what: &str) -> io::Result<f64> {
let s = field_str(bytes);
s.parse::<f64>().map_err(|_| {
Error::new(
ErrorKind::InvalidData,
format!("EDF: malformed float field {what:?}: {s:?}"),
)
})
}
pub fn read_edf<P: AsRef<Path>>(path: P) -> io::Result<EdfSignal> {
let mut buf = Vec::new();
std::fs::File::open(path.as_ref())?.read_to_end(&mut buf)?;
if buf.len() < HEADER_BLOCK {
return Err(Error::new(
ErrorKind::UnexpectedEof,
"EDF file shorter than 256-byte main header",
));
}
let mut pos = 0usize;
let _version = take(&buf, &mut pos, 8)?;
let _patient = take(&buf, &mut pos, 80)?;
let _recording = take(&buf, &mut pos, 80)?;
let _startdate = take(&buf, &mut pos, 8)?;
let _starttime = take(&buf, &mut pos, 8)?;
let header_bytes = parse_i64(take(&buf, &mut pos, 8)?, "header_bytes")?;
let _reserved = take(&buf, &mut pos, 44)?;
let n_records = parse_i64(take(&buf, &mut pos, 8)?, "n_data_records")?;
let record_duration = parse_f64(take(&buf, &mut pos, 8)?, "record_duration_sec")?;
let ns = parse_i64(take(&buf, &mut pos, 4)?, "n_signals")?;
if ns < 0 {
return Err(Error::new(
ErrorKind::InvalidData,
"EDF: negative signal count",
));
}
let ns = ns as usize;
if ns == 0 {
return Err(Error::new(
ErrorKind::InvalidData,
"EDF: file declares zero signals",
));
}
if record_duration <= 0.0 || !record_duration.is_finite() {
return Err(Error::new(
ErrorKind::InvalidData,
"EDF: non-positive record duration",
));
}
if n_records < 0 {
return Err(Error::new(
ErrorKind::InvalidData,
"EDF: unknown/negative data-record count not supported",
));
}
let n_records = n_records as usize;
let expected_header = HEADER_BLOCK
.checked_add(ns.checked_mul(HEADER_BLOCK).ok_or_else(|| {
Error::new(ErrorKind::InvalidData, "EDF: signal count overflows header")
})?)
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: header size overflow"))?;
if header_bytes < expected_header as i64 {
return Err(Error::new(
ErrorKind::InvalidData,
"EDF: declared header_bytes smaller than ns*256+256",
));
}
let header_bytes = header_bytes as usize;
let labels_raw = take(&buf, &mut pos, ns * 16)?.to_vec();
let _transducer = take(&buf, &mut pos, ns * 80)?;
let _phys_dim = take(&buf, &mut pos, ns * 8)?;
let _phys_min = take(&buf, &mut pos, ns * 8)?;
let _phys_max = take(&buf, &mut pos, ns * 8)?;
let _dig_min = take(&buf, &mut pos, ns * 8)?;
let _dig_max = take(&buf, &mut pos, ns * 8)?;
let _prefilter = take(&buf, &mut pos, ns * 80)?;
let nsamp_raw = take(&buf, &mut pos, ns * 8)?.to_vec();
let _sig_reserved = take(&buf, &mut pos, ns * 32)?;
let mut labels = Vec::with_capacity(ns);
let mut samples_per_record = Vec::with_capacity(ns);
for i in 0..ns {
labels.push(field_str(&labels_raw[i * 16..i * 16 + 16]));
let n = parse_i64(&nsamp_raw[i * 8..i * 8 + 8], "n_samples_per_record")?;
if n < 0 {
return Err(Error::new(
ErrorKind::InvalidData,
"EDF: negative n_samples_per_record",
));
}
samples_per_record.push(n as usize);
}
let record_samples: usize = samples_per_record
.iter()
.try_fold(0usize, |acc, &n| acc.checked_add(n))
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: record sample count overflow"))?;
let record_bytes = record_samples
.checked_mul(2)
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: record byte size overflow"))?;
let data_bytes = record_bytes
.checked_mul(n_records)
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: data section size overflow"))?;
let data_start = header_bytes;
let data_end = data_start
.checked_add(data_bytes)
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: data section end overflow"))?;
if data_end > buf.len() {
return Err(Error::new(
ErrorKind::UnexpectedEof,
"EDF: data section truncated (fewer bytes than declared records)",
));
}
let mut channels: Vec<Vec<i64>> = samples_per_record
.iter()
.map(|&n| Vec::with_capacity(n.saturating_mul(n_records)))
.collect();
let mut cursor = data_start;
for _rec in 0..n_records {
for (sig, &n) in samples_per_record.iter().enumerate() {
let chan = &mut channels[sig];
for _ in 0..n {
let lo = buf[cursor];
let hi = buf[cursor + 1];
let val = i16::from_le_bytes([lo, hi]) as i64;
chan.push(val);
cursor += 2;
}
}
}
let mut out_channels = Vec::new();
let mut out_labels = Vec::new();
let mut ref_fs: Option<f64> = None;
for i in 0..ns {
if labels[i] == ANNOTATION_LABEL {
continue;
}
let fs = samples_per_record[i] as f64 / record_duration;
match ref_fs {
None => ref_fs = Some(fs),
Some(r) => {
if (fs - r).abs() > 1e-9 {
return Err(Error::new(
ErrorKind::InvalidData,
format!(
"EDF: non-uniform sampling rates ({r} Hz vs {fs} Hz on \
{label:?}); OpenECS requires one shared fs",
label = labels[i]
),
));
}
}
}
out_channels.push(std::mem::take(&mut channels[i]));
out_labels.push(labels[i].clone());
}
let fs = ref_fs.ok_or_else(|| {
Error::new(
ErrorKind::InvalidData,
"EDF: no signal channels (all dropped as annotations)",
)
})?;
Ok(EdfSignal {
fs,
channels: out_channels,
labels: out_labels,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn ascii_field(out: &mut Vec<u8>, value: &str, width: usize) {
let bytes = value.as_bytes();
assert!(bytes.len() <= width, "test field {value:?} exceeds width");
out.extend_from_slice(bytes);
out.extend(std::iter::repeat(b' ').take(width - bytes.len()));
}
fn build_edf(labels: &[&str], samples_per_record: usize, records: &[Vec<i16>]) -> Vec<u8> {
let ns = labels.len();
let n_records = if ns == 0 { 0 } else { records.len() / ns };
let header_bytes = HEADER_BLOCK + ns * HEADER_BLOCK;
let mut buf = Vec::new();
ascii_field(&mut buf, "0", 8); ascii_field(&mut buf, "X X X X", 80); ascii_field(&mut buf, "Startdate", 80); ascii_field(&mut buf, "01.01.26", 8); ascii_field(&mut buf, "00.00.00", 8); ascii_field(&mut buf, &header_bytes.to_string(), 8); ascii_field(&mut buf, "", 44); ascii_field(&mut buf, &n_records.to_string(), 8); ascii_field(&mut buf, "1", 8); ascii_field(&mut buf, &ns.to_string(), 4);
for &l in labels {
ascii_field(&mut buf, l, 16);
}
for _ in 0..ns {
ascii_field(&mut buf, "AgAgCl", 80); }
for _ in 0..ns {
ascii_field(&mut buf, "uV", 8); }
for _ in 0..ns {
ascii_field(&mut buf, "-32768", 8); }
for _ in 0..ns {
ascii_field(&mut buf, "32767", 8); }
for _ in 0..ns {
ascii_field(&mut buf, "-32768", 8); }
for _ in 0..ns {
ascii_field(&mut buf, "32767", 8); }
for _ in 0..ns {
ascii_field(&mut buf, "HP:0.1Hz", 80); }
for _ in 0..ns {
ascii_field(&mut buf, &samples_per_record.to_string(), 8); }
for _ in 0..ns {
ascii_field(&mut buf, "", 32); }
assert_eq!(buf.len(), header_bytes, "header block size mismatch");
for block in records {
for &s in block {
buf.extend_from_slice(&s.to_le_bytes());
}
}
buf
}
fn write_tempfile(tag: &str, bytes: &[u8]) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let name = format!("lqs_edf_test_{}_{}_{}.edf", std::process::id(), nanos, tag);
let path = std::env::temp_dir().join(name);
let mut f = std::fs::File::create(&path).expect("create tempfile");
f.write_all(bytes).expect("write tempfile");
f.flush().expect("flush tempfile");
path
}
#[test]
fn roundtrip_two_signals_two_records() {
let sig0_r0: Vec<i16> = vec![1, 2, 3];
let sig1_r0: Vec<i16> = vec![-1, -2, -3];
let sig0_r1: Vec<i16> = vec![100, 200, 300];
let sig1_r1: Vec<i16> = vec![-100, i16::MIN, i16::MAX];
let records = vec![
sig0_r0.clone(),
sig1_r0.clone(),
sig0_r1.clone(),
sig1_r1.clone(),
];
let bytes = build_edf(&["Fp1", "Fp2"], 3, &records);
let path = write_tempfile("rt2x2", &bytes);
let result = read_edf(&path);
let _ = std::fs::remove_file(&path);
let edf = result.expect("read_edf must succeed on valid fixture");
assert_eq!(edf.channels.len(), 2, "two kept channels");
assert_eq!(edf.labels, vec!["Fp1".to_string(), "Fp2".to_string()]);
assert_eq!(edf.fs, 3.0, "fs = samples_per_record / duration");
let expect0: Vec<i64> = sig0_r0
.iter()
.chain(sig0_r1.iter())
.map(|&v| v as i64)
.collect();
let expect1: Vec<i64> = sig1_r0
.iter()
.chain(sig1_r1.iter())
.map(|&v| v as i64)
.collect();
assert_eq!(edf.channels[0], expect0, "channel 0 digital samples");
assert_eq!(edf.channels[1], expect1, "channel 1 digital samples");
}
#[test]
fn drops_annotation_channel() {
let real_r0: Vec<i16> = vec![10, 20];
let anno_r0: Vec<i16> = vec![0, 0];
let real_r1: Vec<i16> = vec![30, 40];
let anno_r1: Vec<i16> = vec![0, 0];
let records = vec![real_r0, anno_r0, real_r1, anno_r1];
let bytes = build_edf(&["C3", "EDF Annotations"], 2, &records);
let path = write_tempfile("anno", &bytes);
let result = read_edf(&path);
let _ = std::fs::remove_file(&path);
let edf = result.expect("read_edf must succeed");
assert_eq!(edf.channels.len(), 1, "annotation channel dropped");
assert_eq!(edf.labels, vec!["C3".to_string()]);
assert_eq!(edf.fs, 2.0);
assert_eq!(edf.channels[0], vec![10i64, 20, 30, 40]);
}
#[test]
fn truncated_data_is_error_not_panic() {
let records = vec![vec![1i16, 2, 3], vec![4, 5, 6]];
let mut bytes = build_edf(&["Fz"], 3, &records);
bytes.truncate(bytes.len() - 5);
let path = write_tempfile("trunc", &bytes);
let result = read_edf(&path);
let _ = std::fs::remove_file(&path);
assert!(result.is_err(), "truncated data must return io::Error");
}
#[test]
fn short_header_is_error_not_panic() {
let path = write_tempfile("short", &[b'0'; 10]);
let result = read_edf(&path);
let _ = std::fs::remove_file(&path);
assert!(result.is_err(), "sub-header file must return io::Error");
}
#[test]
fn nonuniform_rates_rejected() {
let ns = 2;
let header_bytes = HEADER_BLOCK + ns * HEADER_BLOCK;
let mut buf = Vec::new();
ascii_field(&mut buf, "0", 8);
ascii_field(&mut buf, "X", 80);
ascii_field(&mut buf, "R", 80);
ascii_field(&mut buf, "01.01.26", 8);
ascii_field(&mut buf, "00.00.00", 8);
ascii_field(&mut buf, &header_bytes.to_string(), 8);
ascii_field(&mut buf, "", 44);
ascii_field(&mut buf, "1", 8); ascii_field(&mut buf, "1", 8); ascii_field(&mut buf, &ns.to_string(), 4);
ascii_field(&mut buf, "A", 16);
ascii_field(&mut buf, "B", 16);
for _ in 0..ns {
ascii_field(&mut buf, "T", 80);
}
for _ in 0..ns {
ascii_field(&mut buf, "uV", 8);
}
for _ in 0..ns {
ascii_field(&mut buf, "-1", 8);
}
for _ in 0..ns {
ascii_field(&mut buf, "1", 8);
}
for _ in 0..ns {
ascii_field(&mut buf, "-1", 8);
}
for _ in 0..ns {
ascii_field(&mut buf, "1", 8);
}
for _ in 0..ns {
ascii_field(&mut buf, "P", 80);
}
ascii_field(&mut buf, "2", 8); ascii_field(&mut buf, "4", 8); for _ in 0..ns {
ascii_field(&mut buf, "", 32);
}
for s in [1i16, 2] {
buf.extend_from_slice(&s.to_le_bytes());
}
for s in [3i16, 4, 5, 6] {
buf.extend_from_slice(&s.to_le_bytes());
}
let path = write_tempfile("nonuniform", &buf);
let result = read_edf(&path);
let _ = std::fs::remove_file(&path);
assert!(result.is_err(), "non-uniform fs must be rejected");
}
}