use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use chrono::{NaiveDate, NaiveTime, Datelike, Timelike};
use crate::types::SignalParam;
use crate::error::{EdfError, Result};
use crate::EDFLIB_TIME_DIMENSION;
const EDFLIB_MAX_ANNOTATION_CHANNELS: usize = 64;
const EDFLIB_ANNOTATION_BYTES: usize = 120;
const EDFLIB_WRITE_MAX_ANNOTATION_LEN: usize = 40;
pub struct EdfWriter {
file: BufWriter<File>,
signals: Vec<SignalParam>,
start_date: NaiveDate,
start_time: NaiveTime,
datarecord_duration: i64,
samples_written: usize,
header_written: bool,
patient_code: String,
sex: String,
birthdate: String,
patient_name: String,
patient_additional: String,
admin_code: String,
technician: String,
equipment: String,
recording_additional: String,
annotations: Vec<crate::types::Annotation>,
starttime_subsecond: i64,
nr_annot_chns: usize, }
impl EdfWriter {
pub fn create<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = File::create(&path)
.map_err(|e| EdfError::FileNotFound(format!("{}: {}", path.as_ref().display(), e)))?;
let writer = BufWriter::new(file);
let default_date = NaiveDate::from_ymd_opt(1985, 1, 1).unwrap();
let default_time = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
Ok(EdfWriter {
file: writer,
signals: Vec::new(),
start_date: default_date,
start_time: default_time,
datarecord_duration: EDFLIB_TIME_DIMENSION, samples_written: 0,
header_written: false,
patient_code: "X".to_string(),
sex: "X".to_string(),
birthdate: "X".to_string(),
patient_name: "X".to_string(),
patient_additional: "X".to_string(),
admin_code: "X".to_string(),
technician: "X".to_string(),
equipment: "X".to_string(),
recording_additional: "X".to_string(),
annotations: Vec::new(),
starttime_subsecond: 0,
nr_annot_chns: 1, })
}
pub fn add_signal(&mut self, signal: SignalParam) -> Result<()> {
if self.header_written {
return Err(EdfError::InvalidFormat("Cannot add signal after writing header".to_string()));
}
if signal.physical_min == signal.physical_max {
return Err(EdfError::PhysicalMinEqualsMax);
}
if signal.digital_min == signal.digital_max {
return Err(EdfError::DigitalMinEqualsMax);
}
self.signals.push(signal);
Ok(())
}
pub fn set_patient_info(&mut self, code: &str, sex: &str, birthdate: &str, name: &str) -> Result<()> {
if self.header_written {
return Err(EdfError::InvalidFormat("Cannot modify patient info after writing header".to_string()));
}
self.patient_code = code.to_string();
self.sex = sex.to_string();
self.birthdate = birthdate.to_string();
self.patient_name = name.to_string();
Ok(())
}
pub fn set_datarecord_duration(&mut self, duration_seconds: f64) -> Result<()> {
if self.header_written {
return Err(EdfError::InvalidFormat("Cannot modify data record duration after writing header".to_string()));
}
if duration_seconds <= 0.0 || duration_seconds > 3600.0 {
return Err(EdfError::InvalidFormat("Data record duration must be between 0 and 3600 seconds".to_string()));
}
self.datarecord_duration = (duration_seconds * EDFLIB_TIME_DIMENSION as f64) as i64;
Ok(())
}
fn write_header(&mut self, total_datarecords: i64) -> Result<()> {
if self.header_written {
return Ok(());
}
let mut annotation_signals = Vec::new();
let annotation_bytes_per_record = EDFLIB_ANNOTATION_BYTES; let annotation_samples_per_record = annotation_bytes_per_record / 2;
for _ in 0..self.nr_annot_chns {
annotation_signals.push(SignalParam {
label: "EDF Annotations ".to_string(), samples_in_file: total_datarecords * annotation_samples_per_record as i64,
physical_max: 1.0,
physical_min: -1.0,
digital_max: 32767,
digital_min: -32768,
samples_per_record: annotation_samples_per_record as i32,
physical_dimension: "".to_string(),
prefilter: "".to_string(),
transducer: "".to_string(),
});
}
let total_signals = self.signals.len() + self.nr_annot_chns;
let header_size = (total_signals + 1) * 256;
let mut main_header = vec![0u8; 256];
main_header[0..8].copy_from_slice(b"0 ");
let patient_field = format!("{} {} {} {} {}",
self.patient_code, self.sex, self.birthdate, self.patient_name, self.patient_additional);
let patient_bytes = patient_field.as_bytes();
let patient_len = patient_bytes.len().min(80);
main_header[8..8+patient_len].copy_from_slice(&patient_bytes[..patient_len]);
let recording_field = format!("Startdate {} {} {} {} {}",
self.start_date.format("%d-%b-%Y"), self.admin_code, self.technician,
self.equipment, self.recording_additional);
let recording_bytes = recording_field.as_bytes();
let recording_len = recording_bytes.len().min(80);
main_header[88..88+recording_len].copy_from_slice(&recording_bytes[..recording_len]);
let date_str = format!("{:02}.{:02}.{:02}",
self.start_date.day(), self.start_date.month(), self.start_date.year() % 100);
main_header[168..176].copy_from_slice(date_str.as_bytes());
let time_str = format!("{:02}.{:02}.{:02}",
self.start_time.hour(), self.start_time.minute(), self.start_time.second());
main_header[176..184].copy_from_slice(time_str.as_bytes());
let header_size_str = format!("{:<8}", header_size);
main_header[184..192].copy_from_slice(header_size_str.as_bytes());
main_header[192..197].copy_from_slice(b"EDF+C");
let datarecords_str = format!("{:<8}", total_datarecords);
main_header[236..244].copy_from_slice(datarecords_str.as_bytes());
let duration_seconds = self.datarecord_duration as f64 / EDFLIB_TIME_DIMENSION as f64;
let duration_str = format!("{:<8}", duration_seconds);
main_header[244..252].copy_from_slice(duration_str.as_bytes());
let signals_str = format!("{:<4}", total_signals);
main_header[252..256].copy_from_slice(signals_str.as_bytes());
self.file.write_all(&main_header)?;
self.write_signal_headers_with_annotations(&annotation_signals)?;
self.header_written = true;
Ok(())
}
pub fn write_samples(&mut self, samples: &[Vec<f64>]) -> Result<()> {
if samples.len() != self.signals.len() {
return Err(EdfError::InvalidFormat("Sample count must match signal count".to_string()));
}
for (i, signal_samples) in samples.iter().enumerate() {
let expected_samples = self.signals[i].samples_per_record as usize;
if signal_samples.len() != expected_samples {
return Err(EdfError::InvalidFormat(
format!("Signal {} expected {} samples per record, got {}",
i, expected_samples, signal_samples.len())
));
}
}
if !self.header_written {
self.write_header(1)?; }
for signal_idx in 0..self.signals.len() {
let signal = &self.signals[signal_idx];
let signal_samples = &samples[signal_idx];
for &physical_value in signal_samples {
let digital_value = signal.to_digital(physical_value);
let clamped_value = digital_value
.max(signal.digital_min)
.min(signal.digital_max);
let bytes = (clamped_value as i16).to_le_bytes();
self.file.write_all(&bytes)?;
}
}
for channel_idx in 0..self.nr_annot_chns {
let annotation_data = self.generate_annotation_tal_for_channel(self.samples_written, channel_idx)?;
self.file.write_all(&annotation_data)?;
}
self.samples_written += 1;
Ok(())
}
pub fn finalize(mut self) -> Result<()> {
if self.header_written && self.samples_written > 1 {
use std::io::{Seek, SeekFrom};
self.file.flush()?;
let mut file = self.file.into_inner().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
file.seek(SeekFrom::Start(236))?;
let datarecords_str = format!("{:<8}", self.samples_written);
file.write_all(datarecords_str.as_bytes())?;
file.flush()?;
} else {
self.file.flush()?;
}
Ok(())
}
pub fn add_annotation(&mut self, onset_seconds: f64, duration_seconds: Option<f64>, description: &str) -> Result<()> {
if onset_seconds < 0.0 {
return Err(EdfError::InvalidFormat("Annotation onset cannot be negative".to_string()));
}
if let Some(duration) = duration_seconds {
if duration < 0.0 {
return Err(EdfError::InvalidFormat("Annotation duration cannot be negative".to_string()));
}
}
if description.is_empty() {
return Err(EdfError::InvalidFormat("Annotation description cannot be empty".to_string()));
}
if description.len() > 512 {
return Err(EdfError::InvalidFormat("Annotation description too long (max 512 characters)".to_string()));
}
let onset = (onset_seconds * EDFLIB_TIME_DIMENSION as f64) as i64;
let duration = duration_seconds
.map(|d| (d * EDFLIB_TIME_DIMENSION as f64) as i64)
.unwrap_or(-1);
let annotation = crate::types::Annotation {
onset,
duration,
description: description.to_string(),
};
self.annotations.push(annotation);
Ok(())
}
pub fn annotation_count(&self) -> usize {
self.annotations.len()
}
fn generate_annotation_tal_for_channel(&self, data_record_index: usize, channel_idx: usize) -> Result<Vec<u8>> {
let mut tal_data = Vec::with_capacity(EDFLIB_ANNOTATION_BYTES);
let data_record_time_start = data_record_index as f64 * (self.datarecord_duration as f64 / EDFLIB_TIME_DIMENSION as f64);
let data_record_time_end = (data_record_index + 1) as f64 * (self.datarecord_duration as f64 / EDFLIB_TIME_DIMENSION as f64);
if channel_idx == 0 {
tal_data.push(b'+');
if data_record_index == 0 && self.starttime_subsecond > 0 {
let time_with_subsecond = data_record_time_start + (self.starttime_subsecond as f64 / EDFLIB_TIME_DIMENSION as f64);
let time_str = format!("{:.7}", time_with_subsecond).trim_end_matches('0').trim_end_matches('.').to_string();
tal_data.extend_from_slice(time_str.as_bytes());
} else {
let time_str = if data_record_time_start.fract() == 0.0 {
format!("{}", data_record_time_start as i64)
} else {
format!("{:.6}", data_record_time_start).trim_end_matches('0').trim_end_matches('.').to_string()
};
tal_data.extend_from_slice(time_str.as_bytes());
}
tal_data.push(0x14); tal_data.push(0x14); tal_data.push(0x00); }
let mut record_annotations = Vec::new();
for (annot_idx, annotation) in self.annotations.iter().enumerate() {
let annotation_time = annotation.onset as f64 / EDFLIB_TIME_DIMENSION as f64;
if annotation_time >= data_record_time_start && annotation_time < data_record_time_end {
let target_channel = if self.nr_annot_chns == 1 {
0 } else {
if channel_idx == 0 {
if annot_idx % self.nr_annot_chns == 0 {
0
} else {
continue; }
} else {
if annot_idx % self.nr_annot_chns == channel_idx {
channel_idx
} else {
continue; }
}
};
if target_channel == channel_idx {
record_annotations.push(annotation);
}
}
}
for annotation in record_annotations {
let annotation_time = annotation.onset as f64 / EDFLIB_TIME_DIMENSION as f64;
let time_str = format!("{:.7}", annotation_time).trim_end_matches('0').trim_end_matches('.').to_string();
let mut min_needed_space = 1 + time_str.len() + 2 + 1;
if annotation.duration >= 0 {
let duration_str = format!("{:.7}", annotation.duration as f64 / EDFLIB_TIME_DIMENSION as f64)
.trim_end_matches('0').trim_end_matches('.').to_string();
min_needed_space += 1 + duration_str.len(); }
if tal_data.len() + min_needed_space > EDFLIB_ANNOTATION_BYTES - 2 {
break; }
tal_data.push(b'+');
tal_data.extend_from_slice(time_str.as_bytes());
if annotation.duration >= 0 {
tal_data.push(0x15); let duration_str = format!("{:.7}", annotation.duration as f64 / EDFLIB_TIME_DIMENSION as f64)
.trim_end_matches('0').trim_end_matches('.').to_string();
tal_data.extend_from_slice(duration_str.as_bytes());
}
tal_data.push(0x14);
let description_bytes = annotation.description.as_bytes();
let max_desc_len = EDFLIB_WRITE_MAX_ANNOTATION_LEN.min(
EDFLIB_ANNOTATION_BYTES - tal_data.len() - 2 );
let desc_len = description_bytes.len().min(max_desc_len);
tal_data.extend_from_slice(&description_bytes[..desc_len]);
tal_data.push(0x14); }
tal_data.resize(EDFLIB_ANNOTATION_BYTES, 0x00);
Ok(tal_data)
}
pub fn set_subsecond_starttime(&mut self, subsecond: i64) -> Result<()> {
if self.header_written {
return Err(EdfError::InvalidFormat("Cannot modify subsecond start time after writing header".to_string()));
}
if subsecond < 0 || subsecond >= EDFLIB_TIME_DIMENSION {
return Err(EdfError::InvalidFormat("Subsecond must be between 0 and 9999999".to_string()));
}
self.starttime_subsecond = subsecond;
Ok(())
}
fn write_signal_headers_with_annotations(&mut self, annotation_signals: &[SignalParam]) -> Result<()> {
let mut all_signals = Vec::new();
all_signals.extend_from_slice(&self.signals);
all_signals.extend_from_slice(annotation_signals);
for signal in &all_signals {
let mut field_data = [b' '; 16];
let label_bytes = signal.label.as_bytes();
let len = label_bytes.len().min(16);
field_data[..len].copy_from_slice(&label_bytes[..len]);
self.file.write_all(&field_data)?;
}
for signal in &all_signals {
let mut field_data = [b' '; 80];
let trans_bytes = signal.transducer.as_bytes();
let len = trans_bytes.len().min(80);
field_data[..len].copy_from_slice(&trans_bytes[..len]);
self.file.write_all(&field_data)?;
}
for signal in &all_signals {
let mut field_data = [b' '; 8];
let unit_bytes = signal.physical_dimension.as_bytes();
let len = unit_bytes.len().min(8);
field_data[..len].copy_from_slice(&unit_bytes[..len]);
self.file.write_all(&field_data)?;
}
for signal in &all_signals {
let phys_min_str = format!("{:<8}", signal.physical_min);
self.file.write_all(phys_min_str.as_bytes())?;
}
for signal in &all_signals {
let phys_max_str = format!("{:<8}", signal.physical_max);
self.file.write_all(phys_max_str.as_bytes())?;
}
for signal in &all_signals {
let dig_min_str = format!("{:<8}", signal.digital_min);
self.file.write_all(dig_min_str.as_bytes())?;
}
for signal in &all_signals {
let dig_max_str = format!("{:<8}", signal.digital_max);
self.file.write_all(dig_max_str.as_bytes())?;
}
for signal in &all_signals {
let mut field_data = [b' '; 80];
let prefilter_bytes = signal.prefilter.as_bytes();
let len = prefilter_bytes.len().min(80);
field_data[..len].copy_from_slice(&prefilter_bytes[..len]);
self.file.write_all(&field_data)?;
}
for signal in &all_signals {
let samples_str = format!("{:<8}", signal.samples_per_record);
self.file.write_all(samples_str.as_bytes())?;
}
for _signal in &all_signals {
let field_data = [b' '; 32];
self.file.write_all(&field_data)?;
}
Ok(())
}
pub fn set_number_of_annotation_signals(&mut self, annot_signals: usize) -> Result<()> {
if self.header_written {
return Err(EdfError::InvalidFormat("Cannot modify annotation signals after writing header".to_string()));
}
if annot_signals == 0 || annot_signals > EDFLIB_MAX_ANNOTATION_CHANNELS {
return Err(EdfError::InvalidFormat(format!(
"Annotation signals must be 1-{}, got {}",
EDFLIB_MAX_ANNOTATION_CHANNELS, annot_signals
)));
}
self.nr_annot_chns = annot_signals;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn create_test_signal() -> SignalParam {
SignalParam {
label: "Test EEG".to_string(),
samples_in_file: 0,
physical_max: 100.0,
physical_min: -100.0,
digital_max: 32767,
digital_min: -32768,
samples_per_record: 256,
physical_dimension: "uV".to_string(),
prefilter: "HP:0.1Hz LP:70Hz".to_string(),
transducer: "AgAgCl electrodes".to_string(),
}
}
fn cleanup_test_file(filename: &str) {
if Path::new(filename).exists() {
fs::remove_file(filename).ok();
}
}
#[test]
fn test_edf_writer_default_annotation_settings() {
let writer = EdfWriter::create("test_default.edf").unwrap();
assert_eq!(writer.nr_annot_chns, 1);
cleanup_test_file("test_default.edf");
}
#[test]
fn test_set_number_of_annotation_signals() {
let mut writer = EdfWriter::create("test_annot_num.edf").unwrap();
assert!(writer.set_number_of_annotation_signals(1).is_ok());
assert_eq!(writer.nr_annot_chns, 1);
assert!(writer.set_number_of_annotation_signals(32).is_ok());
assert_eq!(writer.nr_annot_chns, 32);
assert!(writer.set_number_of_annotation_signals(64).is_ok());
assert_eq!(writer.nr_annot_chns, 64);
assert!(writer.set_number_of_annotation_signals(0).is_err());
assert!(writer.set_number_of_annotation_signals(65).is_err());
cleanup_test_file("test_annot_num.edf");
}
#[test]
fn test_modification_after_header_written() {
let mut writer = EdfWriter::create("test_locked.edf").unwrap();
writer.set_patient_info("P001", "M", "01-JAN-1990", "Test").unwrap();
writer.add_signal(create_test_signal()).unwrap();
let samples = vec![10.0; 256];
writer.write_samples(&[samples]).unwrap();
assert!(writer.set_number_of_annotation_signals(3).is_err());
cleanup_test_file("test_locked.edf");
}
#[test]
fn test_multi_channel_annotation_header_creation() {
let mut writer = EdfWriter::create("test_multi_header.edf").unwrap();
writer.set_patient_info("P001", "M", "01-JAN-1990", "Test").unwrap();
writer.set_number_of_annotation_signals(3).unwrap();
writer.add_signal(create_test_signal()).unwrap();
let samples = vec![10.0; 256];
writer.write_samples(&[samples]).unwrap();
writer.finalize().unwrap();
assert!(Path::new("test_multi_header.edf").exists());
cleanup_test_file("test_multi_header.edf");
}
#[test]
fn test_annotation_tal_generation() {
let mut writer = EdfWriter::create("test_tal.edf").unwrap();
writer.set_patient_info("P001", "M", "01-JAN-1990", "Test").unwrap();
writer.set_number_of_annotation_signals(2).unwrap();
writer.add_annotation(0.0, None, "Test Event").unwrap();
writer.add_annotation(1.5, Some(2.0), "Another Event").unwrap();
writer.add_signal(create_test_signal()).unwrap();
let tal_0 = writer.generate_annotation_tal_for_channel(0, 0).unwrap();
let tal_1 = writer.generate_annotation_tal_for_channel(1, 0).unwrap();
assert!(!tal_0.is_empty());
assert!(!tal_1.is_empty());
let tal_0_str = String::from_utf8_lossy(&tal_0);
let tal_1_str = String::from_utf8_lossy(&tal_1);
assert!(tal_0_str.contains("+0\x14") || tal_1_str.contains("+0\x14"));
cleanup_test_file("test_tal.edf");
}
#[test]
fn test_complete_multi_channel_workflow() {
let filename = "test_complete_workflow.edf";
let mut writer = EdfWriter::create(filename).unwrap();
writer.set_patient_info("P001", "M", "01-JAN-1990", "Multi-channel Test").unwrap();
writer.set_number_of_annotation_signals(3).unwrap();
for i in 0..4 {
let mut signal = create_test_signal();
signal.label = format!("EEG_Ch{}", i + 1);
signal.physical_max = 200.0;
signal.physical_min = -200.0;
writer.add_signal(signal).unwrap();
}
writer.add_annotation(0.0, None, "Recording Start").unwrap();
writer.add_annotation(2.5, Some(1.0), "Artifact").unwrap();
writer.add_annotation(5.0, None, "Eyes Closed").unwrap();
writer.add_annotation(10.0, None, "Eyes Open").unwrap();
for second in 0..10 {
let mut all_samples = Vec::new();
for ch in 0..4 {
let mut channel_samples = Vec::new();
for sample in 0..256 {
let t = (second * 256 + sample) as f64 / 256.0;
let freq = 10.0 + ch as f64 * 2.0; let value = 50.0 * (2.0 * std::f64::consts::PI * freq * t).sin();
channel_samples.push(value);
}
all_samples.push(channel_samples);
}
writer.write_samples(&all_samples).unwrap();
}
writer.finalize().unwrap();
let metadata = fs::metadata(filename).unwrap();
assert!(metadata.len() > 0);
println!("Created multi-channel EDF+ file: {} bytes", metadata.len());
cleanup_test_file(filename);
}
}