modewise_dataset/
spec.rs

1use std::collections::BTreeMap;
2
3use modewise_hdf5::Layout;
4
5use crate::channels::TableFormat;
6use serde::Deserialize;
7
8use crate::DatasetError;
9
10/// Where a dataset lives and how to find and read its recordings.
11#[derive(Debug, Clone, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct DatasetSpec {
14    pub root: String,
15    /// Keep only recordings whose entity takes one of the listed values.
16    #[serde(default)]
17    pub filter: BTreeMap<String, Vec<String>>,
18    /// The first input defines which recordings exist; later inputs must exist for each.
19    #[serde(rename = "input")]
20    pub inputs: Vec<InputSpec>,
21    /// What each channel is (region, hemisphere, tags), the same for every recording.
22    pub channels: Option<ChannelsSpec>,
23    /// Information about each subject, for group statistics and machine learning.
24    pub participants: Option<crate::participants::ParticipantsSpec>,
25}
26
27/// Channel tables, read once and offered to pipelines as one more input.
28#[derive(Debug, Clone, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct ChannelsSpec {
31    /// Pipeline input name for the table.
32    #[serde(default = "default_channels_name")]
33    pub name: String,
34    /// Label files in channel order, relative to the dataset spec's folder; concatenated.
35    pub tables: Vec<TableSpec>,
36    /// Signal inputs whose channels are named by the table, in order, instead of `ch0`, `ch1`, ….
37    #[serde(default)]
38    pub names_for: Vec<String>,
39}
40
41#[derive(Debug, Clone, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TableSpec {
44    pub path: String,
45    pub format: TableFormat,
46}
47
48fn default_channels_name() -> String {
49    "channels".into()
50}
51
52#[derive(Debug, Clone, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct InputSpec {
55    /// Pipeline input this file feeds.
56    pub name: String,
57    pub pattern: String,
58    pub format: Format,
59    /// HDF5: path of the 2D dataset inside the file.
60    pub dataset: Option<String>,
61    #[serde(default)]
62    pub layout: LayoutName,
63    /// Fixed sampling rate. Give this or `sidecar`.
64    pub sampling_rate_hz: Option<f64>,
65    /// Pattern of a BIDS JSON sidecar holding `SamplingFrequency` or `RepetitionTime`.
66    pub sidecar: Option<String>,
67    /// Text file with one channel label per line, relative to the dataset root.
68    pub labels_file: Option<String>,
69    /// events_tsv: column holding each event's label (BIDS default: `trial_type`).
70    pub label_column: Option<String>,
71    /// nifti: label atlases (3D NIfTI), relative to the dataset spec's folder. Each gives one
72    /// channel per label, in ascending label order; atlases are concatenated in this order.
73    #[serde(default)]
74    pub atlases: Vec<String>,
75    /// nifti: scaling of each region's signal after averaging.
76    #[serde(default)]
77    pub standardize: StandardizeName,
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum StandardizeName {
83    #[default]
84    None,
85    /// Minus the mean, divided by the sample SD (ddof = 1), as nilearn's `zscore_sample`.
86    ZscoreSample,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Format {
92    Hdf5,
93    /// A 4D NIfTI run, averaged per atlas label at load time (docs/volumes.md).
94    Nifti,
95    /// BIDS `events.tsv`: tab-separated `onset`, `duration` and a label column.
96    EventsTsv,
97}
98
99#[derive(Debug, Clone, Copy, Default, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum LayoutName {
102    #[default]
103    ChannelsByTime,
104    TimeByChannels,
105}
106
107impl From<LayoutName> for Layout {
108    fn from(name: LayoutName) -> Self {
109        match name {
110            LayoutName::ChannelsByTime => Layout::ChannelsByTime,
111            LayoutName::TimeByChannels => Layout::TimeByChannels,
112        }
113    }
114}
115
116pub(crate) enum SamplingRateSource<'a> {
117    Hz(f64),
118    Sidecar(&'a str),
119}
120
121impl InputSpec {
122    pub(crate) fn sampling_rate_source(&self) -> SamplingRateSource<'_> {
123        match (&self.sampling_rate_hz, &self.sidecar) {
124            (Some(hz), _) => SamplingRateSource::Hz(*hz),
125            (None, Some(sidecar)) => SamplingRateSource::Sidecar(sidecar),
126            (None, None) => SamplingRateSource::Hz(f64::NAN),
127        }
128    }
129
130    fn validate(&self) -> Result<(), DatasetError> {
131        let invalid = |m: &str| DatasetError::Spec(format!("input {:?}: {m}", self.name));
132        match self.format {
133            Format::Hdf5 => {
134                if self.sampling_rate_hz.is_some() == self.sidecar.is_some() {
135                    return Err(invalid(
136                        "give exactly one of `sampling_rate_hz` or `sidecar`",
137                    ));
138                }
139                if self.dataset.is_none() {
140                    return Err(invalid(
141                        "hdf5 inputs need `dataset`, the path inside the file",
142                    ));
143                }
144                if self.label_column.is_some() {
145                    return Err(invalid("`label_column` only applies to events_tsv inputs"));
146                }
147                if !self.atlases.is_empty() || self.standardize != StandardizeName::None {
148                    return Err(invalid(
149                        "`atlases` and `standardize` only apply to nifti inputs",
150                    ));
151                }
152            }
153            Format::Nifti => {
154                if self.sampling_rate_hz.is_some() && self.sidecar.is_some() {
155                    return Err(invalid(
156                        "give at most one of `sampling_rate_hz` or `sidecar`; without either, the NIfTI header's TR is used",
157                    ));
158                }
159                if self.atlases.is_empty() {
160                    return Err(invalid(
161                        "nifti inputs need `atlases`, the label volumes to average over",
162                    ));
163                }
164                if self.dataset.is_some() || self.label_column.is_some() {
165                    return Err(invalid(
166                        "`dataset` and `label_column` do not apply to nifti inputs",
167                    ));
168                }
169            }
170            Format::EventsTsv => {
171                let signal_only = [
172                    ("dataset", self.dataset.is_some()),
173                    ("sampling_rate_hz", self.sampling_rate_hz.is_some()),
174                    ("sidecar", self.sidecar.is_some()),
175                    ("labels_file", self.labels_file.is_some()),
176                    ("atlases", !self.atlases.is_empty()),
177                    ("standardize", self.standardize != StandardizeName::None),
178                ];
179                if let Some((field, _)) = signal_only.iter().find(|(_, set)| *set) {
180                    return Err(invalid(&format!(
181                        "`{field}` does not apply to events_tsv inputs"
182                    )));
183                }
184            }
185        }
186        Ok(())
187    }
188}
189
190impl DatasetSpec {
191    pub fn from_toml(text: &str) -> Result<Self, DatasetError> {
192        let spec: Self = toml::from_str(text).map_err(|e| DatasetError::Spec(e.to_string()))?;
193        if spec.inputs.is_empty() {
194            return Err(DatasetError::Spec("declare at least one [[input]]".into()));
195        }
196        for input in &spec.inputs {
197            input.validate()?;
198        }
199        if let Some(channels) = &spec.channels {
200            channels.validate(&spec.inputs)?;
201        }
202        Ok(spec)
203    }
204}
205
206impl ChannelsSpec {
207    fn validate(&self, inputs: &[InputSpec]) -> Result<(), DatasetError> {
208        let invalid = |m: String| DatasetError::Spec(format!("[channels]: {m}"));
209        if self.tables.is_empty() {
210            return Err(invalid("list at least one table".into()));
211        }
212        if inputs.iter().any(|i| i.name == self.name) {
213            return Err(invalid(format!("name {:?} is already an input", self.name)));
214        }
215        for target in &self.names_for {
216            match inputs.iter().find(|i| &i.name == target) {
217                None => return Err(invalid(format!("names_for: no input {target:?}"))),
218                Some(i) if i.format == Format::EventsTsv => {
219                    return Err(invalid(format!(
220                        "names_for: {target:?} is not a signal input"
221                    )));
222                }
223                Some(i) if i.labels_file.is_some() => {
224                    return Err(invalid(format!(
225                        "names_for: {target:?} already has a labels_file"
226                    )));
227                }
228                Some(_) => {}
229            }
230        }
231        Ok(())
232    }
233}