1use std::collections::BTreeMap;
2
3use modewise_hdf5::Layout;
4
5use crate::channels::TableFormat;
6use serde::Deserialize;
7
8use crate::DatasetError;
9
10#[derive(Debug, Clone, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct DatasetSpec {
14 pub root: String,
15 #[serde(default)]
17 pub filter: BTreeMap<String, Vec<String>>,
18 #[serde(rename = "input")]
20 pub inputs: Vec<InputSpec>,
21 pub channels: Option<ChannelsSpec>,
23 pub participants: Option<crate::participants::ParticipantsSpec>,
25}
26
27#[derive(Debug, Clone, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct ChannelsSpec {
31 #[serde(default = "default_channels_name")]
33 pub name: String,
34 pub tables: Vec<TableSpec>,
36 #[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 pub name: String,
57 pub pattern: String,
58 pub format: Format,
59 pub dataset: Option<String>,
61 #[serde(default)]
62 pub layout: LayoutName,
63 pub sampling_rate_hz: Option<f64>,
65 pub sidecar: Option<String>,
67 pub labels_file: Option<String>,
69 pub label_column: Option<String>,
71 #[serde(default)]
74 pub atlases: Vec<String>,
75 #[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 ZscoreSample,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Format {
92 Hdf5,
93 Nifti,
95 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}