1use std::collections::HashMap;
44use std::path::Path;
45
46use anyhow::{bail, Context};
47use ndarray::Array2;
48
49use crate::channel_positions::{channel_xyz, nearest_channel, normalise};
50use crate::config::DataConfig;
51use crate::data::PreprocessedEpoch;
52
53#[derive(Debug, Clone)]
59pub enum PaddingStrategy {
60 Zero,
64
65 CloneChannel(String),
69
70 CloneNearest,
75
76 InterpWeighted { k: usize },
81
82 Mirror,
89
90 MeanRef,
95
96 NoPadding,
103}
104
105impl Default for PaddingStrategy {
106 fn default() -> Self { Self::Zero }
107}
108
109#[derive(Debug, Clone)]
111pub struct CsvLoadOptions {
112 pub sample_rate: f32,
114
115 pub data_norm: f32,
117
118 pub target_channels: Option<Vec<String>>,
122
123 pub padding: PaddingStrategy,
125
126 pub position_overrides: HashMap<String, [f32; 3]>,
132
133 pub channel_whitelist: Option<Vec<String>>,
141}
142
143impl Default for CsvLoadOptions {
144 fn default() -> Self {
145 Self {
146 sample_rate: 256.0,
147 data_norm: 10.0,
148 target_channels: None,
149 padding: PaddingStrategy::Zero,
150 position_overrides: HashMap::new(),
151 channel_whitelist: None,
152 }
153 }
154}
155
156#[derive(Debug)]
158pub struct CsvInfo {
159 pub ch_names: Vec<String>,
161 pub ch_pos_m: Vec<[f32; 3]>,
163 pub sample_rate: f32,
165 pub n_samples_raw: usize,
167 pub duration_s: f32,
169 pub n_epochs: usize,
171 pub n_padded: usize,
173}
174
175pub fn load_from_csv(
185 path: &Path,
186 opts: &CsvLoadOptions,
187 data_cfg: &DataConfig,
188) -> anyhow::Result<(Vec<PreprocessedEpoch>, CsvInfo)> {
189 let (csv_names, raw_data) = parse_csv(path)
191 .with_context(|| format!("parsing CSV {}", path.display()))?;
192 let (_n_ch_raw, n_t) = raw_data.dim();
193
194 let raw_positions = resolve_positions(&csv_names, &opts.position_overrides);
196
197 let (padded_data, padded_names, padded_positions, n_padded) =
199 if let Some(ref targets) = opts.target_channels {
200 apply_padding(
201 &raw_data,
202 &csv_names,
203 &raw_positions,
204 targets,
205 &opts.padding,
206 &opts.position_overrides,
207 opts.channel_whitelist.as_deref(),
208 )?
209 } else if let Some(ref wl) = opts.channel_whitelist {
210 apply_padding(
212 &raw_data,
213 &csv_names,
214 &raw_positions,
215 wl,
216 &opts.padding,
217 &opts.position_overrides,
218 Some(wl),
219 )?
220 } else {
221 (raw_data, csv_names.clone(), raw_positions, 0)
222 };
223
224 let n_ch_final = padded_data.nrows();
225 let duration_s = n_t as f32 / opts.sample_rate;
226
227 let min_dur = 5.0_f32;
229 if duration_s < min_dur {
230 bail!(
231 "CSV recording is {duration_s:.2} s, shorter than the minimum \
232 epoch duration of {min_dur} s"
233 );
234 }
235
236 let pos_arr = positions_to_array(&padded_positions, n_ch_final);
238 let batches = run_pipeline(
239 padded_data, pos_arr, opts.sample_rate, opts.data_norm, data_cfg,
240 )?;
241 let n_epochs = batches.len();
242
243 let info = CsvInfo {
244 ch_names: padded_names,
245 ch_pos_m: padded_positions,
246 sample_rate: opts.sample_rate,
247 n_samples_raw: n_t,
248 duration_s,
249 n_epochs,
250 n_padded,
251 };
252
253 Ok((batches, info))
254}
255
256pub fn load_from_raw_tensor(
266 data: Array2<f32>,
267 positions: &[[f32; 3]],
268 sample_rate: f32,
269 data_norm: f32,
270 data_cfg: &DataConfig,
271) -> anyhow::Result<Vec<PreprocessedEpoch>> {
272 let n_ch = data.nrows();
273 anyhow::ensure!(
274 positions.len() == n_ch,
275 "positions.len() = {} must equal data.nrows() = {}", positions.len(), n_ch
276 );
277
278 let duration_s = data.ncols() as f32 / sample_rate;
279 if duration_s < 5.0 {
280 bail!("recording is {duration_s:.2} s, shorter than the 5 s minimum epoch");
281 }
282
283 let pos_arr = positions_to_array(positions, n_ch);
284 run_pipeline(data, pos_arr, sample_rate, data_norm, data_cfg)
285}
286
287pub fn load_from_named_tensor(
298 data: Array2<f32>,
299 channel_names: &[&str],
300 sample_rate: f32,
301 data_norm: f32,
302 position_overrides: &HashMap<String, [f32; 3]>,
303 data_cfg: &DataConfig,
304) -> anyhow::Result<Vec<PreprocessedEpoch>> {
305 let n_ch = data.nrows();
306 anyhow::ensure!(
307 channel_names.len() == n_ch,
308 "channel_names.len() = {} must equal data.nrows() = {}",
309 channel_names.len(), n_ch
310 );
311
312 let duration_s = data.ncols() as f32 / sample_rate;
313 if duration_s < 5.0 {
314 bail!("recording is {duration_s:.2} s, shorter than the 5 s minimum epoch");
315 }
316
317 let names: Vec<String> = channel_names.iter().map(|s| s.to_string()).collect();
318 let positions = resolve_positions(&names, position_overrides);
319 let pos_arr = positions_to_array(&positions, n_ch);
320
321 run_pipeline(data, pos_arr, sample_rate, data_norm, data_cfg)
322}
323
324fn parse_csv(path: &Path) -> anyhow::Result<(Vec<String>, Array2<f32>)> {
337 let content = std::fs::read_to_string(path)
338 .with_context(|| format!("reading {}", path.display()))?;
339
340 let mut lines = content.lines()
341 .filter(|l| { let t = l.trim(); !t.is_empty() && !t.starts_with('#') });
342
343 let header_line = lines.next()
345 .ok_or_else(|| anyhow::anyhow!("CSV file is empty"))?;
346 let header: Vec<&str> = header_line.split(',').collect();
347 anyhow::ensure!(header.len() >= 2, "CSV must have at least a timestamp and one channel column");
348
349 let ts_col = header.iter().position(|h| {
351 let n = h.trim().to_ascii_lowercase();
352 n.contains("time") || n == "t" || n == "ts"
353 }).unwrap_or(0);
354
355 let ch_names: Vec<String> = header.iter().enumerate()
357 .filter(|&(i, _)| i != ts_col)
358 .map(|(_, h)| h.trim().to_string())
359 .collect();
360 let n_ch = ch_names.len();
361 anyhow::ensure!(n_ch >= 1, "CSV has no channel columns after timestamp");
362
363 let mut rows: Vec<Vec<f32>> = Vec::new();
365 for (row_idx, line) in lines.enumerate() {
366 let parts: Vec<&str> = line.split(',').collect();
367 anyhow::ensure!(
368 parts.len() == header.len(),
369 "row {row_idx}: expected {} columns, got {}", header.len(), parts.len()
370 );
371 let eeg: Vec<f32> = parts.iter().enumerate()
372 .filter(|&(i, _)| i != ts_col)
373 .map(|(_, s)| {
374 s.trim().parse::<f32>()
375 .with_context(|| format!("row {row_idx}: cannot parse '{}'", s.trim()))
376 })
377 .collect::<anyhow::Result<Vec<f32>>>()?;
378 rows.push(eeg);
379 }
380
381 let n_t = rows.len();
382 anyhow::ensure!(n_t >= 1, "CSV has no data rows");
383
384 let mut flat = vec![0f32; n_ch * n_t];
387 for (t, row) in rows.iter().enumerate() {
388 for (c, &v) in row.iter().enumerate() {
389 flat[c * n_t + t] = v;
390 }
391 }
392 let data = Array2::from_shape_vec((n_ch, n_t), flat)
393 .context("assembling data array")?;
394
395 Ok((ch_names, data))
396}
397
398fn resolve_positions(
410 names: &[String],
411 overrides: &HashMap<String, [f32; 3]>,
412) -> Vec<[f32; 3]> {
413 let mut positions: Vec<[f32; 3]> = names.iter().map(|name| {
414 let key = normalise(name);
416 if let Some(&xyz) = overrides.iter().find(|(k, _)| normalise(k) == key).map(|(_, v)| v) {
417 return xyz;
418 }
419 if let Some(xyz) = channel_xyz(name) {
421 return xyz;
422 }
423 [f32::NAN, f32::NAN, f32::NAN]
425 }).collect();
426
427 let centroid = centroid_of(&positions);
429 for p in &mut positions {
430 if p[0].is_nan() { *p = centroid; }
431 }
432
433 positions
434}
435
436#[inline]
438fn dist3(a: [f32; 3], b: [f32; 3]) -> f32 {
439 let dx = a[0] - b[0];
440 let dy = a[1] - b[1];
441 let dz = a[2] - b[2];
442 (dx * dx + dy * dy + dz * dz).sqrt()
443}
444
445fn centroid_of(positions: &[[f32; 3]]) -> [f32; 3] {
447 let valid: Vec<_> = positions.iter().filter(|p| !p[0].is_nan()).collect();
448 if valid.is_empty() { return [0.0, 0.0, 0.0]; }
449 let n = valid.len() as f32;
450 let x = valid.iter().map(|p| p[0]).sum::<f32>() / n;
451 let y = valid.iter().map(|p| p[1]).sum::<f32>() / n;
452 let z = valid.iter().map(|p| p[2]).sum::<f32>() / n;
453 [x, y, z]
454}
455
456fn positions_to_array(positions: &[[f32; 3]], n_ch: usize) -> Array2<f32> {
457 let flat: Vec<f32> = positions.iter().flat_map(|p| p.iter().copied()).collect();
458 Array2::from_shape_vec((n_ch, 3), flat).expect("positions_to_array shape mismatch")
459}
460
461fn apply_padding(
472 data: &Array2<f32>,
473 names: &[String],
474 positions: &[[f32; 3]],
475 targets: &[String],
476 strategy: &PaddingStrategy,
477 overrides: &HashMap<String, [f32; 3]>,
478 whitelist: Option<&[String]>,
479) -> anyhow::Result<(Array2<f32>, Vec<String>, Vec<[f32; 3]>, usize)> {
480 let n_t = data.ncols();
481 let mut out_rows: Vec<Vec<f32>> = Vec::with_capacity(targets.len());
482 let mut out_names: Vec<String> = Vec::with_capacity(targets.len());
483 let mut out_pos: Vec<[f32; 3]> = Vec::with_capacity(targets.len());
484 let mut n_padded = 0usize;
485
486 let wl_keys: Option<std::collections::HashSet<String>> = whitelist.map(|wl| {
489 wl.iter().map(|n| normalise(n)).collect()
490 });
491 let src_index: HashMap<String, usize> = names.iter().enumerate()
492 .filter(|(_, n)| {
493 wl_keys.as_ref().map_or(true, |wl| wl.contains(&normalise(n)))
494 })
495 .map(|(i, n)| (normalise(n), i))
496 .collect();
497
498 let loaded_xyz_with_idx: Vec<([f32; 3], usize)> = positions.iter().copied()
501 .enumerate()
502 .filter(|(i, _)| src_index.values().any(|&si| si == *i))
503 .map(|(i, xyz)| (xyz, i))
504 .collect();
505
506 for target in targets {
507 let key = normalise(target);
508 if let Some(&src) = src_index.get(&key) {
509 out_rows.push(data.row(src).to_vec());
511 out_names.push(target.clone());
512 out_pos.push(positions[src]);
513 } else if matches!(strategy, PaddingStrategy::NoPadding) {
514 n_padded += 1;
516 continue;
517 } else {
518 n_padded += 1;
520
521 let new_pos = position_for_missing(target, overrides, positions);
523
524 let new_row = match strategy {
525 PaddingStrategy::Zero => {
526 vec![0f32; n_t]
527 }
528 PaddingStrategy::CloneChannel(src_name) => {
529 let src_key = normalise(src_name);
530 let src_idx = src_index.get(&src_key).copied()
531 .ok_or_else(|| anyhow::anyhow!(
532 "CloneChannel source '{}' not found in CSV", src_name
533 ))?;
534 data.row(src_idx).to_vec()
535 }
536 PaddingStrategy::CloneNearest => {
537 let nearest_idx = nearest_channel(new_pos, &loaded_xyz_with_idx)
539 .unwrap_or(0);
540 data.row(nearest_idx).to_vec()
541 }
542
543 PaddingStrategy::InterpWeighted { k } => {
544 let mut dists: Vec<(f32, usize)> = loaded_xyz_with_idx.iter()
547 .map(|&(xyz, idx)| (dist3(xyz, new_pos), idx))
548 .collect();
549 dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
550 let k_actual = (*k).min(dists.len()).max(1);
551 let k_slice = &dists[..k_actual];
552 let weights: Vec<f32> = k_slice.iter()
554 .map(|(d, _)| if *d < 1e-6 { 1e6_f32 } else { 1.0 / d })
555 .collect();
556 let w_sum: f32 = weights.iter().sum();
557 let mut interp = vec![0f32; n_t];
558 for ((_, idx), w) in k_slice.iter().zip(weights.iter()) {
559 let wn = w / w_sum;
560 for (o, &v) in interp.iter_mut().zip(data.row(*idx).iter()) {
561 *o += wn * v;
562 }
563 }
564 interp
565 }
566
567 PaddingStrategy::Mirror => {
568 let mirror_pos = [-new_pos[0], new_pos[1], new_pos[2]];
571 let nearest_idx = nearest_channel(mirror_pos, &loaded_xyz_with_idx)
572 .unwrap_or_else(|| loaded_xyz_with_idx.first().map(|&(_, i)| i).unwrap_or(0));
573 data.row(nearest_idx).to_vec()
574 }
575
576 PaddingStrategy::MeanRef => {
577 let n_real = loaded_xyz_with_idx.len().max(1);
579 let mut mean_sig = vec![0f32; n_t];
580 for &(_, idx) in &loaded_xyz_with_idx {
581 for (m, &v) in mean_sig.iter_mut().zip(data.row(idx).iter()) {
582 *m += v;
583 }
584 }
585 for m in &mut mean_sig { *m /= n_real as f32; }
586 mean_sig
587 }
588
589 PaddingStrategy::NoPadding => unreachable!(),
591 };
592
593 out_rows.push(new_row);
594 out_names.push(target.clone());
595 out_pos.push(new_pos);
596 }
597 }
598
599 let n_out = out_rows.len();
600 let flat: Vec<f32> = out_rows.into_iter().flatten().collect();
601 let padded = Array2::from_shape_vec((n_out, n_t), flat)
602 .context("assembling padded data array")?;
603
604 Ok((padded, out_names, out_pos, n_padded))
605}
606
607fn position_for_missing(
611 name: &str,
612 overrides: &HashMap<String, [f32; 3]>,
613 existing: &[[f32; 3]],
614) -> [f32; 3] {
615 let key = normalise(name);
616 if let Some(&xyz) = overrides.iter().find(|(k, _)| normalise(k) == key).map(|(_, v)| v) {
617 return xyz;
618 }
619 if let Some(xyz) = channel_xyz(name) {
620 return xyz;
621 }
622 centroid_of(existing)
623}
624
625fn run_pipeline(
635 data: Array2<f32>, pos_arr: Array2<f32>, sample_rate: f32,
638 data_norm: f32,
639 data_cfg: &DataConfig,
640) -> anyhow::Result<Vec<PreprocessedEpoch>> {
641 use exg::PipelineConfig;
642
643 let cfg = PipelineConfig { data_norm, ..PipelineConfig::default() };
644 let epochs = exg::preprocess(data, pos_arr, sample_rate, &cfg)?;
645
646 if epochs.is_empty() {
647 bail!("recording produced zero epochs (likely shorter than the 5 s minimum epoch)");
648 }
649
650 let tf = data_cfg.num_fine_time_pts;
651 let bins = data_cfg.num_bins as f32;
652 let mut batches = Vec::with_capacity(epochs.len());
653 for (eeg_arr, pos_arr) in epochs {
654 let (c, t) = eeg_arr.dim();
655 let tc = t / tf;
656
657 let disc: Vec<i32> = pos_arr.iter().enumerate().map(|(i, &v)| {
659 let axis = i % 3;
660 let lo = data_cfg.xyz_min[axis];
661 let hi = data_cfg.xyz_max[axis];
662 let norm = (v - lo) / (hi - lo);
663 (norm * bins).min(bins - 1.0).max(0.0) as i32
664 }).collect();
665
666 let s = c * tc;
668 let mut eeg_tokens = vec![0f32; s * tf];
669 let mut tok_idx = vec![0i32; s * 4];
670 for ch in 0..c {
671 for ti in 0..tc {
672 let token = ch * tc + ti;
673 for f in 0..tf {
674 eeg_tokens[token * tf + f] = eeg_arr[[ch, ti * tf + f]];
675 }
676 tok_idx[token * 4] = disc[ch * 3];
677 tok_idx[token * 4 + 1] = disc[ch * 3 + 1];
678 tok_idx[token * 4 + 2] = disc[ch * 3 + 2];
679 tok_idx[token * 4 + 3] = ti as i32;
680 }
681 }
682 let chan_pos: Vec<f32> = pos_arr.iter().copied().collect();
683 batches.push(PreprocessedEpoch {
684 eeg_tokens, tok_idx, chan_pos, s, tf, n_channels: c, tc,
685 });
686 }
687
688 Ok(batches)
689}
690
691#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
701 fn parse_csv_basic() {
702 let content = "timestamp,Fp1,Fp2\n0.0,1e-5,2e-5\n0.004,3e-5,4e-5\n";
703 let path = std::env::temp_dir().join("zuna_test_basic.csv");
704 std::fs::write(&path, content).unwrap();
705 let (names, data) = parse_csv(&path).unwrap();
706 assert_eq!(names, ["Fp1", "Fp2"]);
707 assert_eq!(data.dim(), (2, 2));
708 assert!((data[[0, 0]] - 1e-5_f32).abs() < 1e-10);
709 assert!((data[[1, 1]] - 4e-5_f32).abs() < 1e-10);
710 }
711
712 #[test]
713 fn parse_csv_skips_comments() {
714 let content = "# comment\ntimestamp,C3\n0.0,0.5\n0.004,-0.3\n";
715 let path = std::env::temp_dir().join("zuna_test_comments.csv");
716 std::fs::write(&path, content).unwrap();
717 let (names, data) = parse_csv(&path).unwrap();
718 assert_eq!(names, ["C3"]);
719 assert_eq!(data.dim(), (1, 2));
720 }
721
722 #[test]
723 fn resolve_positions_uses_database() {
724 let pos = resolve_positions(&["Cz".to_string()], &HashMap::new());
725 assert_eq!(pos.len(), 1);
726 let [x, y, z] = pos[0];
727 assert!(x.abs() < 0.12 && y.abs() < 0.12 && z.abs() < 0.12);
728 }
729
730 #[test]
731 fn resolve_positions_override_wins() {
732 let mut ov = HashMap::new();
733 ov.insert("CZ".to_string(), [0.01, 0.02, 0.09]);
734 let pos = resolve_positions(&["Cz".to_string()], &ov);
735 assert_eq!(pos[0], [0.01, 0.02, 0.09]);
736 }
737
738 #[test]
739 fn resolve_positions_unknown_gets_centroid() {
740 let names = vec!["UNKNOWN_XYZ".to_string(), "Cz".to_string()];
741 let pos = resolve_positions(&names, &HashMap::new());
742 let cz = channel_xyz("Cz").unwrap();
744 let centroid = pos[0]; assert!((centroid[0] - cz[0]).abs() < 1e-5);
747 }
748
749 #[test]
750 fn padding_zero_adds_zero_rows() {
751 let data = Array2::from_shape_vec((2, 4), vec![1f32; 8]).unwrap();
752 let names = vec!["Fp1".to_string(), "Fp2".to_string()];
753 let pos = resolve_positions(&names, &HashMap::new());
754 let targets = vec!["Fp1".to_string(), "Fp2".to_string(), "Fz".to_string()];
755 let (out, out_names, out_pos, n_padded) = apply_padding(
756 &data, &names, &pos, &targets, &PaddingStrategy::Zero, &HashMap::new(), None
757 ).unwrap();
758 assert_eq!(out.dim(), (3, 4));
759 assert_eq!(n_padded, 1);
760 assert_eq!(out_names[2], "Fz");
761 assert!(out.row(2).iter().all(|&v| v == 0.0));
763 let [x, y, z] = out_pos[2];
765 assert!(x.abs() < 0.12 && y.abs() < 0.12 && z.abs() < 0.12);
766 }
767
768 #[test]
769 fn padding_clone_channel() {
770 let data = Array2::from_shape_vec((2, 4), (0..8).map(|i| i as f32).collect()).unwrap();
771 let names = vec!["Fp1".to_string(), "Fp2".to_string()];
772 let pos = resolve_positions(&names, &HashMap::new());
773 let targets = vec!["Fp1".to_string(), "Cz".to_string()]; let (out, _, _, n_padded) = apply_padding(
775 &data, &names, &pos, &targets,
776 &PaddingStrategy::CloneChannel("Fp1".to_string()), &HashMap::new(), None
777 ).unwrap();
778 assert_eq!(n_padded, 1);
779 assert_eq!(out.row(0).to_vec(), out.row(1).to_vec());
781 }
782
783 #[test]
784 fn padding_clone_nearest() {
785 let data = Array2::from_shape_vec((2, 4), (0..8).map(|i| i as f32 * 0.1).collect()).unwrap();
787 let names = vec!["Fp1".to_string(), "Fp2".to_string()];
788 let pos = resolve_positions(&names, &HashMap::new());
789 let targets = vec!["Fp1".to_string(), "Fp2".to_string(), "AF7".to_string()];
790 let (out, _, _, n_padded) = apply_padding(
791 &data, &names, &pos, &targets,
792 &PaddingStrategy::CloneNearest, &HashMap::new(), None
793 ).unwrap();
794 assert_eq!(n_padded, 1);
795 assert!(out.row(2).iter().any(|&v| v != 0.0));
797 }
798}