use std::path::Path;
use anyhow::{bail, Context};
use burn::prelude::*;
use crate::channel_positions::bipolar_channel_xyz;
use crate::channel_vocab;
use crate::data::InputBatch;
pub struct CsvInfo {
pub ch_names: Vec<String>,
pub sample_rate: f32,
pub n_samples_raw: usize,
pub duration_s: f32,
pub n_epochs: usize,
}
pub fn load_from_csv<B: Backend>(
path: &Path,
sample_rate: f32,
epoch_samples: usize,
device: &B::Device,
) -> anyhow::Result<(Vec<InputBatch<B>>, CsvInfo)> {
let (ch_names, data) = parse_csv(path)?;
let n_ch = ch_names.len();
let n_t = data[0].len();
let duration_s = n_t as f32 / sample_rate;
if n_t < epoch_samples {
bail!("CSV has {n_t} samples ({duration_s:.2}s), need at least {epoch_samples} for one epoch");
}
let positions: Vec<f32> = ch_names.iter()
.flat_map(|name| {
bipolar_channel_xyz(name)
.unwrap_or([0.0, 0.0, 0.0])
.to_vec()
})
.collect();
let vocab_indices: Option<Vec<i64>> = {
let indices: Vec<Option<usize>> = ch_names.iter()
.map(|n| channel_vocab::channel_index(n))
.collect();
if indices.iter().all(|i| i.is_some()) {
Some(indices.iter().map(|i| i.unwrap() as i64).collect())
} else {
None }
};
let n_epochs = n_t / epoch_samples;
let mut batches = Vec::with_capacity(n_epochs);
for e in 0..n_epochs {
let start = e * epoch_samples;
let end = start + epoch_samples;
let mut signal = Vec::with_capacity(n_ch * epoch_samples);
for ch in 0..n_ch {
signal.extend_from_slice(&data[ch][start..end]);
}
batches.push(crate::data::build_batch::<B>(
signal,
positions.clone(),
vocab_indices.clone(),
n_ch,
epoch_samples,
device,
));
}
let info = CsvInfo {
ch_names,
sample_rate,
n_samples_raw: n_t,
duration_s,
n_epochs,
};
Ok((batches, info))
}
fn parse_csv(path: &Path) -> anyhow::Result<(Vec<String>, Vec<Vec<f32>>)> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let mut lines = content.lines()
.filter(|l| { let t = l.trim(); !t.is_empty() && !t.starts_with('#') });
let header_line = lines.next()
.ok_or_else(|| anyhow::anyhow!("CSV is empty"))?;
let header: Vec<&str> = header_line.split(',').map(|s| s.trim()).collect();
anyhow::ensure!(header.len() >= 2, "need at least timestamp + one channel");
let ts_col = header.iter().position(|h| {
let n = h.to_ascii_lowercase();
n.contains("time") || n == "t" || n == "ts"
}).unwrap_or(0);
let ch_names: Vec<String> = header.iter().enumerate()
.filter(|&(i, _)| i != ts_col)
.map(|(_, h)| h.to_string())
.collect();
let n_ch = ch_names.len();
let mut columns: Vec<Vec<f32>> = vec![Vec::new(); n_ch];
for (row_idx, line) in lines.enumerate() {
let parts: Vec<&str> = line.split(',').collect();
anyhow::ensure!(parts.len() == header.len(),
"row {row_idx}: expected {} columns, got {}", header.len(), parts.len());
let mut ch_idx = 0;
for (i, s) in parts.iter().enumerate() {
if i == ts_col { continue; }
let v: f32 = s.trim().parse()
.with_context(|| format!("row {row_idx} col {i}: cannot parse '{}'", s.trim()))?;
columns[ch_idx].push(v);
ch_idx += 1;
}
}
anyhow::ensure!(!columns[0].is_empty(), "CSV has no data rows");
Ok((ch_names, columns))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_csv_basic() {
let dir = std::env::temp_dir();
let path = dir.join("luna_test.csv");
std::fs::write(&path, "timestamp,FP1-F7,F7-T3\n0.0,1e-5,2e-5\n0.004,3e-5,4e-5\n").unwrap();
let (names, data) = parse_csv(&path).unwrap();
assert_eq!(names, ["FP1-F7", "F7-T3"]);
assert_eq!(data.len(), 2);
assert_eq!(data[0].len(), 2);
assert!((data[0][0] - 1e-5).abs() < 1e-10);
}
#[test]
fn parse_csv_skips_comments() {
let dir = std::env::temp_dir();
let path = dir.join("luna_test_comments.csv");
std::fs::write(&path, "# comment\ntimestamp,C3-CZ\n0.0,0.5\n0.004,-0.3\n").unwrap();
let (names, data) = parse_csv(&path).unwrap();
assert_eq!(names, ["C3-CZ"]);
assert_eq!(data[0].len(), 2);
}
}