zuna-rs 0.1.1

ZUNA EEG Foundation Model — inference in Rust with Burn ML
Documentation
//! Export FIF recordings to CSV files.
//!
//! The CSV format produced here is precisely what [`crate::csv_loader`]
//! expects, so a round-trip
//! `FIF → csv_export::fif_to_csv → csv_loader::load_from_csv`
//! yields bitwise-identical preprocessed tensors to `load_from_fif`.
//!
//! ## Format
//! ```text
//! # generated by zuna-rs fif_to_csv
//! # sfreq=256 n_channels=12 n_samples=3840
//! timestamp,Fp1,Fp2,...
//! 0.000000000e0,1.234567890e-5,...
//! 3.906250000e-3,...
//! ```
//!
//! Values use `{:.9e}` (10 significant figures) for lossless f32 round-trips.

use std::io::Write as IoWrite;
use std::path::Path;

use anyhow::Context;

/// Export raw FIF data to a CSV file readable by [`crate::csv_loader`].
///
/// # Arguments
/// * `fif_path`  — source FIF recording
/// * `csv_path`  — destination CSV (created or overwritten)
/// * `keep`      — if `Some`, only write channels whose names appear in this
///                 slice (case-insensitive); `None` writes all channels
///
/// # Returns
/// `(channel_names_written, sample_rate_hz)`
pub fn fif_to_csv(
    fif_path: &Path,
    csv_path: &Path,
    keep: Option<&[&str]>,
) -> anyhow::Result<(Vec<String>, f32)> {
    use exg::fiff::raw::open_raw;

    let raw   = open_raw(fif_path)
        .with_context(|| format!("opening FIF: {}", fif_path.display()))?;
    let sfreq = raw.info.sfreq as f32;

    let all_names: Vec<String> = raw.info.chs.iter().map(|ch| ch.name.clone()).collect();

    // Determine which channel indices to write
    let keep_indices: Vec<usize> = match keep {
        None => (0..all_names.len()).collect(),
        Some(subset) => {
            let lower: Vec<String> = subset.iter().map(|s| s.to_ascii_lowercase()).collect();
            all_names.iter().enumerate()
                .filter(|(_, n)| lower.contains(&n.to_ascii_lowercase()))
                .map(|(i, _)| i)
                .collect()
        }
    };

    let written_names: Vec<String> =
        keep_indices.iter().map(|&i| all_names[i].clone()).collect();

    // Read raw f64 data [n_channels, n_times]
    let data_f64 = raw.read_all_data()
        .with_context(|| format!("reading FIF data: {}", fif_path.display()))?;
    let n_times = data_f64.ncols();

    // Create parent directory if needed
    if let Some(parent) = csv_path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("creating directory: {}", parent.display()))?;
    }

    let mut file = std::fs::File::create(csv_path)
        .with_context(|| format!("creating CSV: {}", csv_path.display()))?;

    // Comment header with metadata
    writeln!(file, "# generated by zuna-rs fif_to_csv")?;
    writeln!(file, "# sfreq={sfreq} n_channels={} n_samples={n_times}",
             written_names.len())?;

    // Column header
    write!(file, "timestamp")?;
    for name in &written_names { write!(file, ",{name}")?; }
    writeln!(file)?;

    // Data rows — cast f64 → f32 to match what load_from_fif does internally
    let dt = 1.0_f32 / sfreq;
    for t in 0..n_times {
        let ts = t as f32 * dt;
        write!(file, "{ts:.9e}")?;
        for &ci in &keep_indices {
            let v = data_f64[[ci, t]] as f32;
            write!(file, ",{v:.9e}")?;
        }
        writeln!(file)?;
    }

    Ok((written_names, sfreq))
}