Skip to main content

dspk_ops/filter/design/
sos.rs

1use super::OpsError;
2
3/// Relative impulse-response amplitude below which a filter is considered settled.
4pub const DEFAULT_SETTLING_TOLERANCE: f64 = 1e-3;
5
6/// Extra samples simulated past twice the pole-radius bound when measuring settling.
7const SETTLING_HORIZON_MARGIN: usize = 64;
8
9/// Largest `|a0 − 1|` accepted as a normalized section.
10const A0_TOLERANCE: f64 = 1e-12;
11
12/// Pole radius below which a section is treated as pole-free (settles in two samples).
13const NEGLIGIBLE_POLE_RADIUS: f64 = 1e-12;
14
15/// One second-order section `b0 + b1 z⁻¹ + b2 z⁻² / (1 + a1 z⁻¹ + a2 z⁻²)`, `a[0] = 1`.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Section {
18    pub b: [f64; 3],
19    pub a: [f64; 3],
20}
21
22impl Section {
23    /// Largest pole radius of the section.
24    pub fn pole_radius(&self) -> f64 {
25        let (a1, a2) = (self.a[1], self.a[2]);
26        let disc = a1 * a1 - 4.0 * a2;
27        if disc < 0.0 {
28            a2.sqrt()
29        } else {
30            let sq = disc.sqrt();
31            ((-a1 + sq) / 2.0).abs().max(((-a1 - sq) / 2.0).abs())
32        }
33    }
34
35    /// Steady-state direct-form-II-transposed state for a unit step input (`lfilter_zi`).
36    fn step_zi(&self) -> [f64; 2] {
37        let [b0, b1, b2] = self.b;
38        let [_, a1, a2] = self.a;
39        let r0 = b1 - a1 * b0;
40        let r1 = b2 - a2 * b0;
41        let z0 = (r0 + r1) / (1.0 + a1 + a2);
42        [z0, r1 - a2 * z0]
43    }
44
45    fn dc_gain(&self) -> f64 {
46        self.b.iter().sum::<f64>() / self.a.iter().sum::<f64>()
47    }
48
49    /// The same transfer function as a trapezoidal state-variable filter (Cytomic SVF):
50    /// `[a1, a2, a3, m0, m1, m2]` with `g = tan(ω/2)`-style normalization.
51    ///
52    /// Substituting `z⁻¹ = (1 − s)/(1 + s)` gives `N(s) / D(s)`; normalizing `D` to
53    /// `g²(s'² + k·s' + 1)` with `s = g·s'` yields the SVF's `g`, `k`, and output mix `m0·v0 + m1·v1
54    /// + m2·v2`. Valid for any stable section (`D` then has positive coefficients).
55    pub fn svf(&self) -> [f64; 6] {
56        let [b0, b1, b2] = self.b;
57        let [_, a1, a2] = self.a;
58        let (n0, n1, n2) = (b0 + b1 + b2, 2.0 * (b0 - b2), b0 - b1 + b2);
59        let (d0, d1, d2) = (1.0 + a1 + a2, 2.0 * (1.0 - a2), 1.0 - a1 + a2);
60        let g = (d0 / d2).sqrt();
61        let k = d1 / (d2 * g);
62        let m0 = n2 / d2;
63        let m1 = n1 / (d2 * g) - m0 * k;
64        let m2 = n0 / (d2 * g * g) - m0;
65        let c1 = 1.0 / (1.0 + g * (g + k));
66        let c2 = g * c1;
67        [c1, c2, g * c2, m0, m1, m2]
68    }
69}
70
71/// Cascade of second-order sections.
72#[derive(Debug, Clone, PartialEq)]
73pub struct Sos {
74    pub sections: Vec<Section>,
75}
76
77impl Sos {
78    pub fn new(sections: Vec<Section>) -> Self {
79        Self { sections }
80    }
81
82    pub fn len(&self) -> usize {
83        self.sections.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.sections.is_empty()
88    }
89
90    /// Checks that sections are finite, normalized (`a0 = 1`) and stable.
91    ///
92    /// # Errors
93    ///
94    /// [`OpsError::InvalidSections`] when there are no sections, a coefficient is not finite,
95    /// `a0 ≠ 1`, or a pole lies on or outside the unit circle.
96    pub fn validate(&self) -> Result<(), OpsError> {
97        let ok = !self.sections.is_empty()
98            && self.sections.iter().all(|s| {
99                s.b.iter().chain(s.a.iter()).all(|c| c.is_finite())
100                    && (s.a[0] - 1.0).abs() < A0_TOLERANCE
101                    && s.pole_radius() < 1.0
102            });
103        if ok { Ok(()) } else { Err(OpsError::InvalidSections) }
104    }
105
106    /// Per-section steady-state initial conditions for a unit step (`sosfilt_zi`).
107    /// Multiply by the first input sample to start a pass without a transient.
108    pub fn zi(&self) -> Vec<[f64; 2]> {
109        let mut scale = 1.0;
110        self.sections
111            .iter()
112            .map(|s| {
113                let [z0, z1] = s.step_zi();
114                let zi = [scale * z0, scale * z1];
115                scale *= s.dc_gain();
116                zi
117            })
118            .collect()
119    }
120
121    /// Settling length in samples: the first `t` after which the impulse response tail holds at
122    /// most `tol` of its total L1 mass (`Σ_{k≥t}|h[k]| ≤ tol · Σ|h[k]|`). For any input bounded by
123    /// `A`, a chunk edge then perturbs samples `t` or more away by at most `tol · A · Σ|h|`.
124    ///
125    /// Measured on the designed cascade in f64; simulated up to twice the pole-radius bound
126    /// ([`Self::pole_settling_bound`]), which is always longer.
127    pub fn settling_samples(&self, tol: f64) -> usize {
128        let horizon = 2 * self.pole_settling_bound(tol) + SETTLING_HORIZON_MARGIN;
129        let mut impulse = vec![0.0; horizon];
130        impulse[0] = 1.0;
131        let h = self.filter(&impulse, false);
132        let total: f64 = h.iter().map(|v| v.abs()).sum();
133        let mut tail = 0.0;
134        for (t, v) in h.iter().enumerate().rev() {
135            tail += v.abs();
136            if tail > tol * total {
137                return t + 1;
138            }
139        }
140        0
141    }
142
143    /// Conservative settling bound from pole radii: `ceil(ln(tol) / ln(r))` per section (largest
144    /// pole radius `r`), summed over the cascade.
145    pub fn pole_settling_bound(&self, tol: f64) -> usize {
146        self.sections
147            .iter()
148            .map(|s| {
149                let r = s.pole_radius();
150                if r < NEGLIGIBLE_POLE_RADIUS {
151                    2
152                } else {
153                    (tol.ln() / r.ln()).ceil().max(2.0) as usize
154                }
155            })
156            .sum()
157    }
158
159    /// DC gain of the whole cascade.
160    pub fn dc_gain(&self) -> f64 {
161        self.sections.iter().map(Section::dc_gain).product()
162    }
163
164    /// Host f64 reference of a forward pass (`sosfilt`) starting from `zi · x[0]` when
165    /// `steady_state` is set, else from rest.
166    pub fn filter(&self, x: &[f64], steady_state: bool) -> Vec<f64> {
167        let x0 = x.first().copied().unwrap_or(0.0);
168        let mut state: Vec<[f64; 2]> = if steady_state {
169            self.zi().into_iter().map(|[a, b]| [a * x0, b * x0]).collect()
170        } else {
171            vec![[0.0; 2]; self.sections.len()]
172        };
173        x.iter()
174            .map(|&xi| {
175                let mut v = xi;
176                for (s, z) in self.sections.iter().zip(state.iter_mut()) {
177                    let y = s.b[0] * v + z[0];
178                    z[0] = s.b[1] * v - s.a[1] * y + z[1];
179                    z[1] = s.b[2] * v - s.a[2] * y;
180                    v = y;
181                }
182                v
183            })
184            .collect()
185    }
186
187    /// Host f64 reference of zero-phase filtering (`sosfiltfilt` with `padtype="odd"`).
188    /// `padlen` is clamped to `x.len() - 1`.
189    pub fn filtfilt(&self, x: &[f64], padlen: usize) -> Vec<f64> {
190        let n = x.len();
191        if n == 0 {
192            return Vec::new();
193        }
194        let p = padlen.min(n - 1);
195        let mut ext = Vec::with_capacity(n + 2 * p);
196        ext.extend((1..=p).rev().map(|k| 2.0 * x[0] - x[k]));
197        ext.extend_from_slice(x);
198        ext.extend((1..=p).map(|k| 2.0 * x[n - 1] - x[n - 1 - k]));
199        let mut y = self.filter(&ext, true);
200        y.reverse();
201        let mut y = self.filter(&y, true);
202        y.reverse();
203        y[p..p + n].to_vec()
204    }
205}