Skip to main content

bids_eeg/
eeg_layout.rs

1//! High-level EEG interface on top of [`BidsLayout`].
2//!
3//! [`EegLayout`] wraps a `BidsLayout` to provide EEG-specific queries:
4//! listing EEG files, reading channels/electrodes/events/metadata, loading
5//! signal data, and generating dataset summaries.
6
7use bids_core::error::Result;
8use bids_core::file::BidsFile;
9use bids_io::tsv::read_tsv_gz;
10use bids_layout::BidsLayout;
11
12use crate::channels::{Channel, read_channels_tsv};
13use crate::coordsystem::CoordinateSystem;
14use crate::data::{Annotation, EegData, ReadOptions, read_brainvision_markers, read_eeg_data};
15use crate::electrodes::{Electrode, read_electrodes_tsv};
16use crate::events::{EegEvent, read_events_tsv};
17use crate::metadata::EegMetadata;
18
19/// High-level EEG-specific interface built on top of BidsLayout.
20pub struct EegLayout<'a> {
21    layout: &'a BidsLayout,
22}
23
24impl<'a> EegLayout<'a> {
25    pub fn new(layout: &'a BidsLayout) -> Self {
26        Self { layout }
27    }
28
29    pub fn get_eeg_files(&self) -> Result<Vec<BidsFile>> {
30        self.layout.get().suffix("eeg").collect()
31    }
32    pub fn get_eeg_files_for_subject(&self, subject: &str) -> Result<Vec<BidsFile>> {
33        self.layout.get().suffix("eeg").subject(subject).collect()
34    }
35    pub fn get_eeg_files_for_task(&self, task: &str) -> Result<Vec<BidsFile>> {
36        self.layout.get().suffix("eeg").task(task).collect()
37    }
38
39    pub fn get_channels(&self, f: &BidsFile) -> Result<Option<Vec<Channel>>> {
40        bids_core::try_read_companion(&f.companion("channels", "tsv"), read_channels_tsv)
41    }
42    pub fn get_electrodes(&self, f: &BidsFile) -> Result<Option<Vec<Electrode>>> {
43        bids_core::try_read_companion(&f.companion("electrodes", "tsv"), read_electrodes_tsv)
44    }
45    pub fn get_events(&self, f: &BidsFile) -> Result<Option<Vec<EegEvent>>> {
46        bids_core::try_read_companion(&f.companion("events", "tsv"), read_events_tsv)
47    }
48    pub fn get_eeg_metadata(&self, f: &BidsFile) -> Result<Option<EegMetadata>> {
49        let md = self.layout.get_metadata(&f.path)?;
50        Ok(EegMetadata::from_metadata(&md))
51    }
52    pub fn get_coordsystem(&self, f: &BidsFile) -> Result<Option<CoordinateSystem>> {
53        let p = f.companion("coordsystem", "json");
54        if p.exists() {
55            return Ok(Some(CoordinateSystem::from_file(&p)?));
56        }
57        // Relaxed: try without task entity
58        let stem = f.filename.split('.').next().unwrap_or("");
59        let base_parts: Vec<&str> = stem
60            .split('_')
61            .filter(|p| p.starts_with("sub-") || p.starts_with("ses-") || p.starts_with("acq-"))
62            .collect();
63        if !base_parts.is_empty() {
64            let relaxed = f
65                .dirname
66                .join(format!("{}_coordsystem.json", base_parts.join("_")));
67            if relaxed.exists() {
68                return Ok(Some(CoordinateSystem::from_file(&relaxed)?));
69            }
70        }
71        Ok(None)
72    }
73
74    pub fn get_physio(&self, f: &BidsFile) -> Result<Option<PhysioData>> {
75        let p = f.companion("physio", "tsv.gz");
76        if !p.exists() {
77            return Ok(None);
78        }
79        let rows = read_tsv_gz(&p)?;
80        let json_path = p.with_extension("").with_extension("json");
81        let md = if json_path.exists() {
82            Some(bids_io::json::read_json_sidecar(&json_path)?)
83        } else {
84            None
85        };
86        let sampling_freq = md
87            .as_ref()
88            .and_then(|m| m.get_f64("SamplingFrequency"))
89            .unwrap_or(1.0);
90        let start_time = md
91            .as_ref()
92            .and_then(|m| m.get_f64("StartTime"))
93            .unwrap_or(0.0);
94        let columns: Vec<String> = md
95            .as_ref()
96            .and_then(|m| m.get_array("Columns"))
97            .map(|arr| {
98                arr.iter()
99                    .filter_map(|v| v.as_str().map(String::from))
100                    .collect()
101            })
102            .unwrap_or_default();
103        Ok(Some(PhysioData {
104            rows,
105            sampling_frequency: sampling_freq,
106            start_time,
107            columns,
108        }))
109    }
110
111    pub fn read_edf_header(&self, f: &BidsFile) -> Result<Option<EdfHeader>> {
112        if !f.filename.ends_with(".edf") && !f.filename.ends_with(".bdf") {
113            return Ok(None);
114        }
115        EdfHeader::from_file(&f.path).map(Some)
116    }
117
118    /// Read the actual signal data from an EEG data file.
119    ///
120    /// Automatically detects the format from the file extension (.edf, .bdf, .vhdr)
121    /// and returns the multichannel time-series data in physical units.
122    ///
123    /// For BrainVision files, the companion binary file (.eeg/.dat) is resolved
124    /// relative to the .vhdr header file.
125    ///
126    /// # Example
127    ///
128    /// ```no_run
129    /// # use bids_layout::BidsLayout;
130    /// use bids_eeg::{EegLayout, ReadOptions};
131    ///
132    /// # let layout = BidsLayout::new("/path").unwrap();
133    /// let eeg = EegLayout::new(&layout);
134    /// let files = eeg.get_eeg_files().unwrap();
135    /// if let Some(f) = files.first() {
136    ///     let data = eeg.read_data(f, &ReadOptions::default()).unwrap();
137    ///     println!("{} channels, {} samples", data.n_channels(), data.n_samples(0));
138    /// }
139    /// ```
140    pub fn read_data(&self, f: &BidsFile, opts: &ReadOptions) -> Result<EegData> {
141        read_eeg_data(&f.path, opts)
142    }
143
144    /// Read the signal data from an EEG file, selecting specific channels.
145    ///
146    /// Convenience wrapper around [`read_data`](Self::read_data) that pre-fills
147    /// the channel filter in [`ReadOptions`].
148    pub fn read_data_channels(&self, f: &BidsFile, channels: &[&str]) -> Result<EegData> {
149        let opts = ReadOptions::new().with_channels(
150            channels
151                .iter()
152                .map(std::string::ToString::to_string)
153                .collect(),
154        );
155        self.read_data(f, &opts)
156    }
157
158    /// Read a time window from an EEG data file.
159    ///
160    /// Convenience wrapper around [`read_data`](Self::read_data) that pre-fills
161    /// the time range in [`ReadOptions`].
162    pub fn read_data_time_range(&self, f: &BidsFile, start: f64, end: f64) -> Result<EegData> {
163        let opts = ReadOptions::new().with_time_range(start, end);
164        self.read_data(f, &opts)
165    }
166
167    /// Read annotations/markers from a BrainVision .vmrk file associated with
168    /// the given EEG file.
169    pub fn get_brainvision_markers(
170        &self,
171        f: &BidsFile,
172        sampling_rate: f64,
173    ) -> Result<Vec<Annotation>> {
174        let vmrk_path = f.path.with_extension("vmrk");
175        if vmrk_path.exists() {
176            read_brainvision_markers(&vmrk_path, sampling_rate)
177        } else {
178            Ok(Vec::new())
179        }
180    }
181
182    pub fn get_all_channels_files(&self) -> Result<Vec<BidsFile>> {
183        self.layout
184            .get()
185            .suffix("channels")
186            .datatype("eeg")
187            .extension("tsv")
188            .collect()
189    }
190    pub fn get_all_electrodes_files(&self) -> Result<Vec<BidsFile>> {
191        self.layout
192            .get()
193            .suffix("electrodes")
194            .datatype("eeg")
195            .extension("tsv")
196            .collect()
197    }
198    pub fn get_all_events_files(&self) -> Result<Vec<BidsFile>> {
199        self.layout
200            .get()
201            .suffix("events")
202            .datatype("eeg")
203            .extension("tsv")
204            .collect()
205    }
206    pub fn get_eeg_subjects(&self) -> Result<Vec<String>> {
207        self.layout.get().suffix("eeg").return_unique("subject")
208    }
209    pub fn get_eeg_tasks(&self) -> Result<Vec<String>> {
210        self.layout.get().suffix("eeg").return_unique("task")
211    }
212
213    pub fn summary(&self) -> Result<EegDatasetSummary> {
214        let eeg_files = self.get_eeg_files()?;
215        let subjects = self.get_eeg_subjects()?;
216        let tasks = self.get_eeg_tasks()?;
217        let sampling_frequency = eeg_files
218            .first()
219            .and_then(|f| self.get_eeg_metadata(f).ok().flatten())
220            .map(|m| m.sampling_frequency);
221        let channel_count = eeg_files
222            .first()
223            .and_then(|f| self.get_channels(f).ok().flatten())
224            .map(|c| c.len());
225        Ok(EegDatasetSummary {
226            n_subjects: subjects.len(),
227            n_recordings: eeg_files.len(),
228            subjects,
229            tasks,
230            sampling_frequency,
231            channel_count,
232        })
233    }
234}
235
236#[derive(Debug)]
237pub struct EegDatasetSummary {
238    pub n_subjects: usize,
239    pub n_recordings: usize,
240    pub subjects: Vec<String>,
241    pub tasks: Vec<String>,
242    pub sampling_frequency: Option<f64>,
243    pub channel_count: Option<usize>,
244}
245
246impl std::fmt::Display for EegDatasetSummary {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        writeln!(f, "EEG Dataset Summary:")?;
249        writeln!(f, "  Subjects: {}", self.n_subjects)?;
250        writeln!(f, "  Recordings: {}", self.n_recordings)?;
251        writeln!(f, "  Tasks: {:?}", self.tasks)?;
252        if let Some(sf) = self.sampling_frequency {
253            writeln!(f, "  Sampling Frequency: {sf} Hz")?;
254        }
255        if let Some(cc) = self.channel_count {
256            writeln!(f, "  Channels: {cc}")?;
257        }
258        Ok(())
259    }
260}
261
262#[derive(Debug)]
263pub struct PhysioData {
264    pub rows: Vec<std::collections::HashMap<String, String>>,
265    pub sampling_frequency: f64,
266    pub start_time: f64,
267    pub columns: Vec<String>,
268}
269
270#[derive(Debug)]
271pub struct EdfHeader {
272    pub version: String,
273    pub patient_id: String,
274    pub recording_id: String,
275    pub n_channels: usize,
276    pub n_records: i64,
277    pub record_duration: f64,
278    pub sampling_rates: Vec<f64>,
279    pub channel_labels: Vec<String>,
280}
281
282impl EdfHeader {
283    pub fn from_file(path: &std::path::Path) -> Result<Self> {
284        use std::io::Read;
285        let mut file = std::fs::File::open(path)?;
286        let mut header = vec![0u8; 256];
287        file.read_exact(&mut header)?;
288        let version = String::from_utf8_lossy(&header[0..8]).trim().to_string();
289        let patient_id = String::from_utf8_lossy(&header[8..88]).trim().to_string();
290        let recording_id = String::from_utf8_lossy(&header[88..168]).trim().to_string();
291        let n_channels: usize = String::from_utf8_lossy(&header[252..256])
292            .trim()
293            .parse()
294            .unwrap_or(0);
295        let n_records: i64 = String::from_utf8_lossy(&header[236..244])
296            .trim()
297            .parse()
298            .unwrap_or(-1);
299        let record_duration: f64 = String::from_utf8_lossy(&header[244..252])
300            .trim()
301            .parse()
302            .unwrap_or(1.0);
303        let ext_size = n_channels * 256;
304        let mut ext_header = vec![0u8; ext_size];
305        file.read_exact(&mut ext_header)?;
306        let mut channel_labels = Vec::new();
307        for i in 0..n_channels {
308            let offset = i * 16;
309            if offset + 16 <= ext_header.len() {
310                channel_labels.push(
311                    String::from_utf8_lossy(&ext_header[offset..offset + 16])
312                        .trim()
313                        .to_string(),
314                );
315            }
316        }
317        let mut sampling_rates = Vec::new();
318        let samples_offset = n_channels * 216;
319        for i in 0..n_channels {
320            let offset = samples_offset + i * 8;
321            if offset + 8 <= ext_header.len() {
322                let samples: f64 = String::from_utf8_lossy(&ext_header[offset..offset + 8])
323                    .trim()
324                    .parse()
325                    .unwrap_or(0.0);
326                if record_duration > 0.0 {
327                    sampling_rates.push(samples / record_duration);
328                }
329            }
330        }
331        Ok(Self {
332            version,
333            patient_id,
334            recording_id,
335            n_channels,
336            n_records,
337            record_duration,
338            sampling_rates,
339            channel_labels,
340        })
341    }
342    pub fn duration(&self) -> f64 {
343        if self.n_records > 0 {
344            self.n_records as f64 * self.record_duration
345        } else {
346            0.0
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_companion() {
357        let bf = BidsFile::new("/data/sub-01/eeg/sub-01_task-rest_eeg.edf");
358        assert_eq!(
359            bf.companion("channels", "tsv").to_string_lossy(),
360            "/data/sub-01/eeg/sub-01_task-rest_channels.tsv"
361        );
362        assert_eq!(
363            bf.companion("events", "tsv").to_string_lossy(),
364            "/data/sub-01/eeg/sub-01_task-rest_events.tsv"
365        );
366    }
367}