modewise_operations/
spectrum.rs

1use modewise_pipeline::{
2    Artifact, ArtifactKind, Inputs, Operation, OperationDescriptor, Outputs, Port, resolve_params,
3};
4use modewise_spectral::TimeFrequencyTransform;
5use modewise_stft::{Detrend, Welch, Window};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::input;
11
12#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy)]
13#[serde(rename_all = "snake_case")]
14enum WindowName {
15    Boxcar,
16    Hann,
17}
18
19#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy)]
20#[serde(rename_all = "snake_case")]
21enum DetrendName {
22    None,
23    Constant,
24    Linear,
25}
26
27/// Defaults are scipy.signal.periodogram's: the whole run, boxcar window, mean removed.
28#[derive(Serialize, Deserialize, JsonSchema)]
29#[serde(deny_unknown_fields)]
30struct Params {
31    /// Welch segment length in seconds; the whole run when absent.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    segment_s: Option<f64>,
34    /// Overlap between segments in seconds; half a segment when absent.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    overlap_s: Option<f64>,
37    /// `boxcar` or `hann` (periodic).
38    #[serde(default = "boxcar")]
39    window: WindowName,
40    /// Per segment: `none`, `constant` (mean) or `linear`.
41    #[serde(default = "constant")]
42    detrend: DetrendName,
43}
44
45fn boxcar() -> WindowName {
46    WindowName::Boxcar
47}
48
49fn constant() -> DetrendName {
50    DetrendName::Constant
51}
52
53impl Params {
54    fn check(&self) -> Result<(), String> {
55        for (name, value) in [("segment_s", self.segment_s), ("overlap_s", self.overlap_s)] {
56            if value.is_some_and(|v| !(v.is_finite() && v >= 0.0)) {
57                return Err(format!("`{name}` must be finite and non-negative"));
58            }
59        }
60        if self.overlap_s.is_some() && self.segment_s.is_none() {
61            return Err("`overlap_s` needs `segment_s`".into());
62        }
63        Ok(())
64    }
65
66    fn method(&self, fs_hz: f64) -> Welch {
67        let samples = |s: f64| (s * fs_hz).round() as usize;
68        Welch {
69            segment_len: self.segment_s.map(samples),
70            overlap: self.overlap_s.map(samples),
71            window: match self.window {
72                WindowName::Boxcar => Window::Boxcar,
73                WindowName::Hann => Window::Hann,
74            },
75            detrend: match self.detrend {
76                DetrendName::None => Detrend::None,
77                DetrendName::Constant => Detrend::Constant,
78                DetrendName::Linear => Detrend::Linear,
79            },
80        }
81    }
82}
83
84/// `spectrum`: power spectral density of each channel, one frame (periodogram or Welch).
85pub struct SpectrumOperation;
86
87impl Operation for SpectrumOperation {
88    fn summary(&self) -> &'static str {
89        "Power spectral density per channel as one frame: a periodogram, or Welch's method with segments (as scipy.signal)."
90    }
91
92    fn params_schema(&self) -> serde_json::Value {
93        modewise_pipeline::schema_of::<Params>()
94    }
95
96    fn descriptor(&self) -> OperationDescriptor {
97        OperationDescriptor {
98            name: "spectrum",
99            version: env!("CARGO_PKG_VERSION"),
100            inputs: &[Port {
101                name: "signal",
102                kinds: &[ArtifactKind::Series],
103            }],
104            outputs: &[Port {
105                name: "power",
106                kinds: &[ArtifactKind::TimeFrequency],
107            }],
108        }
109    }
110
111    fn resolve(&self, params: Value) -> Result<Value, String> {
112        let (typed, resolved) = resolve_params::<Params>(params)?;
113        typed.check()?;
114        Ok(resolved)
115    }
116
117    fn run(&self, params: &Value, inputs: &Inputs) -> Result<Outputs, String> {
118        let typed = serde_json::from_value::<Params>(params.clone()).map_err(|e| e.to_string())?;
119        let Artifact::Series(signal) = input(inputs, "signal")? else {
120            return Err("input \"signal\" must be a series".into());
121        };
122        let map = typed
123            .method(signal.sampling_rate().hz())
124            .transform(signal)
125            .map_err(|e| e.to_string())?;
126        Ok(Outputs::from([(
127            "power".into(),
128            Artifact::TimeFrequency(map),
129        )]))
130    }
131}