modewise_dataset/
participants.rs

1//! Participant information from BIDS `participants.tsv` and phenotype tables.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6use modewise_study::Participants;
7use serde::Deserialize;
8
9use crate::Pattern;
10
11/// Tables with one row per subject, joined on the subject entity.
12#[derive(Debug, Clone, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct ParticipantsSpec {
15    /// The recording entity the tables are keyed by.
16    #[serde(default = "default_entity")]
17    pub entity: String,
18    pub tables: Vec<ParticipantTable>,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct ParticipantTable {
24    /// Tab-separated, relative to the dataset root.
25    pub path: String,
26    #[serde(default = "default_id_column")]
27    pub id_column: String,
28    /// How ids relate to the entity, such as `sub-{subject}`. Default: the id is the entity value.
29    pub id_pattern: Option<String>,
30    /// Values meaning "no value", besides empty cells and `n/a`, for example `-9` or `999`.
31    #[serde(default)]
32    pub missing: Vec<String>,
33    /// Keep only these columns (default: all but the id).
34    pub columns: Option<Vec<String>>,
35    /// Put before every column name, to tell apart tables with the same column names.
36    #[serde(default)]
37    pub prefix: String,
38}
39
40fn default_entity() -> String {
41    "subject".into()
42}
43
44fn default_id_column() -> String {
45    "participant_id".into()
46}
47
48pub fn read_participants(root: &Path, spec: &ParticipantsSpec) -> Result<Participants, String> {
49    let mut joined = Participants::default();
50    for table in &spec.tables {
51        let read = read_table(&root.join(&table.path), table, &spec.entity)
52            .map_err(|e| format!("{}: {e}", table.path))?;
53        joined = joined
54            .join(read)
55            .map_err(|e| format!("{}: {e}", table.path))?;
56    }
57    Ok(joined)
58}
59
60fn read_table(path: &Path, spec: &ParticipantTable, entity: &str) -> Result<Participants, String> {
61    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
62    let pattern_text = spec
63        .id_pattern
64        .clone()
65        .unwrap_or_else(|| format!("{{{entity}}}"));
66    let pattern = Pattern::parse(&pattern_text).map_err(|e| e.to_string())?;
67    if pattern.entities() != [entity] {
68        return Err(format!(
69            "id_pattern {pattern_text:?} must contain exactly {{{entity}}}"
70        ));
71    }
72    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
73    let header: Vec<&str> = lines
74        .next()
75        .ok_or("empty file")?
76        .split('\t')
77        .map(str::trim)
78        .collect();
79    let id = header
80        .iter()
81        .position(|h| *h == spec.id_column)
82        .ok_or(format!("no column {:?}", spec.id_column))?;
83    let keep: Vec<usize> = match &spec.columns {
84        Some(wanted) => wanted
85            .iter()
86            .map(|w| {
87                header
88                    .iter()
89                    .position(|h| h == w)
90                    .ok_or(format!("no column {w:?}"))
91            })
92            .collect::<Result<_, _>>()?,
93        None => (0..header.len()).filter(|&i| i != id).collect(),
94    };
95    let missing: BTreeSet<&str> = spec
96        .missing
97        .iter()
98        .map(String::as_str)
99        .chain(["", "n/a", "NA"])
100        .collect();
101    let mut rows = BTreeMap::new();
102    for line in lines {
103        let cells: Vec<&str> = line.split('\t').map(str::trim).collect();
104        let Some(subject) = cells
105            .get(id)
106            .and_then(|v| pattern.capture(v))
107            .and_then(|m| m.get(entity).cloned())
108        else {
109            continue;
110        };
111        let values: BTreeMap<String, String> = keep
112            .iter()
113            .filter_map(|&i| {
114                cells
115                    .get(i)
116                    .filter(|v| !missing.contains(*v))
117                    .map(|v| (format!("{}{}", spec.prefix, header[i]), v.to_string()))
118            })
119            .collect();
120        if rows.insert(subject.clone(), values).is_some() {
121            return Err(format!("subject {subject:?} has more than one row"));
122        }
123    }
124    if rows.is_empty() {
125        return Err(format!(
126            "no id in column {:?} matches {pattern_text:?}",
127            spec.id_column
128        ));
129    }
130    let columns = keep
131        .iter()
132        .map(|&i| format!("{}{}", spec.prefix, header[i]))
133        .collect();
134    Participants::new(columns, rows).map_err(|e| e.to_string())
135}