Skip to main content

ferray_window/windows/
mod.rs

1// ferray-window: Window functions for signal processing and spectral analysis
2//
3// Implements NumPy-equivalent window functions: bartlett, blackman, hamming,
4// hanning, and kaiser. Each returns an Array1<f64> of the specified length M.
5
6// Window-function coefficients (Blackman 0.42/0.5/0.08, Hamming 0.54/0.46,
7// Kaiser modified-Bessel I0 series) are textbook constants reproduced
8// without separators to match the reference papers / NumPy source.
9#![allow(clippy::unreadable_literal)]
10
11use ferray_core::Array;
12use ferray_core::dimension::Ix1;
13use ferray_core::error::{FerrayError, FerrayResult};
14
15use std::f64::consts::PI;
16
17// Re-use the Abramowitz & Stegun Bessel I_0 polynomial approximation
18// from ferray-ufunc instead of shipping a second copy (see #530).
19// The previous copy had already drifted in whitespace; funnelling
20// through the canonical crate keeps both the window code and the
21// ufunc in sync.
22use ferray_ufunc::ops::special::bessel_i0_scalar;
23
24/// Build a window array of length `m` by evaluating `f` at every sample
25/// `0..m`. Handles the shared `m == 0` / `m == 1` edge cases that every
26/// NumPy-equivalent window has (see issue #292 for the original
27/// five-way copy of this pattern).
28#[inline]
29fn gen_window<F: FnMut(usize) -> f64>(m: usize, mut f: F) -> FerrayResult<Array<f64, Ix1>> {
30    if m == 0 {
31        return Array::from_vec(Ix1::new([0]), vec![]);
32    }
33    if m == 1 {
34        return Array::from_vec(Ix1::new([1]), vec![1.0]);
35    }
36    let mut data = Vec::with_capacity(m);
37    for n in 0..m {
38        data.push(f(n));
39    }
40    Array::from_vec(Ix1::new([m]), data)
41}
42
43/// Return the Bartlett (triangular) window of length `m`.
44///
45/// The Bartlett window is defined as:
46///   w(n) = 2/(M-1) * ((M-1)/2 - |n - (M-1)/2|)
47///
48/// This is equivalent to `numpy.bartlett(M)`.
49///
50/// # Errors
51/// Returns an error only if internal array construction fails.
52pub fn bartlett(m: usize) -> FerrayResult<Array<f64, Ix1>> {
53    let half = (m.saturating_sub(1)) as f64 / 2.0;
54    gen_window(m, |n| 1.0 - ((n as f64 - half) / half).abs())
55}
56
57/// Return the Blackman window of length `m`.
58///
59/// The Blackman window is defined as:
60///   w(n) = 0.42 - 0.5 * cos(2*pi*n/(M-1)) + 0.08 * cos(4*pi*n/(M-1))
61///
62/// This is equivalent to `numpy.blackman(M)`.
63///
64/// # Errors
65/// Returns an error only if internal array construction fails.
66pub fn blackman(m: usize) -> FerrayResult<Array<f64, Ix1>> {
67    let denom = (m.saturating_sub(1)) as f64;
68    gen_window(m, |n| {
69        let x = n as f64;
70        0.08f64.mul_add(
71            (4.0 * PI * x / denom).cos(),
72            0.5f64.mul_add(-(2.0 * PI * x / denom).cos(), 0.42),
73        )
74    })
75}
76
77/// Return the Hamming window of length `m`.
78///
79/// The Hamming window is defined as:
80///   w(n) = 0.54 - 0.46 * cos(2*pi*n/(M-1))
81///
82/// This is equivalent to `numpy.hamming(M)`.
83///
84/// # Errors
85/// Returns an error only if internal array construction fails.
86pub fn hamming(m: usize) -> FerrayResult<Array<f64, Ix1>> {
87    let denom = (m.saturating_sub(1)) as f64;
88    gen_window(m, |n| {
89        0.46f64.mul_add(-(2.0 * PI * n as f64 / denom).cos(), 0.54)
90    })
91}
92
93/// Return the Hann (Hanning) window of length `m`.
94///
95/// The Hann window is defined as:
96///   w(n) = 0.5 * (1 - cos(2*pi*n/(M-1)))
97///
98/// `NumPy` calls this function `hanning`. This is equivalent to `numpy.hanning(M)`.
99///
100/// # Errors
101/// Returns an error only if internal array construction fails.
102pub fn hanning(m: usize) -> FerrayResult<Array<f64, Ix1>> {
103    let denom = (m.saturating_sub(1)) as f64;
104    gen_window(m, |n| 0.5 * (1.0 - (2.0 * PI * n as f64 / denom).cos()))
105}
106
107/// Return the Kaiser window of length `m` with shape parameter `beta`.
108///
109/// The Kaiser window is defined as:
110///   w(n) = `I_0(beta` * sqrt(1 - ((2n/(M-1)) - 1)^2)) / `I_0(beta)`
111///
112/// where `I_0` is the modified Bessel function of the first kind, order 0.
113///
114/// This is equivalent to `numpy.kaiser(M, beta)`.
115///
116/// # Edge Cases
117/// - `m == 0`: returns an empty array.
118/// - `m == 1`: returns `[1.0]`.
119///
120/// # Errors
121/// Returns `FerrayError::InvalidValue` if `beta` is NaN or if `beta`
122/// exceeds the range where `I_0(beta)` can be computed in f64. The
123/// asymptotic branch uses `exp(beta) / sqrt(beta)` internally, so
124/// `|beta| > ~709` overflows to infinity and the normalized kaiser
125/// window would reduce to `Inf / Inf = NaN` for every sample
126/// (#294).
127pub fn kaiser(m: usize, beta: f64) -> FerrayResult<Array<f64, Ix1>> {
128    if beta.is_nan() {
129        return Err(FerrayError::invalid_value("kaiser: beta must not be NaN"));
130    }
131    // I_0 is an even function, so kaiser(m, -beta) == kaiser(m, beta).
132    // Accept negative beta for NumPy compatibility.
133    let beta = beta.abs();
134    // Reject beta values where `exp(beta)` overflows f64 (ln(f64::MAX)
135    // ≈ 709.78). We use a slightly conservative threshold so the
136    // clipped output stays finite even after the asymptotic-series
137    // polynomial corrections.
138    const BETA_OVERFLOW_THRESHOLD: f64 = 708.0;
139    if beta > BETA_OVERFLOW_THRESHOLD {
140        return Err(FerrayError::invalid_value(format!(
141            "kaiser: |beta| = {beta} exceeds the safe range ({BETA_OVERFLOW_THRESHOLD}) \
142             for f64 I_0; the window would be NaN everywhere"
143        )));
144    }
145    let i0_beta = bessel_i0_scalar::<f64>(beta);
146    let alpha = (m.saturating_sub(1)) as f64 / 2.0;
147    gen_window(m, |n| {
148        let t = (n as f64 - alpha) / alpha;
149        let arg = beta * (1.0 - t * t).max(0.0).sqrt();
150        bessel_i0_scalar::<f64>(arg) / i0_beta
151    })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    // Helper: compare two slices within tolerance
159    fn assert_close(actual: &[f64], expected: &[f64], tol: f64) {
160        assert_eq!(
161            actual.len(),
162            expected.len(),
163            "length mismatch: {} vs {}",
164            actual.len(),
165            expected.len()
166        );
167        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
168            assert!(
169                (a - e).abs() <= tol,
170                "element {i}: {a} vs {e} (diff = {})",
171                (a - e).abs()
172            );
173        }
174    }
175
176    // -----------------------------------------------------------------------
177    // Edge cases: M=0 and M=1
178    // -----------------------------------------------------------------------
179
180    #[test]
181    fn bartlett_m0() {
182        let w = bartlett(0).unwrap();
183        assert_eq!(w.shape(), &[0]);
184    }
185
186    #[test]
187    fn bartlett_m1() {
188        let w = bartlett(1).unwrap();
189        assert_eq!(w.as_slice().unwrap(), &[1.0]);
190    }
191
192    #[test]
193    fn blackman_m0() {
194        let w = blackman(0).unwrap();
195        assert_eq!(w.shape(), &[0]);
196    }
197
198    #[test]
199    fn blackman_m1() {
200        let w = blackman(1).unwrap();
201        assert_eq!(w.as_slice().unwrap(), &[1.0]);
202    }
203
204    #[test]
205    fn hamming_m0() {
206        let w = hamming(0).unwrap();
207        assert_eq!(w.shape(), &[0]);
208    }
209
210    #[test]
211    fn hamming_m1() {
212        let w = hamming(1).unwrap();
213        assert_eq!(w.as_slice().unwrap(), &[1.0]);
214    }
215
216    #[test]
217    fn hanning_m0() {
218        let w = hanning(0).unwrap();
219        assert_eq!(w.shape(), &[0]);
220    }
221
222    #[test]
223    fn hanning_m1() {
224        let w = hanning(1).unwrap();
225        assert_eq!(w.as_slice().unwrap(), &[1.0]);
226    }
227
228    #[test]
229    fn kaiser_m0() {
230        let w = kaiser(0, 14.0).unwrap();
231        assert_eq!(w.shape(), &[0]);
232    }
233
234    #[test]
235    fn kaiser_m1() {
236        let w = kaiser(1, 14.0).unwrap();
237        assert_eq!(w.as_slice().unwrap(), &[1.0]);
238    }
239
240    #[test]
241    fn kaiser_negative_beta() {
242        // Negative beta should produce the same result as positive beta (I0 is even)
243        let pos = kaiser(5, 1.0).unwrap();
244        let neg = kaiser(5, -1.0).unwrap();
245        assert_eq!(pos.as_slice().unwrap(), neg.as_slice().unwrap());
246    }
247
248    #[test]
249    fn kaiser_nan_beta() {
250        assert!(kaiser(5, f64::NAN).is_err());
251    }
252
253    // -----------------------------------------------------------------------
254    // AC-1: bartlett(5) matches np.bartlett(5) to within 4 ULPs
255    // -----------------------------------------------------------------------
256    // np.bartlett(5) = [0.0, 0.5, 1.0, 0.5, 0.0]
257    #[test]
258    fn bartlett_5_ac1() {
259        let w = bartlett(5).unwrap();
260        let expected = [0.0, 0.5, 1.0, 0.5, 0.0];
261        assert_close(w.as_slice().unwrap(), &expected, 1e-14);
262    }
263
264    // -----------------------------------------------------------------------
265    // AC-2: kaiser(5, 14.0) matches np.kaiser(5, 14.0) to within 4 ULPs
266    // -----------------------------------------------------------------------
267    // np.kaiser(5, 14.0) = [7.72686684e-06, 1.64932188e-01, 1.0, 1.64932188e-01, 7.72686684e-06]
268    #[test]
269    fn kaiser_5_14_ac2() {
270        let w = kaiser(5, 14.0).unwrap();
271        let s = w.as_slice().unwrap();
272        assert_eq!(s.len(), 5);
273        // NumPy reference values (verified via np.kaiser(5, 14.0))
274        let expected: [f64; 5] = [
275            7.72686684e-06,
276            1.64932188e-01,
277            1.0,
278            1.64932188e-01,
279            7.72686684e-06,
280        ];
281        // Bessel polynomial approximation has limited precision (~6 digits)
282        for (i, (&a, &e)) in s.iter().zip(expected.iter()).enumerate() {
283            let tol = if e.abs() < 1e-4 { 1e-8 } else { 1e-6 };
284            assert!(
285                (a - e).abs() <= tol,
286                "kaiser element {i}: {a} vs {e} (diff = {})",
287                (a - e).abs()
288            );
289        }
290    }
291
292    // -----------------------------------------------------------------------
293    // AC-3: All 5 window functions return correct length and match NumPy fixtures
294    // -----------------------------------------------------------------------
295
296    // np.blackman(5) = [-1.38777878e-17, 3.40000000e-01, 1.00000000e+00, 3.40000000e-01, -1.38777878e-17]
297    #[test]
298    fn blackman_5() {
299        let w = blackman(5).unwrap();
300        assert_eq!(w.shape(), &[5]);
301        let s = w.as_slice().unwrap();
302        let expected = [
303            -1.38777878e-17,
304            3.40000000e-01,
305            1.00000000e+00,
306            3.40000000e-01,
307            -1.38777878e-17,
308        ];
309        assert_close(s, &expected, 1e-10);
310    }
311
312    // np.hamming(5) = [0.08, 0.54, 1.0, 0.54, 0.08]
313    #[test]
314    fn hamming_5() {
315        let w = hamming(5).unwrap();
316        assert_eq!(w.shape(), &[5]);
317        let s = w.as_slice().unwrap();
318        let expected = [0.08, 0.54, 1.0, 0.54, 0.08];
319        assert_close(s, &expected, 1e-14);
320    }
321
322    // np.hanning(5) = [0.0, 0.5, 1.0, 0.5, 0.0]
323    #[test]
324    fn hanning_5() {
325        let w = hanning(5).unwrap();
326        assert_eq!(w.shape(), &[5]);
327        let s = w.as_slice().unwrap();
328        let expected = [0.0, 0.5, 1.0, 0.5, 0.0];
329        assert_close(s, &expected, 1e-14);
330    }
331
332    // Larger window: np.bartlett(12)
333    #[test]
334    fn bartlett_12() {
335        let w = bartlett(12).unwrap();
336        assert_eq!(w.shape(), &[12]);
337        let s = w.as_slice().unwrap();
338        // First and last should be 0, peak near center
339        assert!((s[0] - 0.0).abs() < 1e-14);
340        assert!((s[11] - 0.0).abs() < 1e-14);
341        // Symmetric
342        for i in 0..6 {
343            assert!((s[i] - s[11 - i]).abs() < 1e-14, "symmetry at {i}");
344        }
345    }
346
347    // Symmetry test for all windows
348    #[test]
349    fn all_windows_symmetric() {
350        let m = 7;
351        let windows: Vec<Array<f64, Ix1>> = vec![
352            bartlett(m).unwrap(),
353            blackman(m).unwrap(),
354            hamming(m).unwrap(),
355            hanning(m).unwrap(),
356            kaiser(m, 5.0).unwrap(),
357        ];
358        for (idx, w) in windows.iter().enumerate() {
359            let s = w.as_slice().unwrap();
360            for i in 0..m / 2 {
361                assert!(
362                    (s[i] - s[m - 1 - i]).abs() < 1e-12,
363                    "window {idx} not symmetric at {i}"
364                );
365            }
366        }
367    }
368
369    // All windows peak at center
370    #[test]
371    fn all_windows_peak_at_center() {
372        let m = 9;
373        let windows: Vec<Array<f64, Ix1>> = vec![
374            bartlett(m).unwrap(),
375            blackman(m).unwrap(),
376            hamming(m).unwrap(),
377            hanning(m).unwrap(),
378            kaiser(m, 5.0).unwrap(),
379        ];
380        for (idx, w) in windows.iter().enumerate() {
381            let s = w.as_slice().unwrap();
382            let center = s[m / 2];
383            assert!(
384                (center - 1.0).abs() < 1e-10,
385                "window {idx} center = {center}, expected 1.0"
386            );
387        }
388    }
389
390    // Kaiser with beta=0 should be a rectangular window (all ones)
391    #[test]
392    fn kaiser_beta_zero() {
393        let w = kaiser(5, 0.0).unwrap();
394        let s = w.as_slice().unwrap();
395        for &v in s {
396            assert!((v - 1.0).abs() < 1e-10, "expected 1.0, got {v}");
397        }
398    }
399
400    #[test]
401    fn bessel_i0_scalar_zero() {
402        assert!((bessel_i0_scalar::<f64>(0.0) - 1.0).abs() < 1e-6);
403    }
404
405    #[test]
406    fn bessel_i0_scalar_known() {
407        // Issue #296: tighten the tolerances. The Abramowitz & Stegun
408        // approximation is rated ~1e-7 in the polynomial branch and
409        // ~1e-7 in the asymptotic branch per its documentation.
410        // I0(1) ~ 1.2660658777520082
411        assert!((bessel_i0_scalar::<f64>(1.0) - 1.266_065_877_752_008_2).abs() < 1e-7);
412        // I0(5) ~ 27.239871823604443 (asymptotic branch)
413        assert!((bessel_i0_scalar::<f64>(5.0) - 27.239_871_823_604_443).abs() < 1e-5);
414        // I0(10) ~ 2815.7166284662544
415        let expected_i0_10 = 2_815.716_628_466_254;
416        assert!((bessel_i0_scalar::<f64>(10.0) - expected_i0_10).abs() / expected_i0_10 < 1e-5);
417    }
418
419    // ----- Edge-length window coverage (#295) -----
420
421    #[test]
422    fn bartlett_m2_is_zeros() {
423        // Bartlett of length 2: w(n) = 1 - |2n/(M-1) - 1|
424        // n=0: 1 - |0 - 1| = 0
425        // n=1: 1 - |2 - 1| = 0
426        let w = bartlett(2).unwrap();
427        assert_eq!(w.as_slice().unwrap(), &[0.0, 0.0]);
428    }
429
430    #[test]
431    fn hanning_m2_is_zeros() {
432        // Hann of length 2: w(n) = 0.5 * (1 - cos(2*pi*n/(M-1)))
433        // n=0: 0.5 * (1 - cos(0)) = 0
434        // n=1: 0.5 * (1 - cos(2*pi)) = 0
435        let w = hanning(2).unwrap();
436        let s = w.as_slice().unwrap();
437        assert!(s[0].abs() < 1e-14);
438        assert!(s[1].abs() < 1e-14);
439    }
440
441    #[test]
442    fn bartlett_even_length_is_symmetric() {
443        // Even-length windows have no single "center"; symmetry holds
444        // around the midpoint between indices (M/2 - 1) and (M/2).
445        for &m in &[4usize, 6, 8, 10] {
446            let w = bartlett(m).unwrap();
447            let s = w.as_slice().unwrap();
448            for i in 0..m / 2 {
449                assert!(
450                    (s[i] - s[m - 1 - i]).abs() < 1e-14,
451                    "bartlett({m}) not symmetric at {i}"
452                );
453            }
454        }
455    }
456
457    #[test]
458    fn blackman_even_length_is_symmetric() {
459        for &m in &[4usize, 6, 8, 10] {
460            let w = blackman(m).unwrap();
461            let s = w.as_slice().unwrap();
462            for i in 0..m / 2 {
463                assert!(
464                    (s[i] - s[m - 1 - i]).abs() < 1e-14,
465                    "blackman({m}) not symmetric at {i}"
466                );
467            }
468        }
469    }
470
471    #[test]
472    fn kaiser_large_beta_errors() {
473        // Issue #294: beta above the f64 safe range should error,
474        // not silently produce NaN.
475        assert!(kaiser(8, 800.0).is_err());
476        assert!(kaiser(8, 1000.0).is_err());
477        // 700 is still safe.
478        assert!(kaiser(8, 700.0).is_ok());
479    }
480}