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