use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use dspk_pipeline::{Node, OutputFormat, PipelineSpec, SPEC_VERSION};
use super::epoch::{EpochDoc, Placement};
use super::graph::{GraphDoc, Recipes};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const SESSION_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SessionDoc {
pub dspk_session: u32,
#[serde(default, skip_serializing_if = "GraphDoc::is_empty")]
pub graph: GraphDoc,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub recipes: Recipes,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub epochs: Vec<EpochDoc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub placements: Vec<Placement>,
#[serde(default)]
pub views: Vec<ViewDoc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_view: Option<String>,
}
impl Default for SessionDoc {
fn default() -> Self {
Self { dspk_session: SESSION_VERSION, graph: GraphDoc::default(), recipes: Recipes::new(), epochs: Vec::new(), placements: Vec::new(), views: Vec::new(), active_view: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ViewDoc {
pub id: String,
pub name: String,
pub recording: RecordingRef,
#[serde(default)]
pub ops: Vec<OpDoc>,
#[serde(default)]
pub display: DisplayDoc,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[schemars(with = "BTreeMap<String, serde_json::Value>")]
pub plugins: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RecordingRef {
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channels: Option<Vec<usize>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OpDoc {
pub id: String,
#[serde(default = "yes")]
pub enabled: bool,
pub node: Node,
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DisplayDoc {
#[serde(default)]
pub show: Show,
#[serde(default = "default_gain")]
pub gain: f32,
#[serde(default = "default_lanes")]
pub lanes: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<[f64; 2]>,
}
pub const DEFAULT_GAIN: f32 = 1.0;
pub const DEFAULT_LANES: usize = 8;
fn default_gain() -> f32 {
DEFAULT_GAIN
}
fn default_lanes() -> usize {
DEFAULT_LANES
}
impl Default for DisplayDoc {
fn default() -> Self {
Self { show: Show::default(), gain: DEFAULT_GAIN, lanes: DEFAULT_LANES, window: None }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Show {
#[default]
Processed,
Raw,
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("session document: {0}")]
Parse(String),
#[error("session document version {0} is not supported (this app reads version {SESSION_VERSION})")]
Version(u32),
#[error("{0}")]
Io(String),
}
impl SessionDoc {
pub fn from_json(text: &str) -> Result<Self, SessionError> {
serde_json::from_str::<Self>(text).map_err(|e| SessionError::Parse(e.to_string()))?.checked()
}
fn checked(mut self) -> Result<Self, SessionError> {
if self.dspk_session != SESSION_VERSION {
return Err(SessionError::Version(self.dspk_session));
}
self.normalize();
Ok(self)
}
pub fn normalize(&mut self) {
if self.graph.is_empty() && !self.views.is_empty() {
self.graph = GraphDoc::from_views(&self.views);
}
self.compile();
}
pub fn compile(&mut self) {
self.views = self.graph.compile(&self.recipes);
if !self.active_view.as_ref().is_some_and(|a| self.views.iter().any(|v| v.id == *a)) {
self.active_view = self.views.first().map(|v| v.id.clone());
}
}
pub fn normalized(mut self) -> Self {
self.normalize();
self
}
pub fn load(path: &Path) -> Result<Self, SessionError> {
let text = std::fs::read_to_string(path).map_err(|e| SessionError::Io(format!("{}: {e}", path.display())))?;
Self::from_json(&text)
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("a session always serializes")
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, self.to_json())?;
std::fs::rename(tmp, path)
}
pub fn json_schema() -> String {
serde_json::to_string_pretty(&schemars::schema_for!(SessionDoc)).expect("a schema always serializes")
}
pub fn view(&self, id: &str) -> Option<&ViewDoc> {
self.views.iter().find(|v| v.id == id)
}
pub fn view_mut(&mut self, id: &str) -> Option<&mut ViewDoc> {
self.views.iter_mut().find(|v| v.id == id)
}
pub fn active(&self) -> Option<&ViewDoc> {
self.active_view.as_deref().and_then(|id| self.view(id))
}
pub fn new_view_id(&self) -> String {
fresh_id("v", |id| self.view(id).is_some() || self.graph.contains(id))
}
pub fn new_op_id(&self, node: &Node) -> String {
let id = self.graph.fresh_id(op_name(node));
if self.view(&id).is_none() {
return id;
}
fresh_id(op_name(node), |id| self.graph.contains(id) || self.view(id).is_some())
}
}
impl ViewDoc {
pub fn op(&self, id: &str) -> Option<&OpDoc> {
self.ops.iter().find(|o| o.id == id)
}
pub fn new_op_id(&self, node: &Node) -> String {
let base = op_name(node);
if self.op(base).is_none() {
return base.to_string();
}
fresh_id(base, |id| self.op(id).is_some())
}
pub fn enabled_nodes(&self) -> impl Iterator<Item = &Node> {
self.ops.iter().filter(|o| o.enabled).map(|o| &o.node)
}
pub fn pipeline(&self, output: PathBuf, format: OutputFormat) -> PipelineSpec {
let mut nodes = BTreeMap::new();
let mut edges = Vec::new();
nodes.insert(
"read".to_string(),
Node::Read {
path: Some(self.recording.path.clone()),
inputs: None,
source: self.recording.source.clone(),
channels: self.recording.channels.clone(),
start: None,
stop: None,
},
);
let mut last = "read".to_string();
for op in self.ops.iter().filter(|o| o.enabled) {
nodes.insert(op.id.clone(), op.node.clone());
edges.push((last, op.id.clone()));
last = op.id.clone();
}
nodes.insert("write".to_string(), Node::Write { path: output, format, compression: None, chunk_samples: None, parallel: None });
edges.push((last, "write".to_string()));
PipelineSpec { dspk_pipeline: SPEC_VERSION, name: Some(self.name.clone()), nodes, edges, execution: Default::default() }
}
}
pub fn op_family(node: &Node) -> &'static str {
match node {
Node::Iir { .. } | Node::Fir { .. } | Node::Sos { .. } | Node::Notch { .. } => "filter",
Node::Car {} | Node::Cmr {} => "reference",
Node::Median { .. } | Node::GaussianSmooth { .. } => "smooth",
_ => "transform",
}
}
pub fn op_name(node: &Node) -> &'static str {
match node {
Node::Read { .. } => "read",
Node::Write { .. } => "write",
Node::Scale { .. } => "scale",
Node::SubtractBaseline { .. } => "subtract_baseline",
Node::Clamp { .. } => "clamp",
Node::Iir { .. } => "iir",
Node::Notch { .. } => "notch",
Node::Sos { .. } => "sos",
Node::Fir { .. } => "fir",
Node::Resample { .. } => "resample",
Node::Car {} => "car",
Node::Cmr {} => "cmr",
Node::GaussianSmooth { .. } => "gaussian_smooth",
Node::Median { .. } => "median",
Node::TeagerKaiser { .. } => "teager_kaiser",
}
}
fn fresh_id(prefix: &str, taken: impl Fn(&str) -> bool) -> String {
(1..).map(|i| format!("{prefix}{i}")).find(|id| !taken(id)).expect("ids are unbounded")
}
#[cfg(test)]
mod tests {
use super::*;
use dspk_pipeline::{Band, Design, Mode, Start};
fn bandpass(lo: f64, hi: f64) -> Node {
Node::Iir { band: Band::Bandpass, cutoff: vec![lo, hi], order: 3, design: Design::Butterworth, ripple_db: None, mode: Mode::ForwardBackward, start: Start::Rest }
}
fn doc() -> SessionDoc {
let view = ViewDoc {
id: "v1".into(),
name: "probe A".into(),
recording: RecordingRef { path: "/data/rec.bin".into(), source: None, channels: Some(vec![0, 1, 2]) },
ops: vec![
OpDoc { id: "iir".into(), enabled: true, node: bandpass(300.0, 6000.0) },
OpDoc { id: "cmr".into(), enabled: false, node: Node::Cmr {} },
],
display: DisplayDoc::default(),
plugins: BTreeMap::new(),
};
SessionDoc { views: vec![view], active_view: Some("v1".into()), ..SessionDoc::default() }.normalized()
}
#[test]
fn test_json_round_trip() {
let d = doc();
assert_eq!(d.graph.nodes.len(), 4, "source, two ops, view");
assert_eq!(SessionDoc::from_json(&d.to_json()).unwrap(), d);
}
#[test]
fn test_defaults_are_filled() {
let text = r#"{"dspk_session": 1, "active_view": "v1",
"views": [{"id": "v1", "name": "probe A", "recording": {"path": "/data/rec.bin"},
"ops": [{"id": "hp", "node": {"op": "iir", "band": "highpass", "cutoff": [300.0]}}]}]}"#;
let d = SessionDoc::from_json(text).unwrap();
assert_eq!(d.views[0].ops[0].id, "hp");
assert!(d.views[0].ops[0].enabled);
assert_eq!(d.views[0].display, DisplayDoc::default());
}
#[test]
fn test_unknown_version_and_fields_fail() {
assert!(matches!(SessionDoc::from_json(r#"{"dspk_session": 2}"#), Err(SessionError::Version(2))));
assert!(SessionDoc::from_json(r#"{"dspk_session": 1, "viewz": []}"#).is_err());
}
#[test]
fn test_pipeline_of_a_view_skips_disabled_ops() {
let spec = doc().views[0].pipeline("/out/x.zarr".into(), OutputFormat::Zarr);
let chain = spec.chain().unwrap();
assert_eq!(chain.stages.iter().map(|(id, _)| *id).collect::<Vec<_>>(), ["iir"]);
match chain.read.1 {
Node::Read { channels, .. } => assert_eq!(channels.as_deref(), Some(&[0, 1, 2][..])),
_ => unreachable!(),
}
}
#[test]
fn test_ids() {
let d = doc();
assert_eq!(d.new_view_id(), "v2");
assert_eq!(d.views[0].new_op_id(&bandpass(1.0, 2.0)), "iir1");
assert_eq!(d.views[0].new_op_id(&Node::Car {}), "car");
assert_eq!(d.new_op_id(&bandpass(1.0, 2.0)), "iir1");
}
#[test]
fn test_schema_names_the_document() {
assert!(SessionDoc::json_schema().contains("dspk_session"));
}
}