exg 0.0.5

EXG (EEG/ECG/EMG) preprocessing — native Rust DSP + FIF reader, numerical parity with MNE-Python
Documentation
//! Measurement info (MNE's `Info` struct) read from a FIF file.
//!
//! We read only the fields needed for EEG preprocessing; MEG-specific fields
//! (projections, CTF compensations, HPI, …) are intentionally omitted.
use std::io::{Read, Seek};
use anyhow::{bail, Result};

use super::constants::*;
use super::tag::*;
use super::tree::Node;

// ── Channel info ─────────────────────────────────────────────────────────

/// Channel info, parsed from a `FIFFT_CH_INFO_STRUCT` (30) tag.
///
/// On-disk layout (big-endian, 100 bytes total):
/// ```text
///  4  scanno       i32
///  4  logno        i32
///  4  kind         i32
///  4  range        f32
///  4  cal          f32
///  4  coil_type    i32
/// 48  loc          12 × f32
///  4  unit         i32
///  4  unit_mul     i32
/// 16  ch_name      16 × u8 (null-padded Latin-1)
/// ─────────────────
/// 100 bytes
/// ```
#[derive(Debug, Clone)]
/// Per-channel information parsed from the FIFF 96-byte channel info struct.
pub struct ChannelInfo {
    pub scan_no:   i32,
    pub log_no:    i32,
    pub kind:      i32,
    pub range:     f32,
    pub cal:       f32,
    pub coil_type: i32,
    /// Position + orientation: `[x, y, z, nx0, ny0, nz0, …]` in metres.
    pub loc:       [f32; 12],
    pub unit:      i32,
    pub unit_mul:  i32,
    pub name:      String,
}

impl ChannelInfo {
    /// Calibration factor applied to raw integer/float samples: `cal × range`.
    #[inline]
    pub fn calibration(&self) -> f64 {
        (self.cal as f64) * (self.range as f64)
    }

    /// Parse from the 96-byte payload of a FIFFT_CH_INFO_STRUCT tag.
    ///
    /// Layout: scanno(4) + logno(4) + kind(4) + range(4) + cal(4) +
    ///         coil_type(4) + loc(48) + unit(4) + unit_mul(4) + ch_name(16) = 96
    pub fn from_bytes(raw: &[u8]) -> Result<Self> {
        if raw.len() < 96 {
            bail!("ch_info payload too short: {} bytes (need 96)", raw.len());
        }
        let scan_no   = i32::from_be_bytes(raw[0..4].try_into().unwrap());
        let log_no    = i32::from_be_bytes(raw[4..8].try_into().unwrap());
        let kind      = i32::from_be_bytes(raw[8..12].try_into().unwrap());
        let range     = f32::from_be_bytes(raw[12..16].try_into().unwrap());
        let cal       = f32::from_be_bytes(raw[16..20].try_into().unwrap());
        let coil_type = i32::from_be_bytes(raw[20..24].try_into().unwrap());
        let mut loc   = [0f32; 12];
        for (i, v) in loc.iter_mut().enumerate() {
            *v = f32::from_be_bytes(raw[24 + i * 4..24 + i * 4 + 4].try_into().unwrap());
        }
        let unit      = i32::from_be_bytes(raw[72..76].try_into().unwrap());
        let unit_mul  = i32::from_be_bytes(raw[76..80].try_into().unwrap());
        // Channel name: null-terminated Latin-1, 16 bytes
        let name_bytes = &raw[80..96];
        let end = name_bytes.iter().position(|&b| b == 0).unwrap_or(16);
        let name = name_bytes[..end].iter().map(|&b| b as char).collect();
        Ok(ChannelInfo { scan_no, log_no, kind, range, cal, coil_type, loc, unit, unit_mul, name })
    }
}

// ── Measurement info ─────────────────────────────────────────────────────

/// Measurement metadata extracted from `FIFFB_MEAS_INFO`.
///
/// Corresponds to MNE's `Info` object, restricted to fields we actually use.
#[derive(Debug, Clone)]
pub struct MeasInfo {
    pub n_chan:     usize,
    pub sfreq:     f64,
    pub lowpass:   Option<f64>,
    pub highpass:  Option<f64>,
    pub line_freq: Option<f64>,
    pub chs:       Vec<ChannelInfo>,
    pub bad_ch_names: Vec<String>,
    pub experimenter: Option<String>,
    pub description:  Option<String>,
}

impl MeasInfo {
    /// Calibration array `[n_chan]`: `cal[i] = chs[i].cal * chs[i].range`.
    pub fn cals(&self) -> Vec<f64> {
        self.chs.iter().map(|c| c.calibration()).collect()
    }

    /// Channel names in order.
    pub fn ch_names(&self) -> Vec<&str> {
        self.chs.iter().map(|c| c.name.as_str()).collect()
    }
}

/// Read `MeasInfo` from an open FIF file given the tree.
pub fn read_meas_info<R: Read + Seek>(reader: &mut R, tree: &Node) -> Result<MeasInfo> {
    // Navigate to FIFFB_MEAS_INFO
    let meas_node = tree
        .find_block(FIFFB_MEAS)
        .ok_or_else(|| anyhow::anyhow!("FIFFB_MEAS block not found"))?;
    let info_node = meas_node
        .find_block(FIFFB_MEAS_INFO)
        .ok_or_else(|| anyhow::anyhow!("FIFFB_MEAS_INFO block not found"))?;

    let mut n_chan     = None::<usize>;
    let mut sfreq      = None::<f64>;
    let mut lowpass    = None::<f64>;
    let mut highpass   = None::<f64>;
    let mut line_freq  = None::<f64>;
    let mut chs        = Vec::<ChannelInfo>::new();
    let mut bad_ch_names = Vec::<String>::new();
    let mut experimenter = None::<String>;
    let mut description  = None::<String>;

    for ent in &info_node.entries {
        match ent.kind {
            FIFF_NCHAN => {
                n_chan = Some(read_i32(reader, ent)? as usize);
            }
            FIFF_SFREQ => {
                sfreq = Some(read_f32(reader, ent)? as f64);
            }
            FIFF_LOWPASS => {
                let v = read_f32(reader, ent)?;
                if v.is_finite() {
                    lowpass = Some(v as f64);
                }
            }
            FIFF_HIGHPASS => {
                let v = read_f32(reader, ent)?;
                if v.is_finite() {
                    highpass = Some(v as f64);
                }
            }
            FIFF_LINE_FREQ => {
                let v = read_f32(reader, ent)?;
                if v.is_finite() {
                    line_freq = Some(v as f64);
                }
            }
            FIFF_CH_INFO => {
                let raw = read_raw_bytes(reader, ent)?;
                chs.push(ChannelInfo::from_bytes(&raw)?);
            }
            FIFF_BAD_CHS => {
                // List of bad channel names separated by colons.
                let s = read_string(reader, ent)?;
                bad_ch_names = s
                    .split(':')
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .map(str::to_string)
                    .collect();
            }
            FIFF_EXPERIMENTER => {
                experimenter = Some(read_string(reader, ent)?);
            }
            FIFF_DESCRIPTION => {
                description = Some(read_string(reader, ent)?);
            }
            _ => {}
        }
    }

    let n_chan = n_chan.ok_or_else(|| anyhow::anyhow!("FIFF_NCHAN not found"))?;
    let sfreq  = sfreq.ok_or_else(|| anyhow::anyhow!("FIFF_SFREQ not found"))?;

    if chs.len() != n_chan {
        bail!("expected {n_chan} ch_info structs, got {}", chs.len());
    }

    Ok(MeasInfo { n_chan, sfreq, lowpass, highpass, line_freq, chs, bad_ch_names, experimenter, description })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ch_info_from_bytes_basic() {
        // Construct a minimal 96-byte payload.
        let mut raw = vec![0u8; 96];
        // kind = 2 (FIFFV_EEG_CH)
        raw[8..12].copy_from_slice(&2_i32.to_be_bytes());
        // range = 1.0
        raw[12..16].copy_from_slice(&1_f32.to_be_bytes());
        // cal = 2.0
        raw[16..20].copy_from_slice(&2_f32.to_be_bytes());
        // loc[0] = 0.5
        raw[24..28].copy_from_slice(&0.5_f32.to_be_bytes());
        // name = "Fp1\0..."
        raw[80..84].copy_from_slice(b"Fp1\0");

        let ch = ChannelInfo::from_bytes(&raw).unwrap();
        assert_eq!(ch.kind, 2);
        approx::assert_abs_diff_eq!(ch.range, 1.0_f32, epsilon = 1e-7);
        approx::assert_abs_diff_eq!(ch.cal, 2.0_f32, epsilon = 1e-7);
        approx::assert_abs_diff_eq!(ch.loc[0], 0.5_f32, epsilon = 1e-7);
        approx::assert_abs_diff_eq!(ch.calibration() as f32, 2.0, epsilon = 1e-6);
        assert_eq!(ch.name, "Fp1");
    }

    #[test]
    fn ch_info_too_short() {
        assert!(ChannelInfo::from_bytes(&[0u8; 95]).is_err());
    }
}