modewise_operations/
hilbert.rs

1use modewise_hilbert::{Padding, Weight, hilbert_spectrum, instantaneous};
2use modewise_pipeline::{
3    Artifact, ArtifactKind, Inputs, Operation, OperationDescriptor, Outputs, Port, resolve_params,
4};
5use modewise_spectral::{FrequencyGrid, GridScale};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::input;
11
12#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, Default)]
13#[serde(rename_all = "snake_case")]
14enum PaddingName {
15    #[default]
16    Mirror,
17    None,
18}
19
20#[derive(Serialize, Deserialize, JsonSchema)]
21#[serde(deny_unknown_fields)]
22struct HilbertParams {
23    /// Extend the signal before the FFT (`mirror`); `none` matches scipy.signal.hilbert.
24    #[serde(default)]
25    padding: PaddingName,
26}
27
28/// Instantaneous amplitude and frequency of every mode (docs/time-frequency.md).
29pub struct HilbertOperation;
30
31impl Operation for HilbertOperation {
32    fn summary(&self) -> &'static str {
33        "Instantaneous amplitude and frequency of every mode from its analytic signal (docs/time-frequency.md)."
34    }
35
36    fn params_schema(&self) -> serde_json::Value {
37        modewise_pipeline::schema_of::<HilbertParams>()
38    }
39
40    fn descriptor(&self) -> OperationDescriptor {
41        OperationDescriptor {
42            name: "hilbert",
43            version: env!("CARGO_PKG_VERSION"),
44            inputs: &[Port {
45                name: "modes",
46                kinds: &[ArtifactKind::Decomposition],
47            }],
48            outputs: &[Port {
49                name: "attributes",
50                kinds: &[ArtifactKind::Instantaneous],
51            }],
52        }
53    }
54
55    fn resolve(&self, params: Value) -> Result<Value, String> {
56        resolve_params::<HilbertParams>(params).map(|(_, v)| v)
57    }
58
59    fn run(&self, params: &Value, inputs: &Inputs) -> Result<Outputs, String> {
60        let p: HilbertParams = serde_json::from_value(params.clone()).map_err(|e| e.to_string())?;
61        let Artifact::Decomposition(modes) = input(inputs, "modes")? else {
62            return Err("input \"modes\" must be a decomposition".into());
63        };
64        let padding = match p.padding {
65            PaddingName::Mirror => Padding::Mirror,
66            PaddingName::None => Padding::None,
67        };
68        let attributes = instantaneous(modes, padding).map_err(|e| e.to_string())?;
69        Ok(Outputs::from([(
70            "attributes".into(),
71            Artifact::Instantaneous(attributes),
72        )]))
73    }
74}
75
76#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, Default)]
77#[serde(rename_all = "snake_case")]
78enum ScaleName {
79    #[default]
80    Linear,
81    Log,
82}
83
84#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, Default)]
85#[serde(rename_all = "snake_case")]
86enum WeightName {
87    #[default]
88    Power,
89    Amplitude,
90}
91
92#[derive(Serialize, Deserialize, JsonSchema)]
93#[serde(deny_unknown_fields)]
94struct SpectrumParams {
95    /// Lowest edge of the frequency grid in Hz (above 0 for a log grid).
96    f_min: f64,
97    /// Highest edge of the frequency grid in Hz.
98    f_max: f64,
99    /// Number of bins, each [low, high).
100    n_bins: usize,
101    /// `linear` or `log` bins (log bins report geometric centres).
102    #[serde(default)]
103    scale: ScaleName,
104    /// `power` (amplitude²) or `amplitude`.
105    #[serde(default)]
106    weight: WeightName,
107}
108
109impl SpectrumParams {
110    fn grid(&self) -> Result<FrequencyGrid, String> {
111        let scale = match self.scale {
112            ScaleName::Linear => GridScale::Linear,
113            ScaleName::Log => GridScale::Log,
114        };
115        FrequencyGrid::new(self.f_min, self.f_max, self.n_bins, scale).map_err(|e| e.to_string())
116    }
117}
118
119/// Hilbert spectrum: power (or amplitude) binned by instantaneous frequency, summed over modes.
120pub struct HilbertSpectrumOperation;
121
122impl Operation for HilbertSpectrumOperation {
123    fn summary(&self) -> &'static str {
124        "Hilbert spectrum: instantaneous amplitude or power of the modes binned on a frequency grid over time (docs/time-frequency.md)."
125    }
126
127    fn params_schema(&self) -> serde_json::Value {
128        modewise_pipeline::schema_of::<SpectrumParams>()
129    }
130
131    fn descriptor(&self) -> OperationDescriptor {
132        OperationDescriptor {
133            name: "hilbert_spectrum",
134            version: env!("CARGO_PKG_VERSION"),
135            inputs: &[Port {
136                name: "attributes",
137                kinds: &[ArtifactKind::Instantaneous],
138            }],
139            outputs: &[Port {
140                name: "power",
141                kinds: &[ArtifactKind::TimeFrequency],
142            }],
143        }
144    }
145
146    fn resolve(&self, params: Value) -> Result<Value, String> {
147        let (typed, resolved) = resolve_params::<SpectrumParams>(params)?;
148        typed.grid()?;
149        Ok(resolved)
150    }
151
152    fn run(&self, params: &Value, inputs: &Inputs) -> Result<Outputs, String> {
153        let p: SpectrumParams =
154            serde_json::from_value(params.clone()).map_err(|e| e.to_string())?;
155        let Artifact::Instantaneous(attributes) = input(inputs, "attributes")? else {
156            return Err("input \"attributes\" must be instantaneous attributes".into());
157        };
158        let weight = match p.weight {
159            WeightName::Power => Weight::Power,
160            WeightName::Amplitude => Weight::Amplitude,
161        };
162        let spectrum =
163            hilbert_spectrum(attributes, &p.grid()?, weight).map_err(|e| e.to_string())?;
164        Ok(Outputs::from([(
165            "power".into(),
166            Artifact::TimeFrequency(spectrum.map),
167        )]))
168    }
169}