use std::path::{Path, PathBuf};
use dspk_core::{DspError, DspResult};
use dspk_io::container::zarr::{has_array, open_rw_store, read_array, read_node_attributes, read_node_json, write_array_f64, write_array_u64, write_group};
use serde_json::{json, Map, Value};
use crate::domain::epoch::EpochDoc;
pub const EPOCHS_VERSION: u32 = 1;
pub fn save(recording: &Path, epochs: &[EpochDoc]) -> DspResult<PathBuf> {
let dir = crate::data::derived::ensure(recording, &[])?;
let group = dir.join("epochs");
if group.exists() {
std::fs::remove_dir_all(&group).map_err(|e| DspError::Io(format!("{}: {e}", crate::domain::paths::short_path(&group))))?;
}
let store = open_rw_store(&dir)?;
let list = |f: fn(&EpochDoc) -> String| Value::Array(epochs.iter().map(|e| json!(f(e))).collect());
let mut attrs = Map::new();
attrs.insert("dspk_epochs".into(), json!(EPOCHS_VERSION));
attrs.insert("ids".into(), list(|e| e.id.clone()));
attrs.insert("names".into(), list(|e| e.name.clone()));
attrs.insert("kinds".into(), list(|e| e.kind.clone()));
attrs.insert("notes".into(), list(|e| e.notes.clone()));
write_group(&store, &dir, "/epochs", attrs)?;
let n = [epochs.len()];
write_array_f64(&store, &dir, "/epochs/start_s", &epochs.iter().map(|e| e.start_sec).collect::<Vec<_>>(), &n, &["epochs"], Map::new())?;
write_array_f64(&store, &dir, "/epochs/end_s", &epochs.iter().map(|e| e.end_sec).collect::<Vec<_>>(), &n, &["epochs"], Map::new())?;
Ok(dir)
}
pub fn load(recording: &Path) -> DspResult<Vec<EpochDoc>> {
let dir = crate::data::derived::derived_store(recording);
if !has_array(&dir, "/epochs/start_s") {
return read_zarr(&legacy_path(recording), recording);
}
let attrs = read_node_attributes(&dir, "/epochs").unwrap_or_default();
let text = |key: &str, i: usize| attrs.get(key).and_then(|v| v.get(i)).and_then(Value::as_str).unwrap_or_default().to_string();
let (start, end) = (read_array::<f64>(&dir, "/epochs/start_s")?.data, read_array::<f64>(&dir, "/epochs/end_s")?.data);
Ok(start
.iter()
.zip(&end)
.enumerate()
.map(|(i, (&a, &b))| EpochDoc {
id: text("ids", i),
recording: recording.to_path_buf(),
name: text("names", i),
kind: Some(text("kinds", i)).filter(|k| !k.is_empty()).unwrap_or_else(|| "other".into()),
start_sec: a,
end_sec: b,
notes: text("notes", i),
})
.collect())
}
pub fn legacy_path(recording: &Path) -> PathBuf {
recording.with_file_name(format!("{}.epochs.zarr", crate::data::derived::stem(recording)))
}
pub fn write_zarr(path: &Path, epochs: &[EpochDoc]) -> DspResult<()> {
if path.exists() {
std::fs::remove_dir_all(path).map_err(|e| DspError::Io(e.to_string()))?;
}
let store = open_rw_store(path)?;
let list = |f: fn(&EpochDoc) -> String| Value::Array(epochs.iter().map(|e| json!(f(e))).collect());
let mut root = Map::new();
root.insert("dspk_epochs".into(), json!(EPOCHS_VERSION));
root.insert("ids".into(), list(|e| e.id.clone()));
root.insert("names".into(), list(|e| e.name.clone()));
root.insert("kinds".into(), list(|e| e.kind.clone()));
root.insert("notes".into(), list(|e| e.notes.clone()));
write_group(&store, path, "/", root)?;
let us = |s: f64| (s * 1e6).round().max(0.0) as u64;
let n = epochs.len();
write_array_u64(&store, path, "/start_us", &epochs.iter().map(|e| us(e.start_sec)).collect::<Vec<_>>(), &[n], &["epochs"], Map::new())?;
write_array_u64(&store, path, "/end_us", &epochs.iter().map(|e| us(e.end_sec)).collect::<Vec<_>>(), &[n], &["epochs"], Map::new())?;
Ok(())
}
pub fn read_zarr(path: &Path, recording: &Path) -> DspResult<Vec<EpochDoc>> {
if !has_array(path, "/start_us") {
return Ok(Vec::new());
}
let root = read_node_json(path, "/").unwrap_or(Value::Null);
let attrs = root.get("attributes").unwrap_or(&root);
let text = |key: &str, i: usize| attrs.get(key).and_then(|v| v.get(i)).and_then(Value::as_str).unwrap_or_default().to_string();
let (start, end) = (read_array::<u64>(path, "/start_us")?.data, read_array::<u64>(path, "/end_us")?.data);
Ok(start
.iter()
.zip(&end)
.enumerate()
.map(|(i, (&a, &b))| EpochDoc {
id: text("ids", i),
recording: recording.to_path_buf(),
name: text("names", i),
kind: Some(text("kinds", i)).filter(|k| !k.is_empty()).unwrap_or_else(|| "other".into()),
start_sec: a as f64 / 1e6,
end_sec: b as f64 / 1e6,
notes: text("notes", i),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_epochs_round_trip_and_legacy() {
let dir = std::env::temp_dir().join(format!("dspk_epochs_{}", std::process::id()));
let rec = dir.join("rec.bin");
std::fs::create_dir_all(&dir).unwrap();
let e = EpochDoc { id: "e1".into(), recording: rec.clone(), name: "tone".into(), kind: "stimulus".into(), start_sec: 1.25, end_sec: 2.5, notes: "60 dB".into() };
assert_eq!(legacy_path(&rec), dir.join("rec.epochs.zarr"));
write_zarr(&legacy_path(&rec), std::slice::from_ref(&e)).unwrap();
assert_eq!(load(&rec).unwrap(), vec![e.clone()]);
let mut f = e.clone();
f.name = "tone 2".into();
assert_eq!(save(&rec, &[e.clone(), f.clone()]).unwrap(), dir.join("rec.dspk.zarr"));
assert_eq!(save(&rec, std::slice::from_ref(&f)).unwrap(), dir.join("rec.dspk.zarr"));
assert_eq!(load(&rec).unwrap(), vec![f]);
let _ = std::fs::remove_dir_all(&dir);
}
}