dda_rs/
parser.rs

1use crate::error::{DDAError, Result};
2
3/// Parse DDA binary output and return as 2D matrix [channels × timepoints]
4///
5/// Based on dda-py _process_output: skip first 2 columns, take every 4th column, then transpose
6///
7/// # Arguments
8/// * `content` - Raw text output from run_DDA_ASCII binary
9///
10/// # Returns
11/// Processed matrix in [channels/scales × timepoints] format
12pub fn parse_dda_output(content: &str) -> Result<Vec<Vec<f64>>> {
13    let mut matrix: Vec<Vec<f64>> = Vec::new();
14
15    // Parse the file into a matrix (rows = time windows, columns = various outputs)
16    for line in content.lines() {
17        // Skip comments and empty lines
18        if line.trim().is_empty() || line.trim().starts_with('#') {
19            continue;
20        }
21
22        // Parse all values in the line
23        let values: Vec<f64> = line
24            .split_whitespace()
25            .filter_map(|s| s.parse::<f64>().ok())
26            .filter(|v| v.is_finite())
27            .collect();
28
29        if !values.is_empty() {
30            matrix.push(values);
31        }
32    }
33
34    if matrix.is_empty() {
35        return Err(DDAError::ParseError("No valid data found in DDA output".to_string()));
36    }
37
38    log::info!("Loaded DDA output shape: {} rows × {} columns", matrix.len(), matrix[0].len());
39
40    // Log first row for debugging
41    if !matrix.is_empty() && matrix[0].len() >= 10 {
42        log::debug!("First row sample (first 10 values): {:?}", &matrix[0][0..10]);
43    }
44
45    // Process according to DDA format: skip first 2 columns, then take every 4th column
46    // Python does: Q[:, 2:] then Q[:, 1::4]
47    // This means: skip first 2, then from remaining take indices 1, 5, 9... = original columns 3, 7, 11...
48    if matrix[0].len() > 2 {
49        // First, skip first 2 columns to match Python's Q[:, 2:]
50        let mut after_skip: Vec<Vec<f64>> = Vec::new();
51        for row in &matrix {
52            let skipped: Vec<f64> = row.iter().skip(2).copied().collect();
53            after_skip.push(skipped);
54        }
55
56        log::debug!("After skipping first 2 columns: {} rows × {} columns", after_skip.len(), after_skip[0].len());
57
58        // Log some values from after_skip to see what we have
59        if !after_skip.is_empty() && after_skip[0].len() >= 10 {
60            log::debug!("After skip, first row (first 10 values): {:?}", &after_skip[0][0..10]);
61        }
62
63        // Now take every 4th column starting from index 0 (0-indexed from the skipped array)
64        // Try index 0 first: [:, 0::4] which takes indices 0, 4, 8, 12...
65        let mut extracted: Vec<Vec<f64>> = Vec::new();
66
67        for row in &after_skip {
68            let mut row_values = Vec::new();
69            let mut col_idx = 0; // Start at column index 0 of the already-skipped array
70            while col_idx < row.len() {
71                row_values.push(row[col_idx]);
72                col_idx += 4;
73            }
74            extracted.push(row_values);
75        }
76
77        // Log extracted sample
78        if !extracted.is_empty() && extracted[0].len() >= 5 {
79            log::debug!("First extracted row sample (first 5 values): {:?}", &extracted[0][0..5]);
80        }
81
82        if extracted.is_empty() || extracted[0].is_empty() {
83            return Err(DDAError::ParseError("No data after column extraction".to_string()));
84        }
85
86        let num_rows = extracted.len();
87        let num_cols = extracted[0].len();
88
89        log::info!("Extracted matrix shape: {} rows × {} columns (time windows × delays/scales)", num_rows, num_cols);
90
91        // Transpose: convert from [time_windows × scales] to [scales × time_windows]
92        // This gives us [channel/scale][timepoint] format expected by frontend
93        let mut transposed: Vec<Vec<f64>> = vec![Vec::new(); num_cols];
94
95        for (row_idx, row) in extracted.iter().enumerate() {
96            if row.len() != num_cols {
97                log::warn!("Row {} has {} columns, expected {}. Skipping this row.", row_idx, row.len(), num_cols);
98                continue;
99            }
100            for (col_idx, &value) in row.iter().enumerate() {
101                transposed[col_idx].push(value);
102            }
103        }
104
105        if transposed.is_empty() || transposed[0].is_empty() {
106            return Err(DDAError::ParseError("Transpose resulted in empty data".to_string()));
107        }
108
109        log::info!("Transposed to: {} channels × {} timepoints", transposed.len(), transposed[0].len());
110
111        Ok(transposed)
112    } else {
113        // If we have <= 2 columns, return as single channel
114        Ok(vec![matrix.into_iter().flatten().collect()])
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_parse_dda_output_basic() {
124        let content = "# Comment line\n\
125                       1.0 2.0 3.0 4.0 5.0 6.0\n\
126                       7.0 8.0 9.0 10.0 11.0 12.0\n";
127
128        let result = parse_dda_output(content).unwrap();
129        assert!(!result.is_empty());
130    }
131
132    #[test]
133    fn test_parse_empty_content() {
134        let content = "# Only comments\n# More comments\n";
135        let result = parse_dda_output(content);
136        assert!(result.is_err());
137    }
138}