Skip to main content

open_eeg_codec_standard/
edf.rs

1//! Minimal pure-Rust EDF (European Data Format) reader.
2//!
3//! This is a deliberately small, dependency-free reader: just enough of
4//! the EDF spec to pull the **digital** (raw int16 ADC) samples out of a
5//! `.edf` file so the OpenECS benchmark CLI can grade real recordings instead
6//! of only the built-in synthetic fixture. The OpenECS codec operates on the
7//! integer sample domain, so we return the digital samples verbatim as
8//! `i64` and never apply the physical-units affine scaling.
9//!
10//! ## EDF layout (the subset we parse)
11//!
12//! A 256-byte ASCII main header, then `ns` × 256-byte signal headers laid
13//! out **field-by-field across all signals** (all labels, then all
14//! transducers, …), then the data records. Each data record holds, for
15//! each signal in order, `n_samples_per_record` little-endian `int16`
16//! samples. The per-signal sampling rate is
17//! `n_samples_per_record / record_duration_sec`.
18//!
19//! Channels labelled `"EDF Annotations"` (the EDF+ timestamp/event track)
20//! are not signal data and are dropped.
21//!
22//! ## Uniform-rate policy
23//!
24//! EDF permits a different rate per signal. OpenECS grades a single `fs`, so
25//! this reader **requires the kept signals to share one rate**: it takes
26//! the first kept (non-annotation) signal's `fs` as the reference and
27//! returns an [`std::io::Error`] if any other kept signal disagrees. This
28//! is the simplest contract that never silently mixes rates.
29//!
30//! ## Robustness
31//!
32//! Every read is bounds-checked. A short or truncated file — header cut
33//! off, a field too short to hold its ASCII number, a data section
34//! smaller than the declared record count — yields an
35//! [`std::io::Error`], never a panic.
36
37use std::io::{self, Error, ErrorKind, Read};
38use std::path::Path;
39
40/// Decoded EDF signal: the digital (raw integer ADC) samples, one channel
41/// per kept signal, plus the shared sampling rate and the channel labels.
42#[derive(Clone, Debug)]
43pub struct EdfSignal {
44    /// Shared sampling rate in Hz (all kept channels agree on this).
45    pub fs: f64,
46    /// Raw digital int16 samples, widened to `i64`, one `Vec` per channel.
47    pub channels: Vec<Vec<i64>>,
48    /// Trimmed signal labels, parallel to `channels`.
49    pub labels: Vec<String>,
50}
51
52/// Size in bytes of the fixed main header and of each signal header.
53const HEADER_BLOCK: usize = 256;
54/// The EDF+ annotation track label (trimmed). These channels are dropped.
55const ANNOTATION_LABEL: &str = "EDF Annotations";
56
57/// Read an ASCII field of `len` bytes starting at `*pos`, advancing `*pos`.
58/// Returns the raw bytes; bounds-checked against `buf`.
59fn take<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> io::Result<&'a [u8]> {
60    let end = pos
61        .checked_add(len)
62        .ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF header offset overflow"))?;
63    if end > buf.len() {
64        return Err(Error::new(
65            ErrorKind::UnexpectedEof,
66            "EDF header truncated: not enough bytes for field",
67        ));
68    }
69    let field = &buf[*pos..end];
70    *pos = end;
71    Ok(field)
72}
73
74/// Trim trailing/leading ASCII whitespace from an EDF field and return it
75/// as a `String`. EDF pads fields with spaces; we treat the field as ASCII
76/// (lossy-decode any stray non-ASCII so we never panic on bad bytes).
77fn field_str(bytes: &[u8]) -> String {
78    String::from_utf8_lossy(bytes).trim().to_string()
79}
80
81/// Parse an EDF ASCII numeric field into an `i64`.
82fn parse_i64(bytes: &[u8], what: &str) -> io::Result<i64> {
83    let s = field_str(bytes);
84    s.parse::<i64>().map_err(|_| {
85        Error::new(
86            ErrorKind::InvalidData,
87            format!("EDF: malformed integer field {what:?}: {s:?}"),
88        )
89    })
90}
91
92/// Parse an EDF ASCII numeric field into an `f64`.
93fn parse_f64(bytes: &[u8], what: &str) -> io::Result<f64> {
94    let s = field_str(bytes);
95    s.parse::<f64>().map_err(|_| {
96        Error::new(
97            ErrorKind::InvalidData,
98            format!("EDF: malformed float field {what:?}: {s:?}"),
99        )
100    })
101}
102
103/// Parse a `.edf` file into an [`EdfSignal`].
104///
105/// Parses the header, reads every data record, deinterleaves the
106/// per-record int16 blocks into one channel per signal, drops
107/// `"EDF Annotations"` channels, and enforces a single shared sampling
108/// rate across the kept channels (see the module docs). Returns an
109/// [`std::io::Error`] on any I/O failure, malformed header, rate
110/// disagreement, or truncation — never panics.
111pub fn read_edf<P: AsRef<Path>>(path: P) -> io::Result<EdfSignal> {
112    let mut buf = Vec::new();
113    std::fs::File::open(path.as_ref())?.read_to_end(&mut buf)?;
114
115    // ── Main header (256 bytes, field-by-field). ──────────────────────
116    if buf.len() < HEADER_BLOCK {
117        return Err(Error::new(
118            ErrorKind::UnexpectedEof,
119            "EDF file shorter than 256-byte main header",
120        ));
121    }
122    let mut pos = 0usize;
123    let _version = take(&buf, &mut pos, 8)?;
124    let _patient = take(&buf, &mut pos, 80)?;
125    let _recording = take(&buf, &mut pos, 80)?;
126    let _startdate = take(&buf, &mut pos, 8)?;
127    let _starttime = take(&buf, &mut pos, 8)?;
128    let header_bytes = parse_i64(take(&buf, &mut pos, 8)?, "header_bytes")?;
129    let _reserved = take(&buf, &mut pos, 44)?;
130    let n_records = parse_i64(take(&buf, &mut pos, 8)?, "n_data_records")?;
131    let record_duration = parse_f64(take(&buf, &mut pos, 8)?, "record_duration_sec")?;
132    let ns = parse_i64(take(&buf, &mut pos, 4)?, "n_signals")?;
133
134    if ns < 0 {
135        return Err(Error::new(
136            ErrorKind::InvalidData,
137            "EDF: negative signal count",
138        ));
139    }
140    let ns = ns as usize;
141    if ns == 0 {
142        return Err(Error::new(
143            ErrorKind::InvalidData,
144            "EDF: file declares zero signals",
145        ));
146    }
147    if record_duration <= 0.0 || !record_duration.is_finite() {
148        return Err(Error::new(
149            ErrorKind::InvalidData,
150            "EDF: non-positive record duration",
151        ));
152    }
153    // EDF allows n_data_records == -1 ("unknown"); we require a concrete
154    // count because we read records up front rather than streaming.
155    if n_records < 0 {
156        return Err(Error::new(
157            ErrorKind::InvalidData,
158            "EDF: unknown/negative data-record count not supported",
159        ));
160    }
161    let n_records = n_records as usize;
162
163    // The declared header length must cover the main header plus one
164    // 256-byte block per signal. Trust the field but sanity-check it.
165    let expected_header = HEADER_BLOCK
166        .checked_add(ns.checked_mul(HEADER_BLOCK).ok_or_else(|| {
167            Error::new(ErrorKind::InvalidData, "EDF: signal count overflows header")
168        })?)
169        .ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: header size overflow"))?;
170    if header_bytes < expected_header as i64 {
171        return Err(Error::new(
172            ErrorKind::InvalidData,
173            "EDF: declared header_bytes smaller than ns*256+256",
174        ));
175    }
176    let header_bytes = header_bytes as usize;
177
178    // ── Signal headers: each field is stored as ns contiguous entries. ─
179    // labels(16) transducer(80) phys_dim(8) phys_min(8) phys_max(8)
180    // dig_min(8) dig_max(8) prefilter(80) n_samples(8) reserved(32)
181    let labels_raw = take(&buf, &mut pos, ns * 16)?.to_vec();
182    let _transducer = take(&buf, &mut pos, ns * 80)?;
183    let _phys_dim = take(&buf, &mut pos, ns * 8)?;
184    let _phys_min = take(&buf, &mut pos, ns * 8)?;
185    let _phys_max = take(&buf, &mut pos, ns * 8)?;
186    let _dig_min = take(&buf, &mut pos, ns * 8)?;
187    let _dig_max = take(&buf, &mut pos, ns * 8)?;
188    let _prefilter = take(&buf, &mut pos, ns * 80)?;
189    let nsamp_raw = take(&buf, &mut pos, ns * 8)?.to_vec();
190    let _sig_reserved = take(&buf, &mut pos, ns * 32)?;
191
192    let mut labels = Vec::with_capacity(ns);
193    let mut samples_per_record = Vec::with_capacity(ns);
194    for i in 0..ns {
195        labels.push(field_str(&labels_raw[i * 16..i * 16 + 16]));
196        let n = parse_i64(&nsamp_raw[i * 8..i * 8 + 8], "n_samples_per_record")?;
197        if n < 0 {
198            return Err(Error::new(
199                ErrorKind::InvalidData,
200                "EDF: negative n_samples_per_record",
201            ));
202        }
203        samples_per_record.push(n as usize);
204    }
205
206    // ── Data section: one record = sum_i(samples_per_record[i]) int16. ─
207    let record_samples: usize = samples_per_record
208        .iter()
209        .try_fold(0usize, |acc, &n| acc.checked_add(n))
210        .ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: record sample count overflow"))?;
211    let record_bytes = record_samples
212        .checked_mul(2)
213        .ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: record byte size overflow"))?;
214    let data_bytes = record_bytes
215        .checked_mul(n_records)
216        .ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: data section size overflow"))?;
217
218    // Data starts at the declared header length (which we verified covers
219    // the fixed + signal headers). Some writers pad; honour header_bytes.
220    let data_start = header_bytes;
221    let data_end = data_start
222        .checked_add(data_bytes)
223        .ok_or_else(|| Error::new(ErrorKind::InvalidData, "EDF: data section end overflow"))?;
224    if data_end > buf.len() {
225        return Err(Error::new(
226            ErrorKind::UnexpectedEof,
227            "EDF: data section truncated (fewer bytes than declared records)",
228        ));
229    }
230
231    // Allocate one channel buffer per signal, sized to its full length.
232    let mut channels: Vec<Vec<i64>> = samples_per_record
233        .iter()
234        .map(|&n| Vec::with_capacity(n.saturating_mul(n_records)))
235        .collect();
236
237    // Deinterleave: walk records, and within each record walk signals,
238    // reading samples_per_record[i] little-endian int16 into channel i.
239    let mut cursor = data_start;
240    for _rec in 0..n_records {
241        for (sig, &n) in samples_per_record.iter().enumerate() {
242            let chan = &mut channels[sig];
243            for _ in 0..n {
244                // cursor + 2 <= data_end <= buf.len() by construction, but
245                // index defensively so a logic slip can never panic.
246                let lo = buf[cursor];
247                let hi = buf[cursor + 1];
248                let val = i16::from_le_bytes([lo, hi]) as i64;
249                chan.push(val);
250                cursor += 2;
251            }
252        }
253    }
254
255    // ── Drop annotation channels, then enforce a single shared rate. ──
256    let mut out_channels = Vec::new();
257    let mut out_labels = Vec::new();
258    let mut ref_fs: Option<f64> = None;
259
260    for i in 0..ns {
261        if labels[i] == ANNOTATION_LABEL {
262            continue;
263        }
264        let fs = samples_per_record[i] as f64 / record_duration;
265        match ref_fs {
266            None => ref_fs = Some(fs),
267            Some(r) => {
268                if (fs - r).abs() > 1e-9 {
269                    return Err(Error::new(
270                        ErrorKind::InvalidData,
271                        format!(
272                            "EDF: non-uniform sampling rates ({r} Hz vs {fs} Hz on \
273                             {label:?}); OpenECS requires one shared fs",
274                            label = labels[i]
275                        ),
276                    ));
277                }
278            }
279        }
280        out_channels.push(std::mem::take(&mut channels[i]));
281        out_labels.push(labels[i].clone());
282    }
283
284    let fs = ref_fs.ok_or_else(|| {
285        Error::new(
286            ErrorKind::InvalidData,
287            "EDF: no signal channels (all dropped as annotations)",
288        )
289    })?;
290
291    Ok(EdfSignal {
292        fs,
293        channels: out_channels,
294        labels: out_labels,
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use std::io::Write;
302
303    /// Write an ASCII value left-justified into a fixed-width field of
304    /// `width` bytes, space-padded — exactly how EDF stores header fields.
305    fn ascii_field(out: &mut Vec<u8>, value: &str, width: usize) {
306        let bytes = value.as_bytes();
307        assert!(bytes.len() <= width, "test field {value:?} exceeds width");
308        out.extend_from_slice(bytes);
309        out.extend(std::iter::repeat(b' ').take(width - bytes.len()));
310    }
311
312    /// Build a minimal but spec-valid EDF byte image in memory.
313    ///
314    /// `samples` is one `Vec<i16>` per signal, length = n_records *
315    /// samples_per_record[i]. All signals here share the same
316    /// samples_per_record and record_duration = 1.0, so fs is uniform.
317    fn build_edf(labels: &[&str], samples_per_record: usize, records: &[Vec<i16>]) -> Vec<u8> {
318        let ns = labels.len();
319        let n_records = if ns == 0 { 0 } else { records.len() / ns };
320        let header_bytes = HEADER_BLOCK + ns * HEADER_BLOCK;
321
322        let mut buf = Vec::new();
323        // Main header.
324        ascii_field(&mut buf, "0", 8); // version
325        ascii_field(&mut buf, "X X X X", 80); // patient
326        ascii_field(&mut buf, "Startdate", 80); // recording
327        ascii_field(&mut buf, "01.01.26", 8); // startdate
328        ascii_field(&mut buf, "00.00.00", 8); // starttime
329        ascii_field(&mut buf, &header_bytes.to_string(), 8); // header bytes
330        ascii_field(&mut buf, "", 44); // reserved
331        ascii_field(&mut buf, &n_records.to_string(), 8); // n_data_records
332        ascii_field(&mut buf, "1", 8); // record_duration_sec = 1.0
333        ascii_field(&mut buf, &ns.to_string(), 4); // n_signals
334
335        // Signal headers, field-by-field across all signals.
336        for &l in labels {
337            ascii_field(&mut buf, l, 16);
338        }
339        for _ in 0..ns {
340            ascii_field(&mut buf, "AgAgCl", 80); // transducer
341        }
342        for _ in 0..ns {
343            ascii_field(&mut buf, "uV", 8); // phys_dim
344        }
345        for _ in 0..ns {
346            ascii_field(&mut buf, "-32768", 8); // phys_min
347        }
348        for _ in 0..ns {
349            ascii_field(&mut buf, "32767", 8); // phys_max
350        }
351        for _ in 0..ns {
352            ascii_field(&mut buf, "-32768", 8); // dig_min
353        }
354        for _ in 0..ns {
355            ascii_field(&mut buf, "32767", 8); // dig_max
356        }
357        for _ in 0..ns {
358            ascii_field(&mut buf, "HP:0.1Hz", 80); // prefilter
359        }
360        for _ in 0..ns {
361            ascii_field(&mut buf, &samples_per_record.to_string(), 8); // n_samples
362        }
363        for _ in 0..ns {
364            ascii_field(&mut buf, "", 32); // signal reserved
365        }
366        assert_eq!(buf.len(), header_bytes, "header block size mismatch");
367
368        // Data records: records[] is already in (record, signal) order;
369        // each entry is the int16 block for that (record, signal).
370        for block in records {
371            for &s in block {
372                buf.extend_from_slice(&s.to_le_bytes());
373            }
374        }
375        buf
376    }
377
378    /// Write `bytes` to a uniquely-named tempfile under the system temp dir.
379    fn write_tempfile(tag: &str, bytes: &[u8]) -> std::path::PathBuf {
380        // Unique enough for a test: pid + a monotonic-ish nanos stamp + tag.
381        let nanos = std::time::SystemTime::now()
382            .duration_since(std::time::UNIX_EPOCH)
383            .map(|d| d.as_nanos())
384            .unwrap_or(0);
385        let name = format!("lqs_edf_test_{}_{}_{}.edf", std::process::id(), nanos, tag);
386        let path = std::env::temp_dir().join(name);
387        let mut f = std::fs::File::create(&path).expect("create tempfile");
388        f.write_all(bytes).expect("write tempfile");
389        f.flush().expect("flush tempfile");
390        path
391    }
392
393    #[test]
394    fn roundtrip_two_signals_two_records() {
395        // 2 signals, samples_per_record = 3, 2 records, record_duration 1s
396        // -> fs = 3 Hz, each channel 6 samples.
397        // Data order per spec: record 0 [sig0, sig1], record 1 [sig0, sig1].
398        let sig0_r0: Vec<i16> = vec![1, 2, 3];
399        let sig1_r0: Vec<i16> = vec![-1, -2, -3];
400        let sig0_r1: Vec<i16> = vec![100, 200, 300];
401        let sig1_r1: Vec<i16> = vec![-100, i16::MIN, i16::MAX];
402        let records = vec![
403            sig0_r0.clone(),
404            sig1_r0.clone(),
405            sig0_r1.clone(),
406            sig1_r1.clone(),
407        ];
408        let bytes = build_edf(&["Fp1", "Fp2"], 3, &records);
409        let path = write_tempfile("rt2x2", &bytes);
410
411        let result = read_edf(&path);
412        // Clean up before asserting so a failure can't leak the tempfile.
413        let _ = std::fs::remove_file(&path);
414        let edf = result.expect("read_edf must succeed on valid fixture");
415
416        assert_eq!(edf.channels.len(), 2, "two kept channels");
417        assert_eq!(edf.labels, vec!["Fp1".to_string(), "Fp2".to_string()]);
418        assert_eq!(edf.fs, 3.0, "fs = samples_per_record / duration");
419
420        // Channel 0 = sig0 across both records; channel 1 = sig1.
421        let expect0: Vec<i64> = sig0_r0
422            .iter()
423            .chain(sig0_r1.iter())
424            .map(|&v| v as i64)
425            .collect();
426        let expect1: Vec<i64> = sig1_r0
427            .iter()
428            .chain(sig1_r1.iter())
429            .map(|&v| v as i64)
430            .collect();
431        assert_eq!(edf.channels[0], expect0, "channel 0 digital samples");
432        assert_eq!(edf.channels[1], expect1, "channel 1 digital samples");
433    }
434
435    #[test]
436    fn drops_annotation_channel() {
437        // 2 signals: one real, one "EDF Annotations" — the annotation
438        // track is dropped, leaving exactly one channel.
439        let real_r0: Vec<i16> = vec![10, 20];
440        let anno_r0: Vec<i16> = vec![0, 0];
441        let real_r1: Vec<i16> = vec![30, 40];
442        let anno_r1: Vec<i16> = vec![0, 0];
443        let records = vec![real_r0, anno_r0, real_r1, anno_r1];
444        let bytes = build_edf(&["C3", "EDF Annotations"], 2, &records);
445        let path = write_tempfile("anno", &bytes);
446
447        let result = read_edf(&path);
448        let _ = std::fs::remove_file(&path);
449        let edf = result.expect("read_edf must succeed");
450
451        assert_eq!(edf.channels.len(), 1, "annotation channel dropped");
452        assert_eq!(edf.labels, vec!["C3".to_string()]);
453        assert_eq!(edf.fs, 2.0);
454        assert_eq!(edf.channels[0], vec![10i64, 20, 30, 40]);
455    }
456
457    #[test]
458    fn truncated_data_is_error_not_panic() {
459        let records = vec![vec![1i16, 2, 3], vec![4, 5, 6]];
460        let mut bytes = build_edf(&["Fz"], 3, &records);
461        // Chop off the last few data bytes to truncate the data section.
462        bytes.truncate(bytes.len() - 5);
463        let path = write_tempfile("trunc", &bytes);
464
465        let result = read_edf(&path);
466        let _ = std::fs::remove_file(&path);
467        assert!(result.is_err(), "truncated data must return io::Error");
468    }
469
470    #[test]
471    fn short_header_is_error_not_panic() {
472        let path = write_tempfile("short", &[b'0'; 10]);
473        let result = read_edf(&path);
474        let _ = std::fs::remove_file(&path);
475        assert!(result.is_err(), "sub-header file must return io::Error");
476    }
477
478    #[test]
479    fn nonuniform_rates_rejected() {
480        // Two real signals with different samples_per_record would yield
481        // different fs. build_edf uses one shared samples_per_record, so we
482        // hand-build a mismatch here.
483        let ns = 2;
484        let header_bytes = HEADER_BLOCK + ns * HEADER_BLOCK;
485        let mut buf = Vec::new();
486        ascii_field(&mut buf, "0", 8);
487        ascii_field(&mut buf, "X", 80);
488        ascii_field(&mut buf, "R", 80);
489        ascii_field(&mut buf, "01.01.26", 8);
490        ascii_field(&mut buf, "00.00.00", 8);
491        ascii_field(&mut buf, &header_bytes.to_string(), 8);
492        ascii_field(&mut buf, "", 44);
493        ascii_field(&mut buf, "1", 8); // 1 record
494        ascii_field(&mut buf, "1", 8); // duration 1s
495        ascii_field(&mut buf, &ns.to_string(), 4);
496        ascii_field(&mut buf, "A", 16);
497        ascii_field(&mut buf, "B", 16);
498        for _ in 0..ns {
499            ascii_field(&mut buf, "T", 80);
500        }
501        for _ in 0..ns {
502            ascii_field(&mut buf, "uV", 8);
503        }
504        for _ in 0..ns {
505            ascii_field(&mut buf, "-1", 8);
506        }
507        for _ in 0..ns {
508            ascii_field(&mut buf, "1", 8);
509        }
510        for _ in 0..ns {
511            ascii_field(&mut buf, "-1", 8);
512        }
513        for _ in 0..ns {
514            ascii_field(&mut buf, "1", 8);
515        }
516        for _ in 0..ns {
517            ascii_field(&mut buf, "P", 80);
518        }
519        ascii_field(&mut buf, "2", 8); // sig0: 2 samples/record -> 2 Hz
520        ascii_field(&mut buf, "4", 8); // sig1: 4 samples/record -> 4 Hz
521        for _ in 0..ns {
522            ascii_field(&mut buf, "", 32);
523        }
524        // Data: 1 record, sig0 (2 int16) then sig1 (4 int16).
525        for s in [1i16, 2] {
526            buf.extend_from_slice(&s.to_le_bytes());
527        }
528        for s in [3i16, 4, 5, 6] {
529            buf.extend_from_slice(&s.to_le_bytes());
530        }
531        let path = write_tempfile("nonuniform", &buf);
532
533        let result = read_edf(&path);
534        let _ = std::fs::remove_file(&path);
535        assert!(result.is_err(), "non-uniform fs must be rejected");
536    }
537}