modewise_operations/
spectrum.rs1use 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#[derive(Serialize, Deserialize, JsonSchema)]
29#[serde(deny_unknown_fields)]
30struct Params {
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 segment_s: Option<f64>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 overlap_s: Option<f64>,
37 #[serde(default = "boxcar")]
39 window: WindowName,
40 #[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
84pub 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}