pub mod mat5;
mod write;
pub use write::write_raw;
use anyhow::{anyhow, bail, Context, Result};
use ndarray::Array2;
use std::fs::File;
use std::path::{Path, PathBuf};
use mat5::MatValue;
#[derive(Debug, Clone)]
pub struct EeglabChannel {
pub label: String,
pub xyz: [f32; 3],
}
#[derive(Debug, Clone)]
pub struct RawSet {
pub set_path: PathBuf,
pub fdt_path: PathBuf,
pub n_chan: usize,
pub n_samples: usize, pub n_trials: usize,
pub sfreq: f64,
pub channels: Vec<EeglabChannel>,
}
impl RawSet {
pub fn is_epoched(&self) -> bool {
self.n_trials > 1
}
pub fn n_times_total(&self) -> usize {
self.n_samples * self.n_trials
}
pub fn ch_names(&self) -> Vec<&str> {
self.channels.iter().map(|c| c.label.as_str()).collect()
}
pub fn chan_pos_meters(&self) -> Array2<f32> {
let mut a = Array2::<f32>::zeros((self.n_chan, 3));
for (i, ch) in self.channels.iter().enumerate() {
a[[i, 0]] = ch.xyz[0];
a[[i, 1]] = ch.xyz[1];
a[[i, 2]] = ch.xyz[2];
}
a
}
pub fn read_all_data(&self) -> Result<Array2<f64>> {
let n_ch = self.n_chan;
let n_t = self.n_times_total();
let total = n_ch * n_t;
let need_bytes = total * 4;
let f = File::open(&self.fdt_path)
.with_context(|| format!("open {}", self.fdt_path.display()))?;
let on_disk = f.metadata()?.len() as usize;
if on_disk != need_bytes {
bail!(
".fdt size mismatch: file {} has {} bytes, struct claims {}×{}×{} = {} samples ({} bytes)",
self.fdt_path.display(), on_disk,
n_ch, self.n_samples, self.n_trials, total, need_bytes,
);
}
let mmap = unsafe { memmap2::Mmap::map(&f) }
.with_context(|| format!("mmap {}", self.fdt_path.display()))?;
debug_assert_eq!(mmap.len(), need_bytes);
let mut out = Array2::<f64>::zeros((n_ch, n_t));
let out_slice = out.as_slice_mut().expect("contiguous");
let (head, mid, tail) = unsafe { mmap.align_to::<f32>() };
if head.is_empty() && tail.is_empty() {
for t in 0..n_t {
let row = &mid[t * n_ch..(t + 1) * n_ch];
for c in 0..n_ch {
out_slice[c * n_t + t] = row[c] as f64;
}
}
} else {
for t in 0..n_t {
let off = t * n_ch * 4;
for c in 0..n_ch {
let b = &mmap[off + c * 4..off + c * 4 + 4];
out_slice[c * n_t + t] = f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64;
}
}
}
Ok(out)
}
pub fn read_epochs(&self) -> Result<ndarray::Array3<f32>> {
if !self.is_epoched() {
bail!("read_epochs called on non-epoched RawSet");
}
let flat = self.read_all_data()?;
let mut out = ndarray::Array3::<f32>::zeros((self.n_trials, self.n_chan, self.n_samples));
for tr in 0..self.n_trials {
for c in 0..self.n_chan {
for s in 0..self.n_samples {
out[[tr, c, s]] = flat[[c, tr * self.n_samples + s]] as f32;
}
}
}
Ok(out)
}
pub fn epoch_event_types(&self) -> Result<Vec<Option<String>>> {
let bytes = std::fs::read(&self.set_path)
.with_context(|| format!("re-read {}", self.set_path.display()))?;
let mat = mat5::read_mat_v5(std::io::Cursor::new(bytes))?;
Self::epoch_event_types_from_mat(&mat, self.n_trials)
}
pub fn epoch_event_types_from_mat(
mat: &mat5::MatFile,
n_trials: usize,
) -> Result<Vec<Option<String>>> {
let eeg = mat.get("EEG").ok_or_else(|| anyhow!("no `EEG` struct"))?;
let fields = match eeg {
MatValue::Struct(m) => m,
_ => bail!("`EEG` is not a struct"),
};
let elems: &Vec<std::collections::HashMap<String, MatValue>> = match fields.get("epoch") {
Some(MatValue::StructArray { elems, .. }) => elems,
Some(MatValue::Struct(m)) => {
let mut out = vec![None; n_trials.max(1)];
if let Some(s) = m.get("eventtype").and_then(|v| v.as_str()) {
out[0] = Some(s.to_string());
}
return Ok(out);
}
_ => return Ok(vec![None; n_trials]),
};
let mut out = Vec::with_capacity(n_trials);
for i in 0..n_trials {
let tag = elems
.get(i)
.and_then(|m| m.get("eventtype"))
.and_then(extract_event_string);
out.push(tag);
}
Ok(out)
}
pub fn epoch_levels(&self) -> Result<Vec<i32>> {
let tags = self.epoch_event_types()?;
Ok(tags
.iter()
.map(|t| t.as_deref().and_then(parse_b_level).unwrap_or(-1))
.collect())
}
}
fn extract_event_string(v: &MatValue) -> Option<String> {
match v {
MatValue::Str(s) => Some(s.clone()),
MatValue::Cell { elems, .. } => elems.iter().find_map(extract_event_string),
_ => None,
}
}
pub fn parse_b_level(tag: &str) -> Option<i32> {
let rest = tag.strip_prefix('B')?;
let (lvl_str, rest) = rest.split_at(rest.chars().take_while(|c| c.is_ascii_digit()).count());
let lvl: i32 = lvl_str.parse().ok()?;
if !(1..=7).contains(&lvl) {
return None;
}
let rest = rest.strip_prefix('(')?;
if rest.len() < 3 {
return None;
}
let (digits, tail) = rest.split_at(2);
if !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if !tail.starts_with(')') {
return None;
}
Some(lvl)
}
pub fn open_raw<P: AsRef<Path>>(set_path: P) -> Result<RawSet> {
let set_path = set_path.as_ref().to_path_buf();
let f = File::open(&set_path).with_context(|| format!("open {}", set_path.display()))?;
let mat = mat5::read_mat_v5(f)?;
let eeg = mat
.get("EEG")
.or_else(|| {
let structs: Vec<&MatValue> = mat
.vars
.values()
.filter(|v| matches!(v, MatValue::Struct(_)))
.collect();
if structs.len() == 1 {
Some(structs[0])
} else {
None
}
})
.ok_or_else(|| anyhow!("no top-level `EEG` struct in {}", set_path.display()))?;
let eeg_fields = match eeg {
MatValue::Struct(m) => m,
_ => bail!("`EEG` is not a struct in {}", set_path.display()),
};
let n_chan = field_scalar_usize(eeg_fields, "nbchan")?;
let pnts = field_scalar_usize(eeg_fields, "pnts")?;
let trials = field_scalar_usize(eeg_fields, "trials").unwrap_or(1).max(1);
let sfreq = field_scalar_f64(eeg_fields, "srate")?;
let fdt_path: PathBuf = match eeg_fields.get("data") {
Some(MatValue::Str(name)) => {
if name.ends_with(".fdt") || name.ends_with(".dat") {
set_path.with_file_name(name)
} else {
set_path.with_extension("fdt")
}
}
_ => set_path.with_extension("fdt"),
};
if !fdt_path.exists() {
bail!(".fdt companion not found at {}", fdt_path.display());
}
let channels = parse_chanlocs(eeg_fields, n_chan);
Ok(RawSet {
set_path,
fdt_path,
n_chan,
n_samples: pnts,
n_trials: trials,
sfreq,
channels,
})
}
fn field_scalar_f64(
fields: &std::collections::HashMap<String, MatValue>,
name: &str,
) -> Result<f64> {
let v = fields
.get(name)
.ok_or_else(|| anyhow!("EEG.{name} not found"))?;
v.as_scalar()
.ok_or_else(|| anyhow!("EEG.{name} is not a scalar (got {v:?})"))
}
fn field_scalar_usize(
fields: &std::collections::HashMap<String, MatValue>,
name: &str,
) -> Result<usize> {
let x = field_scalar_f64(fields, name)?;
if x < 0.0 || !x.is_finite() {
bail!("EEG.{name} = {x} is not a non-negative integer");
}
Ok(x as usize)
}
fn parse_chanlocs(
fields: &std::collections::HashMap<String, MatValue>,
n_chan: usize,
) -> Vec<EeglabChannel> {
let mut out: Vec<EeglabChannel> = (0..n_chan)
.map(|i| EeglabChannel {
label: format!("ch{}", i + 1),
xyz: [0.0; 3],
})
.collect();
let Some(v) = fields.get("chanlocs") else {
return out;
};
let elems: &Vec<std::collections::HashMap<String, MatValue>> = match v {
MatValue::StructArray { elems, .. } => elems,
MatValue::Struct(m) => {
if let Some(c) = mk_channel(m) {
out[0] = c;
}
return out;
}
_ => return out,
};
for (i, m) in elems.iter().take(n_chan).enumerate() {
if let Some(c) = mk_channel(m) {
out[i] = c;
}
}
out
}
fn mk_channel(m: &std::collections::HashMap<String, MatValue>) -> Option<EeglabChannel> {
let label = m
.get("labels")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let x = m.get("X").and_then(|v| v.as_scalar()).unwrap_or(0.0) as f32;
let y = m.get("Y").and_then(|v| v.as_scalar()).unwrap_or(0.0) as f32;
let z = m.get("Z").and_then(|v| v.as_scalar()).unwrap_or(0.0) as f32;
Some(EeglabChannel {
label,
xyz: [x, y, z],
})
}