Skip to main content

bids_nirs/
lib.rs

1#![deny(unsafe_code)]
2//! Functional Near-Infrared Spectroscopy (fNIRS) support for BIDS datasets.
3//!
4//! Provides access to NIRS data files, channels, optode positions (source and
5//! detector), events, coordinate systems, and NIRS-specific metadata.
6//!
7//! ## Feature flags
8//!
9//! - **`snirf`** — Enable SNIRF (HDF5) data reading. Requires `libhdf5` on the system.
10//!   Adds the `data` module with `NirsData` and `read_snirf`.
11//!
12//! See: <https://bids-specification.readthedocs.io/en/stable/modality-specific-files/near-infrared-spectroscopy.html>
13
14#[cfg(feature = "snirf")]
15pub mod data;
16
17use bids_core::error::Result;
18use bids_core::file::BidsFile;
19use bids_core::metadata::BidsMetadata;
20use bids_io::tsv::read_tsv;
21use bids_layout::BidsLayout;
22use serde::{Deserialize, Serialize};
23
24#[cfg(feature = "snirf")]
25pub use data::{NirsData, read_snirf};
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "PascalCase")]
29pub struct NirsMetadata {
30    #[serde(default)]
31    pub sampling_frequency: Option<f64>,
32    #[serde(rename = "NIRSChannelCount", default)]
33    pub nirs_channel_count: Option<u32>,
34    #[serde(default)]
35    pub task_name: Option<String>,
36    #[serde(default)]
37    pub manufacturer: Option<String>,
38    #[serde(rename = "NIRSSourceOptodeCount", default)]
39    pub source_optode_count: Option<u32>,
40    #[serde(rename = "NIRSDetectorOptodeCount", default)]
41    pub detector_optode_count: Option<u32>,
42}
43impl NirsMetadata {
44    pub fn from_metadata(md: &BidsMetadata) -> Option<Self> {
45        md.deserialize_as()
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Optode {
51    pub name: String,
52    pub optode_type: String,
53    pub x: Option<f64>,
54    pub y: Option<f64>,
55    pub z: Option<f64>,
56}
57impl Optode {
58    pub fn has_position(&self) -> bool {
59        self.x.is_some() && self.y.is_some() && self.z.is_some()
60    }
61}
62
63pub fn read_optodes_tsv(path: &std::path::Path) -> Result<Vec<Optode>> {
64    let rows = read_tsv(path)?;
65    Ok(rows
66        .iter()
67        .map(|r| Optode {
68            name: r.get("name").cloned().unwrap_or_default(),
69            optode_type: r.get("type").cloned().unwrap_or_default(),
70            x: r.get("x").and_then(|v| v.parse().ok()),
71            y: r.get("y").and_then(|v| v.parse().ok()),
72            z: r.get("z").and_then(|v| v.parse().ok()),
73        })
74        .collect())
75}
76
77pub struct NirsLayout<'a> {
78    layout: &'a BidsLayout,
79}
80impl<'a> NirsLayout<'a> {
81    pub fn new(layout: &'a BidsLayout) -> Self {
82        Self { layout }
83    }
84    pub fn get_nirs_files(&self) -> Result<Vec<BidsFile>> {
85        self.layout.get().suffix("nirs").collect()
86    }
87    pub fn get_nirs_files_for_subject(&self, s: &str) -> Result<Vec<BidsFile>> {
88        self.layout.get().suffix("nirs").subject(s).collect()
89    }
90    pub fn get_nirs_files_for_task(&self, t: &str) -> Result<Vec<BidsFile>> {
91        self.layout.get().suffix("nirs").task(t).collect()
92    }
93    pub fn get_channels(&self, f: &BidsFile) -> Result<Option<Vec<bids_eeg::Channel>>> {
94        let p = f.companion("channels", "tsv");
95        if p.exists() {
96            Ok(Some(bids_eeg::read_channels_tsv(&p)?))
97        } else {
98            Ok(None)
99        }
100    }
101    pub fn get_optodes(&self, f: &BidsFile) -> Result<Option<Vec<Optode>>> {
102        let p = f.companion("optodes", "tsv");
103        if p.exists() {
104            Ok(Some(read_optodes_tsv(&p)?))
105        } else {
106            Ok(None)
107        }
108    }
109    pub fn get_events(&self, f: &BidsFile) -> Result<Option<Vec<bids_eeg::EegEvent>>> {
110        let p = f.companion("events", "tsv");
111        if p.exists() {
112            Ok(Some(bids_eeg::read_events_tsv(&p)?))
113        } else {
114            Ok(None)
115        }
116    }
117    pub fn get_coordsystem(&self, f: &BidsFile) -> Result<Option<bids_eeg::CoordinateSystem>> {
118        let p = f.companion("coordsystem", "json");
119        if p.exists() {
120            Ok(Some(bids_eeg::CoordinateSystem::from_file(&p)?))
121        } else {
122            Ok(None)
123        }
124    }
125    pub fn get_metadata(&self, f: &BidsFile) -> Result<Option<NirsMetadata>> {
126        Ok(self.layout.get_metadata(&f.path)?.deserialize_as())
127    }
128
129    /// Read NIRS signal data from a SNIRF (.snirf) file.
130    ///
131    /// Requires the `snirf` feature flag.
132    #[cfg(feature = "snirf")]
133    pub fn read_data(&self, f: &BidsFile) -> Result<NirsData> {
134        read_snirf(&f.path)
135    }
136    pub fn get_all_channels_files(&self) -> Result<Vec<BidsFile>> {
137        self.layout
138            .get()
139            .suffix("channels")
140            .datatype("nirs")
141            .extension("tsv")
142            .collect()
143    }
144    pub fn get_all_optodes_files(&self) -> Result<Vec<BidsFile>> {
145        self.layout
146            .get()
147            .suffix("optodes")
148            .datatype("nirs")
149            .extension("tsv")
150            .collect()
151    }
152    pub fn get_nirs_subjects(&self) -> Result<Vec<String>> {
153        self.layout.get().suffix("nirs").return_unique("subject")
154    }
155    pub fn get_nirs_tasks(&self) -> Result<Vec<String>> {
156        self.layout.get().suffix("nirs").return_unique("task")
157    }
158    pub fn summary(&self) -> Result<NirsSummary> {
159        let files = self.get_nirs_files()?;
160        let subjects = self.get_nirs_subjects()?;
161        let tasks = self.get_nirs_tasks()?;
162        let md = files
163            .first()
164            .and_then(|f| self.get_metadata(f).ok().flatten());
165        let sf = md.as_ref().and_then(|m| m.sampling_frequency);
166        let n_ch = md.as_ref().and_then(|m| m.nirs_channel_count);
167        Ok(NirsSummary {
168            n_subjects: subjects.len(),
169            n_recordings: files.len(),
170            subjects,
171            tasks,
172            sampling_frequency: sf,
173            channel_count: n_ch.map(|c| c as usize),
174        })
175    }
176}
177
178#[derive(Debug)]
179pub struct NirsSummary {
180    pub n_subjects: usize,
181    pub n_recordings: usize,
182    pub subjects: Vec<String>,
183    pub tasks: Vec<String>,
184    pub sampling_frequency: Option<f64>,
185    pub channel_count: Option<usize>,
186}
187impl std::fmt::Display for NirsSummary {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        writeln!(
190            f,
191            "NIRS Summary: {} subjects, {} recordings, tasks: {:?}",
192            self.n_subjects, self.n_recordings, self.tasks
193        )?;
194        if let Some(sf) = self.sampling_frequency {
195            writeln!(f, "  Sampling: {sf} Hz")?;
196        }
197        if let Some(ch) = self.channel_count {
198            writeln!(f, "  Channels: {ch}")?;
199        }
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    #[test]
208    fn test_nirs_metadata() {
209        let json = r#"{"SamplingFrequency":10.0,"NIRSChannelCount":36,"NIRSSourceOptodeCount":8,"NIRSDetectorOptodeCount":8}"#;
210        let md: NirsMetadata = serde_json::from_str(json).unwrap();
211        assert_eq!(md.sampling_frequency, Some(10.0));
212        assert_eq!(md.nirs_channel_count, Some(36));
213        assert_eq!(md.source_optode_count, Some(8));
214    }
215}