use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use dspk_pipeline::Node;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::session::OpDoc;
pub const RECIPE_VERSION: u32 = 1;
pub const RECIPE_SUFFIX: &str = ".recipe.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PortKind {
#[default]
Signal,
Events,
Sorting,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RecipeDoc {
pub dspk_recipe: u32,
pub name: String,
#[serde(default = "one")]
pub version: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub input: PortKind,
#[serde(default)]
pub output: PortKind,
pub ops: Vec<OpDoc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exposed: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub dspk: String,
}
fn one() -> u32 {
1
}
#[derive(Debug, thiserror::Error)]
pub enum RecipeError {
#[error("recipe {0}: {1}")]
Parse(String, String),
#[error("recipe {0}: version {1} is not supported (this app reads version {RECIPE_VERSION})")]
Version(String, u32),
#[error("{0}")]
Io(String),
}
impl RecipeDoc {
pub fn new(name: impl Into<String>, labels: Vec<String>, ops: Vec<OpDoc>) -> Self {
Self {
dspk_recipe: RECIPE_VERSION,
name: name.into(),
version: 1,
description: String::new(),
labels,
input: PortKind::Signal,
output: PortKind::Signal,
ops,
exposed: Vec::new(),
dspk: env!("CARGO_PKG_VERSION").to_string(),
}
}
pub fn is_sorter(&self) -> bool {
self.output == PortKind::Sorting || self.labels.iter().any(|l| l == "sorter")
}
pub fn ops_with(&self, overrides: &BTreeMap<String, Value>) -> Vec<OpDoc> {
self.ops
.iter()
.map(|op| {
let prefix = format!("{}.", op.id);
let mine: Vec<(&str, &Value)> = overrides.iter().filter_map(|(k, v)| k.strip_prefix(&prefix).map(|key| (key, v))).collect();
if mine.is_empty() {
return op.clone();
}
let mut value = serde_json::to_value(&op.node).unwrap_or(Value::Null);
for (key, v) in mine {
value[key] = v.clone();
}
let node = serde_json::from_value::<Node>(value).unwrap_or_else(|_| op.node.clone());
OpDoc { node, ..op.clone() }
})
.collect()
}
pub fn overrides_for(&self, op: &str, node: &Node) -> BTreeMap<String, Value> {
let Some(def) = self.ops.iter().find(|o| o.id == op) else { return BTreeMap::new() };
let (a, b) = (serde_json::to_value(&def.node).unwrap_or(Value::Null), serde_json::to_value(node).unwrap_or(Value::Null));
let mut out = BTreeMap::new();
if let (Value::Object(a), Value::Object(b)) = (a, b) {
for (k, v) in b {
if a.get(&k) != Some(&v) {
out.insert(format!("{op}.{k}"), v);
}
}
}
out
}
pub fn from_json(text: &str) -> Result<Self, RecipeError> {
let r: Self = serde_json::from_str(text).map_err(|e| RecipeError::Parse("?".into(), e.to_string()))?;
if r.dspk_recipe != RECIPE_VERSION {
return Err(RecipeError::Version(r.name, r.dspk_recipe));
}
Ok(r)
}
pub fn load(path: &Path) -> Result<Self, RecipeError> {
let text = std::fs::read_to_string(path).map_err(|e| RecipeError::Io(format!("{}: {e}", path.display())))?;
Self::from_json(&text).map_err(|e| match e {
RecipeError::Parse(_, m) => RecipeError::Parse(path.display().to_string(), m),
other => other,
})
}
pub fn save_in(&self, dir: &Path) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}{RECIPE_SUFFIX}", file_stem(&self.name)));
std::fs::write(&path, serde_json::to_string_pretty(self).expect("a recipe always serializes"))?;
Ok(path)
}
pub fn json_schema() -> String {
serde_json::to_string_pretty(&schemars::schema_for!(RecipeDoc)).expect("a schema always serializes")
}
}
pub fn file_stem(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
s.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-")
}
pub fn library(dir: &Path) -> (Vec<RecipeDoc>, Vec<String>) {
let mut found = Vec::new();
let mut errors = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(RECIPE_SUFFIX)) {
match RecipeDoc::load(&p) {
Ok(r) => found.push(r),
Err(e) => errors.push(e.to_string()),
}
}
}
}
found.sort_by(|a, b| a.name.cmp(&b.name));
(found, errors)
}
pub fn builtin() -> Vec<RecipeDoc> {
let op = |id: &str, node: Value| OpDoc { id: id.into(), enabled: true, node: serde_json::from_value(node).expect("built-in recipe ops are valid") };
let mut spike = RecipeDoc::new(
"Spike band",
vec!["filter".into(), "reference".into(), "ephys".into()],
vec![op("bandpass", json!({"op": "iir", "band": "bandpass", "cutoff": [300.0, 6000.0]})), op("cmr", json!({"op": "cmr"}))],
);
spike.description = "Band-pass 300–6000 Hz then common median reference: what sorters expect.".into();
let mut lfp = RecipeDoc::new(
"LFP",
vec!["filter".into(), "ephys".into()],
vec![op("lowpass", json!({"op": "iir", "band": "lowpass", "cutoff": [300.0], "order": 4})), op("car", json!({"op": "car"}))],
);
lfp.description = "Low-pass 300 Hz then common average reference.".into();
let mut emg = RecipeDoc::new(
"EMG envelope",
vec!["filter".into(), "emg".into()],
vec![
op("bandpass", json!({"op": "iir", "band": "bandpass", "cutoff": [20.0, 450.0], "order": 4})),
op("energy", json!({"op": "teager_kaiser"})),
op("smooth", json!({"op": "gaussian_smooth", "sigma_samples": 20.0})),
],
);
emg.description = "Band-pass 20–450 Hz, Teager–Kaiser energy, Gaussian smoothing.".into();
let mut mains = RecipeDoc::new("Mains removal (60 Hz)", vec!["filter".into()], vec![op("notch60", json!({"op": "notch", "freq": 60.0, "q": 30.0})), op("notch120", json!({"op": "notch", "freq": 120.0, "q": 30.0}))]);
mains.description = "Notches at 60 Hz and its first harmonic.".into();
vec![spike, lfp, emg, mains]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_overrides_apply_and_round_trip() {
let r = builtin().remove(0);
let mut over = BTreeMap::new();
over.insert("bandpass.cutoff".to_string(), json!([500.0, 5000.0]));
let ops = r.ops_with(&over);
assert_eq!(serde_json::to_value(&ops[0].node).unwrap()["cutoff"], json!([500.0, 5000.0]));
assert_eq!(r.overrides_for("bandpass", &ops[0].node), over);
assert_eq!(RecipeDoc::from_json(&serde_json::to_string(&r).unwrap()).unwrap(), r);
assert!(!r.is_sorter());
assert!(RecipeDoc::json_schema().contains("dspk_recipe"));
assert_eq!(file_stem("Mains removal (60 Hz)"), "mains-removal-60-hz");
}
#[test]
fn test_builtin_ops_are_view_ops() {
for r in builtin() {
for op in &r.ops {
assert!(op.node.stage().is_ok(), "{}: {}", r.name, op.id);
}
}
}
}