Skip to main content

zuna_rs/
csv_loader.rs

1//! CSV and raw-tensor loading for ZUNA inference.
2//!
3//! Three entry points, all producing the same `Vec<InputBatch<B>>` that
4//! The ZUNA encoder (`zuna_rs::rlx::ZunaEncoder`, or `zuna_rs::ZunaEncoder` when
5//! built with RLX-only defaults) consumes:
6//!
7//! | Function | Input |
8//! |---|---|
9//! | [`load_from_csv`] | CSV file: timestamp column + channel columns |
10//! | [`load_from_raw_tensor`] | `ndarray::Array2<f32>` + explicit `[f32;3]` positions |
11//! | [`load_from_named_tensor`] | `ndarray::Array2<f32>` + channel names (auto-lookup) |
12//!
13//! ## CSV format
14//!
15//! ```text
16//! timestamp,Fp1,Fp2,F3,F4,C3,C4
17//! 0.000000000e0,2.0721e-05,8.38e-07,...
18//! 3.906250000e-3,...
19//! ```
20//!
21//! - First column must be timestamps in **seconds** (column name is ignored;
22//!   any leading column whose name contains "time" or is index 0 is treated as
23//!   the timestamp).
24//! - Remaining columns are EEG channel values in **volts**.
25//! - Lines starting with `#` are ignored.
26//! - Scientific notation (`1.23e-5`) and plain decimals both accepted.
27//!
28//! ## Padding
29//!
30//! When `target_channels` is set in [`CsvLoadOptions`], channels present in
31//! the target list but absent from the CSV are synthesised:
32//!
33//! | [`PaddingStrategy`] | Data | Position |
34//! |---|---|---|
35//! | `Zero` | all-zero row | overrides → database → centroid |
36//! | `CloneChannel(src)` | copy of the named channel's row | overrides → database → src's pos |
37//! | `CloneNearest` | copy of nearest loaded channel by xyz | overrides → database → centroid |
38//! | `InterpWeighted { k }` | inverse-distance–weighted mean of k nearest real channels | same as CloneNearest |
39//! | `Mirror` | copy of nearest real channel on the opposite hemisphere (X flipped) | database → centroid |
40//! | `MeanRef` | per-sample mean of all real channels (common average reference) | database → centroid |
41//! | `NoPadding` | missing channels are **dropped** — output has fewer channels than the target list | n/a |
42
43use 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// ─────────────────────────────────────────────────────────────────────────────
54// Public types
55// ─────────────────────────────────────────────────────────────────────────────
56
57/// How to synthesise EEG channels that are missing from the CSV.
58#[derive(Debug, Clone)]
59pub enum PaddingStrategy {
60    /// Fill the missing channel with zeros.
61    /// Its scalp position is taken from `position_overrides`, then the
62    /// channel-position database, then the centroid of existing channels.
63    Zero,
64
65    /// Clone the data from a specific named channel.
66    /// Position of the new channel: `position_overrides[missing]` →
67    /// database lookup of the *missing* channel name → centroid.
68    CloneChannel(String),
69
70    /// Clone the data from whichever loaded channel is nearest (by Euclidean
71    /// distance) to the missing channel's known position.
72    /// Position of the new channel: `position_overrides[missing]` →
73    /// database lookup of the *missing* channel name → centroid.
74    CloneNearest,
75
76    /// Synthesise by inverse-distance–weighted averaging of the `k` nearest
77    /// real channels.  Uses all real channels when `k` ≥ number of real
78    /// channels.  This is a simple form of scalp-surface interpolation.
79    /// Position: same as [`CloneNearest`](Self::CloneNearest).
80    InterpWeighted { k: usize },
81
82    /// Copy the signal of the nearest real channel on the **opposite**
83    /// hemisphere (the target channel's X coordinate is negated to find
84    /// the "mirror" point, then the closest real channel to that point is
85    /// used).  Useful for symmetric montages where the contralateral
86    /// homologue is the best available substitute.
87    /// Position: database → centroid.
88    Mirror,
89
90    /// Fill with the per-sample mean across **all** real channels.
91    /// This is equivalent to injecting the common-average-reference (CAR)
92    /// signal, which is the least-informative but spectrally neutral choice.
93    /// Position: database → centroid.
94    MeanRef,
95
96    /// **No padding** — channels that are absent from the CSV are silently
97    /// dropped from the output instead of being synthesised.
98    ///
99    /// The returned data will have fewer channels than `target_channels` when
100    /// any targets are missing. The encoder handles variable-length inputs
101    /// natively.
102    NoPadding,
103}
104
105impl Default for PaddingStrategy {
106    fn default() -> Self { Self::Zero }
107}
108
109/// Options for [`load_from_csv`].
110#[derive(Debug, Clone)]
111pub struct CsvLoadOptions {
112    /// Sampling rate of the CSV data in Hz.  Default: `256.0`.
113    pub sample_rate: f32,
114
115    /// Signal normalisation divisor applied after z-scoring.  Default: `10.0`.
116    pub data_norm: f32,
117
118    /// If set, the output channels are reordered / padded to match this list.
119    /// Channels in the CSV but *not* in this list are discarded.
120    /// Channels in the list but *not* in the CSV are synthesised with [`padding`](Self::padding).
121    pub target_channels: Option<Vec<String>>,
122
123    /// Strategy for synthesising missing channels.  Default: [`PaddingStrategy::Zero`].
124    pub padding: PaddingStrategy,
125
126    /// Per-channel XYZ position overrides (metres).
127    ///
128    /// Keys are matched case-insensitively.  Use this to supply
129    /// *fuzzy coordinates* for channels not in the standard montage database,
130    /// or to override database positions for `CloneNearest` distance queries.
131    pub position_overrides: HashMap<String, [f32; 3]>,
132
133    /// If set, only CSV columns whose normalised name appears in this list are
134    /// treated as **present**.  Other CSV columns are silently ignored — they
135    /// will be synthesised as missing channels if they appear in
136    /// `target_channels`.
137    ///
138    /// Use this to simulate recordings with fewer channels without modifying
139    /// the CSV file (e.g. `--n-channels 6` in the `csv_embed` example).
140    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/// Metadata returned alongside the batches by [`load_from_csv`].
157#[derive(Debug)]
158pub struct CsvInfo {
159    /// Final channel names after reordering and padding.
160    pub ch_names: Vec<String>,
161    /// Scalp positions in metres `[C, 3]` after reordering and padding.
162    pub ch_pos_m: Vec<[f32; 3]>,
163    /// Sample rate used (from [`CsvLoadOptions::sample_rate`]).
164    pub sample_rate: f32,
165    /// Number of raw time-samples read from the CSV.
166    pub n_samples_raw: usize,
167    /// Recording duration in seconds.
168    pub duration_s: f32,
169    /// Number of 5-second epochs produced.
170    pub n_epochs: usize,
171    /// Number of channels added by padding.
172    pub n_padded: usize,
173}
174
175// ─────────────────────────────────────────────────────────────────────────────
176// Entry point 1 — CSV file
177// ─────────────────────────────────────────────────────────────────────────────
178
179/// Load EEG data from a CSV file and run the full ZUNA preprocessing pipeline.
180///
181/// The pipeline is identical to [`preprocess_fif_cpu`](crate::data::preprocess_fif_cpu):
182/// resample (if needed) → 0.5 Hz highpass FIR → average reference →
183/// global z-score → epoch (5 s) → baseline correction → ÷ data_norm.
184pub fn load_from_csv(
185    path:     &Path,
186    opts:     &CsvLoadOptions,
187    data_cfg: &DataConfig,
188) -> anyhow::Result<(Vec<PreprocessedEpoch>, CsvInfo)> {
189    // ── Parse CSV ─────────────────────────────────────────────────────────────
190    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    // ── Look up positions for loaded channels ─────────────────────────────────
195    let raw_positions = resolve_positions(&csv_names, &opts.position_overrides);
196
197    // ── Apply target-channel reordering / padding ─────────────────────────────
198    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            // No explicit target — whitelist acts as the target list itself
211            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    // ── Minimum epoch size guard ──────────────────────────────────────────────
228    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    // ── Run exg preprocessing pipeline ───────────────────────────────────────
237    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
256// ─────────────────────────────────────────────────────────────────────────────
257// Entry point 2 — raw tensor with explicit XYZ positions
258// ─────────────────────────────────────────────────────────────────────────────
259
260/// Load from a pre-assembled `Array2<f32>` with one **explicit** `[x,y,z]`
261/// position per channel row.
262///
263/// The data must be raw (unprocessed) EEG in volts; the full exg pipeline is
264/// applied internally.  The shape is `[n_channels, n_samples]`.
265pub 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
287// ─────────────────────────────────────────────────────────────────────────────
288// Entry point 3 — raw tensor with channel names (auto position lookup)
289// ─────────────────────────────────────────────────────────────────────────────
290
291/// Load from a pre-assembled `Array2<f32>` using **channel names** to look up
292/// scalp positions from the bundled montage database.
293///
294/// Channels not found in any montage (e.g. custom names) get the centroid of
295/// the remaining channels as their position, which keeps them encodable.
296/// Pass explicit XYZ via `position_overrides` to override any channel.
297pub 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
324// ─────────────────────────────────────────────────────────────────────────────
325// CSV parser (no external dependencies)
326// ─────────────────────────────────────────────────────────────────────────────
327
328/// Parse a CSV file into `(channel_names, data [C, T])`.
329///
330/// Rules:
331/// - Lines starting with `#` are skipped.
332/// - First non-blank, non-comment line is the header.
333/// - The first column is the timestamp column (identified by the header name
334///   containing "time" case-insensitively, or simply by being column index 0).
335/// - All remaining columns are EEG channels.
336fn 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    // ── Header ────────────────────────────────────────────────────────────────
344    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    // Identify timestamp column (first column, OR first whose name ≈ "time")
350    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    // Channel names: all columns except the timestamp column
356    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    // ── Data rows ─────────────────────────────────────────────────────────────
364    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    // ── Assemble [C, T] array ─────────────────────────────────────────────────
385    // rows is currently [T, C]; transpose to [C, T]
386    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
398// ─────────────────────────────────────────────────────────────────────────────
399// Position helpers
400// ─────────────────────────────────────────────────────────────────────────────
401
402/// Resolve XYZ positions for a list of channel names.
403///
404/// Priority per channel:
405/// 1. `overrides` map (case-insensitive normalised key)
406/// 2. [`channel_xyz`] database
407/// 3. `[0.0, 0.0, 0.0]` placeholder — will be replaced by centroid after all
408///    known channels are resolved.
409fn 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        // 1. override map
415        let key = normalise(name);
416        if let Some(&xyz) = overrides.iter().find(|(k, _)| normalise(k) == key).map(|(_, v)| v) {
417            return xyz;
418        }
419        // 2. database
420        if let Some(xyz) = channel_xyz(name) {
421            return xyz;
422        }
423        // 3. placeholder
424        [f32::NAN, f32::NAN, f32::NAN]
425    }).collect();
426
427    // Replace NaN placeholders with centroid of known positions
428    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/// Euclidean distance between two 3-D points.
437#[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
445/// Compute centroid of non-NaN positions; returns `[0,0,0]` if none.
446fn 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
461// ─────────────────────────────────────────────────────────────────────────────
462// Padding
463// ─────────────────────────────────────────────────────────────────────────────
464
465/// Reorder and pad channels to match `target_channels`.
466///
467/// If `whitelist` is `Some`, only CSV channels whose normalised name appears
468/// in the whitelist are considered "present"; others are ignored.
469///
470/// Returns `(padded_data [C_out, T], padded_names, padded_positions, n_padded)`.
471fn 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    // Build a normalised-name → source-index map for loaded channels.
487    // If a whitelist is provided, only whitelisted channels count as "present".
488    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    // Positions of loaded channels, useful for CloneNearest.
499    // Restricted to whitelisted channels when whitelist is active.
500    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            // Channel present in CSV — use it as-is
510            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            // Drop the missing channel entirely — no synthesis, no row added.
515            n_padded += 1;
516            continue;
517        } else {
518            // Channel missing — synthesise
519            n_padded += 1;
520
521            // Position for the new channel
522            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                    // Find loaded channel whose position is closest to `new_pos`
538                    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                    // Sort real channels by L2 distance, keep k nearest, then
545                    // form an inverse-distance–weighted average.
546                    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                    // weight_i = 1/d_i  (replace exact-zero distance with large weight)
553                    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                    // Flip the target's X coordinate to the opposite hemisphere,
569                    // then find the nearest real channel to that mirror position.
570                    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                    // Per-sample mean of all real channels.
578                    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                // Handled by the early `continue` branch above.
590                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
607/// Determine the XYZ position for a missing channel.
608///
609/// Priority: position_overrides → database lookup → centroid of existing.
610fn 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
625// ─────────────────────────────────────────────────────────────────────────────
626// Shared preprocessing pipeline
627// ─────────────────────────────────────────────────────────────────────────────
628
629/// Run the full exg preprocessing pipeline and assemble `InputBatch` structs.
630///
631/// Pipeline (identical to [`load_from_fif`](crate::data::load_from_fif)):
632/// resample → 0.5 Hz HP FIR → average reference → global z-score →
633/// epoch (5 s) → baseline correction → ÷ data_norm
634fn run_pipeline(
635    data:        Array2<f32>,    // [C, T] raw EEG in volts
636    pos_arr:     Array2<f32>,    // [C, 3] metres
637    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        // Discretise channel positions into bin indices.
658        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        // Chop + reshape `[C, T]` → `[C×tc, tf]`; build matching tok_idx.
667        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// ─────────────────────────────────────────────────────────────────────────────
692// Unit tests
693// ─────────────────────────────────────────────────────────────────────────────
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    /// Write a minimal CSV to a temp file and verify it round-trips.
700    #[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        // Unknown channel should get centroid of known channels, which is Cz
743        let cz = channel_xyz("Cz").unwrap();
744        let centroid = pos[0]; // unknown channel
745        // centroid of [unknown_placeholder, cz] → when unknown is NaN, centroid = cz
746        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        // Fz row must be all zeros
762        assert!(out.row(2).iter().all(|&v| v == 0.0));
763        // Fz must have a known position (from database)
764        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()];  // Cz missing
774        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        // Cz row should equal Fp1 row
780        assert_eq!(out.row(0).to_vec(), out.row(1).to_vec());
781    }
782
783    #[test]
784    fn padding_clone_nearest() {
785        // Fp1 and Fp2 are close together; Fz is between them and Cz
786        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        // AF7 is near Fp1/Fp2 front — cloned from one of them, must be nonzero
796        assert!(out.row(2).iter().any(|&v| v != 0.0));
797    }
798}