Skip to main content

ferray_window/windows/
mod.rs

1// ferray-window: Window functions for signal processing and spectral analysis
2//
3// Implements NumPy- and SciPy-equivalent window functions:
4//   - bartlett, blackman, hamming, hanning, kaiser (NumPy core)
5//   - cosine, exponential, gaussian, general_cosine, general_hamming,
6//     nuttall, parzen, taylor, tukey (SciPy / torch.signal.windows extras)
7// Each returns an Array1<f64> of length M.
8
9// Window-function coefficients (Blackman 0.42/0.5/0.08, Hamming 0.54/0.46,
10// Kaiser modified-Bessel I0 series) are textbook constants reproduced
11// without separators to match the reference papers / NumPy source.
12#![allow(clippy::unreadable_literal)]
13
14use ferray_core::Array;
15use ferray_core::dimension::Ix1;
16use ferray_core::error::{FerrayError, FerrayResult};
17
18use std::f64::consts::PI;
19
20// Re-use the Abramowitz & Stegun Bessel I_0 polynomial approximation
21// from ferray-ufunc instead of shipping a second copy (see #530).
22// The previous copy had already drifted in whitespace; funnelling
23// through the canonical crate keeps both the window code and the
24// ufunc in sync.
25use ferray_ufunc::ops::special::bessel_i0_scalar;
26
27/// Build a window array of length `m` by evaluating `f` at every sample
28/// `0..m`. Handles the shared `m == 0` / `m == 1` edge cases that every
29/// NumPy-equivalent window has (see issue #292 for the original
30/// five-way copy of this pattern).
31#[inline]
32fn gen_window<F: FnMut(usize) -> f64>(m: usize, mut f: F) -> FerrayResult<Array<f64, Ix1>> {
33    if m == 0 {
34        return Array::from_vec(Ix1::new([0]), vec![]);
35    }
36    if m == 1 {
37        return Array::from_vec(Ix1::new([1]), vec![1.0]);
38    }
39    let mut data = Vec::with_capacity(m);
40    for n in 0..m {
41        data.push(f(n));
42    }
43    Array::from_vec(Ix1::new([m]), data)
44}
45
46/// Return the Bartlett (triangular) window of length `m`.
47///
48/// The Bartlett window is defined as:
49///   w(n) = 2/(M-1) * ((M-1)/2 - |n - (M-1)/2|)
50///
51/// This is equivalent to `numpy.bartlett(M)`.
52///
53/// # Errors
54/// Returns an error only if internal array construction fails.
55pub fn bartlett(m: usize) -> FerrayResult<Array<f64, Ix1>> {
56    let half = (m.saturating_sub(1)) as f64 / 2.0;
57    gen_window(m, |n| 1.0 - ((n as f64 - half) / half).abs())
58}
59
60/// Return the Blackman window of length `m`.
61///
62/// The Blackman window is defined as:
63///   w(n) = 0.42 - 0.5 * cos(2*pi*n/(M-1)) + 0.08 * cos(4*pi*n/(M-1))
64///
65/// This is equivalent to `numpy.blackman(M)`.
66///
67/// # Errors
68/// Returns an error only if internal array construction fails.
69pub fn blackman(m: usize) -> FerrayResult<Array<f64, Ix1>> {
70    let denom = (m.saturating_sub(1)) as f64;
71    gen_window(m, |n| {
72        let x = n as f64;
73        0.08f64.mul_add(
74            (4.0 * PI * x / denom).cos(),
75            0.5f64.mul_add(-(2.0 * PI * x / denom).cos(), 0.42),
76        )
77    })
78}
79
80/// Return the Hamming window of length `m`.
81///
82/// The Hamming window is defined as:
83///   w(n) = 0.54 - 0.46 * cos(2*pi*n/(M-1))
84///
85/// This is equivalent to `numpy.hamming(M)`.
86///
87/// # Errors
88/// Returns an error only if internal array construction fails.
89pub fn hamming(m: usize) -> FerrayResult<Array<f64, Ix1>> {
90    let denom = (m.saturating_sub(1)) as f64;
91    gen_window(m, |n| {
92        0.46f64.mul_add(-(2.0 * PI * n as f64 / denom).cos(), 0.54)
93    })
94}
95
96/// Return the Hann (Hanning) window of length `m`.
97///
98/// The Hann window is defined as:
99///   w(n) = 0.5 * (1 - cos(2*pi*n/(M-1)))
100///
101/// `NumPy` calls this function `hanning`. This is equivalent to `numpy.hanning(M)`.
102///
103/// # Errors
104/// Returns an error only if internal array construction fails.
105pub fn hanning(m: usize) -> FerrayResult<Array<f64, Ix1>> {
106    let denom = (m.saturating_sub(1)) as f64;
107    gen_window(m, |n| 0.5 * (1.0 - (2.0 * PI * n as f64 / denom).cos()))
108}
109
110/// Return the Kaiser window of length `m` with shape parameter `beta`.
111///
112/// The Kaiser window is defined as:
113///   w(n) = `I_0(beta` * sqrt(1 - ((2n/(M-1)) - 1)^2)) / `I_0(beta)`
114///
115/// where `I_0` is the modified Bessel function of the first kind, order 0.
116///
117/// This is equivalent to `numpy.kaiser(M, beta)`.
118///
119/// # Edge Cases
120/// - `m == 0`: returns an empty array.
121/// - `m == 1`: returns `[1.0]`.
122///
123/// # Errors
124/// Returns `FerrayError::InvalidValue` if `beta` is NaN or if `beta`
125/// exceeds the range where `I_0(beta)` can be computed in f64. The
126/// asymptotic branch uses `exp(beta) / sqrt(beta)` internally, so
127/// `|beta| > ~709` overflows to infinity and the normalized kaiser
128/// window would reduce to `Inf / Inf = NaN` for every sample
129/// (#294).
130pub fn kaiser(m: usize, beta: f64) -> FerrayResult<Array<f64, Ix1>> {
131    if beta.is_nan() {
132        return Err(FerrayError::invalid_value("kaiser: beta must not be NaN"));
133    }
134    // I_0 is an even function, so kaiser(m, -beta) == kaiser(m, beta).
135    // Accept negative beta for NumPy compatibility.
136    let beta = beta.abs();
137    // Reject beta values where `exp(beta)` overflows f64 (ln(f64::MAX)
138    // ≈ 709.78). We use a slightly conservative threshold so the
139    // clipped output stays finite even after the asymptotic-series
140    // polynomial corrections.
141    const BETA_OVERFLOW_THRESHOLD: f64 = 708.0;
142    if beta > BETA_OVERFLOW_THRESHOLD {
143        return Err(FerrayError::invalid_value(format!(
144            "kaiser: |beta| = {beta} exceeds the safe range ({BETA_OVERFLOW_THRESHOLD}) \
145             for f64 I_0; the window would be NaN everywhere"
146        )));
147    }
148    let i0_beta = bessel_i0_scalar::<f64>(beta);
149    let alpha = (m.saturating_sub(1)) as f64 / 2.0;
150    gen_window(m, |n| {
151        let t = (n as f64 - alpha) / alpha;
152        let arg = beta * (1.0 - t * t).max(0.0).sqrt();
153        bessel_i0_scalar::<f64>(arg) / i0_beta
154    })
155}
156
157// ===========================================================================
158// SciPy-extended windows (cosine, exponential, gaussian, general_cosine,
159// general_hamming, nuttall, parzen, taylor, tukey).
160//
161// All are symmetric ("sym=True" in scipy parlance); a non-symmetric "periodic"
162// variant can be obtained by computing window of length m+1 and dropping the
163// last sample (NumPy convention).
164// ===========================================================================
165
166/// Half-cycle cosine window (also known as the "sine" window).
167///
168/// `w(n) = sin(pi * (n + 0.5) / M)` for `n = 0..M-1`.
169///
170/// Mirrors `scipy.signal.windows.cosine` /
171/// `torch.signal.windows.cosine`.
172pub fn cosine(m: usize) -> FerrayResult<Array<f64, Ix1>> {
173    if m == 0 {
174        return Array::from_vec(Ix1::new([0]), vec![]);
175    }
176    if m == 1 {
177        return Array::from_vec(Ix1::new([1]), vec![1.0]);
178    }
179    let mf = m as f64;
180    gen_window(m, |n| (PI * (n as f64 + 0.5) / mf).sin())
181}
182
183/// Exponentially decaying window centred on `center` with time-constant `tau`.
184///
185/// `w(n) = exp(-|n - center| / tau)`. If `center` is `None`, defaults to
186/// the geometric centre `(M - 1) / 2`. `tau` must be > 0.
187///
188/// Mirrors `scipy.signal.windows.exponential` /
189/// `torch.signal.windows.exponential`.
190pub fn exponential(m: usize, center: Option<f64>, tau: f64) -> FerrayResult<Array<f64, Ix1>> {
191    if !tau.is_finite() || tau <= 0.0 {
192        return Err(FerrayError::invalid_value(format!(
193            "exponential: tau must be positive and finite, got {tau}"
194        )));
195    }
196    let centre = center.unwrap_or((m.saturating_sub(1)) as f64 / 2.0);
197    gen_window(m, |n| (-((n as f64) - centre).abs() / tau).exp())
198}
199
200/// Gaussian window with standard deviation `std`.
201///
202/// `w(n) = exp(-((n - (M-1)/2) / std)^2 / 2)`. `std` must be > 0.
203///
204/// Mirrors `scipy.signal.windows.gaussian` /
205/// `torch.signal.windows.gaussian`.
206pub fn gaussian(m: usize, std: f64) -> FerrayResult<Array<f64, Ix1>> {
207    if !std.is_finite() || std <= 0.0 {
208        return Err(FerrayError::invalid_value(format!(
209            "gaussian: std must be positive and finite, got {std}"
210        )));
211    }
212    let centre = (m.saturating_sub(1)) as f64 / 2.0;
213    gen_window(m, |n| {
214        let z = ((n as f64) - centre) / std;
215        (-0.5 * z * z).exp()
216    })
217}
218
219/// Generalized cosine sum window: `w(n) = Σ_k (-1)^k a[k] cos(2π k n / (M-1))`.
220///
221/// `coeffs` is the slice `[a_0, a_1, a_2, ...]`. Setting `[0.5, 0.5]` gives
222/// the Hann window; `[0.42, 0.5, 0.08]` gives the classical Blackman.
223///
224/// Mirrors `scipy.signal.windows.general_cosine` /
225/// `torch.signal.windows.general_cosine`.
226pub fn general_cosine(m: usize, coeffs: &[f64]) -> FerrayResult<Array<f64, Ix1>> {
227    if coeffs.is_empty() {
228        return Err(FerrayError::invalid_value(
229            "general_cosine: coeffs must not be empty",
230        ));
231    }
232    let denom = (m.saturating_sub(1)) as f64;
233    let coeffs = coeffs.to_vec();
234    gen_window(m, |n| {
235        let nf = n as f64;
236        let mut sum = 0.0_f64;
237        for (k, &a) in coeffs.iter().enumerate() {
238            let sign = if k % 2 == 0 { 1.0 } else { -1.0 };
239            sum += sign * a * (2.0 * PI * (k as f64) * nf / denom).cos();
240        }
241        sum
242    })
243}
244
245/// Generalized Hamming window: `w(n) = α - (1 - α) cos(2π n / (M - 1))`.
246///
247/// `alpha = 0.5` gives the Hann window; `alpha = 25/46 ≈ 0.5435` gives a
248/// "perfect" Hamming (NumPy uses 0.54 by tradition).
249///
250/// Mirrors `scipy.signal.windows.general_hamming` /
251/// `torch.signal.windows.general_hamming`.
252pub fn general_hamming(m: usize, alpha: f64) -> FerrayResult<Array<f64, Ix1>> {
253    if !alpha.is_finite() {
254        return Err(FerrayError::invalid_value(format!(
255            "general_hamming: alpha must be finite, got {alpha}"
256        )));
257    }
258    let denom = (m.saturating_sub(1)) as f64;
259    gen_window(m, |n| {
260        alpha - (1.0 - alpha) * (2.0 * PI * (n as f64) / denom).cos()
261    })
262}
263
264/// 4-term Nuttall window with continuous first derivative.
265///
266/// Coefficients [0.3635819, 0.4891775, 0.1365995, 0.0106411] (Nuttall 1981,
267/// "minimum 4-term Blackman-Harris with continuous first derivative").
268///
269/// Mirrors `scipy.signal.windows.nuttall` /
270/// `torch.signal.windows.nuttall`.
271pub fn nuttall(m: usize) -> FerrayResult<Array<f64, Ix1>> {
272    general_cosine(m, &[0.3635819, 0.4891775, 0.1365995, 0.0106411])
273}
274
275/// Parzen (de la Vallée Poussin) window: a piecewise-cubic B-spline.
276///
277/// Mirrors `scipy.signal.windows.parzen` / `torch.signal.windows.parzen`.
278pub fn parzen(m: usize) -> FerrayResult<Array<f64, Ix1>> {
279    if m == 0 {
280        return Array::from_vec(Ix1::new([0]), vec![]);
281    }
282    if m == 1 {
283        return Array::from_vec(Ix1::new([1]), vec![1.0]);
284    }
285    // Parzen formula: |x| in [0, M/4]: 1 - 6 r^2 + 6 |r|^3,
286    //                |x| in (M/4, M/2]: 2 (1 - |r|)^3, where r = x / (M/2).
287    let half = m as f64 / 2.0;
288    gen_window(m, |n| {
289        let x = (n as f64) - (m as f64 - 1.0) / 2.0;
290        let r = x.abs() / half;
291        if r <= 0.5 {
292            1.0 - 6.0 * r * r + 6.0 * r * r * r
293        } else if r <= 1.0 {
294            let one_minus_r = 1.0 - r;
295            2.0 * one_minus_r * one_minus_r * one_minus_r
296        } else {
297            0.0
298        }
299    })
300}
301
302/// Taylor window with `nbar` near-side lobes and a sidelobe level of `sll`
303/// dB below the main lobe.
304///
305/// `nbar` (typically 4) controls how many sidelobes are constrained at the
306/// design level; `sll` (typically 30) is the desired peak sidelobe
307/// attenuation in **positive** dB. When `norm` is true the window is
308/// normalised so `w[(M-1)/2] = 1`.
309///
310/// Implementation follows Carrara & Goodman, "Symmetric Taylor Window"
311/// (Synthetic Aperture Radar, 1995). For the radar/array-processing
312/// applications where Taylor windows are used, `nbar = 4`, `sll = 30`
313/// gives equiripple sidelobes ~30 dB down — the usual default.
314///
315/// Mirrors `scipy.signal.windows.taylor` / `torch.signal.windows.taylor`.
316pub fn taylor(
317    m: usize,
318    nbar: usize,
319    sll: f64,
320    norm: bool,
321) -> FerrayResult<Array<f64, Ix1>> {
322    if m == 0 {
323        return Array::from_vec(Ix1::new([0]), vec![]);
324    }
325    if m == 1 {
326        return Array::from_vec(Ix1::new([1]), vec![1.0]);
327    }
328    if nbar == 0 {
329        return Err(FerrayError::invalid_value("taylor: nbar must be >= 1"));
330    }
331    if !sll.is_finite() {
332        return Err(FerrayError::invalid_value("taylor: sll must be finite"));
333    }
334    // R = 10^(sll/20), B = (1/π) acosh(R)
335    let r = 10.0_f64.powf(sll / 20.0);
336    let b = r.acosh() / PI;
337    let nbar_f = nbar as f64;
338    // sigma^2 chosen so the (nbar)-th zero of the Taylor pattern is at
339    // n = nbar (Carrara & Goodman eq. 13).
340    let sigma2 = (nbar_f * nbar_f) / (b * b + (nbar_f - 0.5) * (nbar_f - 0.5));
341
342    // Compute coefficients F_m for m = 1..nbar-1.
343    let mut f_coeffs = Vec::with_capacity(nbar.saturating_sub(1));
344    for mm in 1..nbar {
345        let mmf = mm as f64;
346        // Numerator: ((-1)^(m+1) / 2) * Π_{n=1..nbar-1} [1 - m^2 / (sigma^2 * (B^2 + (n - 0.5)^2))]
347        // Denominator: Π_{n=1..nbar-1, n != m} [1 - m^2 / n^2]
348        let mut num = 1.0_f64;
349        for n in 1..nbar {
350            let nf = n as f64;
351            num *= 1.0 - mmf * mmf / (sigma2 * (b * b + (nf - 0.5) * (nf - 0.5)));
352        }
353        let sign = if mm % 2 == 0 { -1.0 } else { 1.0 };
354        let mut den = 1.0_f64;
355        for n in 1..nbar {
356            if n == mm {
357                continue;
358            }
359            let nf = n as f64;
360            den *= 1.0 - mmf * mmf / (nf * nf);
361        }
362        // The 0.5 prefactor: F_0 = 1 contributes the constant term, and
363        // each F_m doubles when reflected about zero in the cosine sum,
364        // so we halve the inverse-Fourier coefficients here.
365        f_coeffs.push(0.5 * sign * num / den);
366    }
367
368    let denom = (m - 1) as f64;
369    let arr = gen_window(m, |n| {
370        let xn = (n as f64) - denom / 2.0;
371        let mut w = 1.0_f64;
372        for (idx, &fk) in f_coeffs.iter().enumerate() {
373            let kk = (idx + 1) as f64;
374            w += 2.0 * fk * (2.0 * PI * kk * xn / m as f64).cos();
375        }
376        w
377    })?;
378
379    if !norm {
380        return Ok(arr);
381    }
382    // Normalise so the centre value is 1.
383    let s = arr.as_slice().unwrap().to_vec();
384    let centre_val = if m % 2 == 1 {
385        s[m / 2]
386    } else {
387        // For even M, centre is between two samples — average.
388        0.5 * (s[m / 2 - 1] + s[m / 2])
389    };
390    if centre_val == 0.0 {
391        return Ok(arr); // pathological; leave un-normalised
392    }
393    let normed: Vec<f64> = s.into_iter().map(|v| v / centre_val).collect();
394    Array::from_vec(Ix1::new([m]), normed)
395}
396
397/// Tukey (cosine-tapered) window with taper ratio `alpha` ∈ [0, 1].
398///
399/// `alpha = 0` gives a rectangular window; `alpha = 1` gives a Hann
400/// window. The middle `(1 - alpha) * (M - 1)` samples are unity, and the
401/// edges are tapered with a half-cosine.
402///
403/// Mirrors `scipy.signal.windows.tukey` / `torch.signal.windows.tukey`.
404pub fn tukey(m: usize, alpha: f64) -> FerrayResult<Array<f64, Ix1>> {
405    if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
406        return Err(FerrayError::invalid_value(format!(
407            "tukey: alpha must be in [0, 1], got {alpha}"
408        )));
409    }
410    if m == 0 {
411        return Array::from_vec(Ix1::new([0]), vec![]);
412    }
413    if m == 1 {
414        return Array::from_vec(Ix1::new([1]), vec![1.0]);
415    }
416    if alpha == 0.0 {
417        return Array::from_vec(Ix1::new([m]), vec![1.0; m]);
418    }
419    let denom = (m - 1) as f64;
420    let width = alpha * denom / 2.0;
421    gen_window(m, |n| {
422        let nf = n as f64;
423        if nf < width {
424            0.5 * (1.0 + (PI * (nf / width - 1.0)).cos())
425        } else if nf <= denom - width {
426            1.0
427        } else {
428            0.5 * (1.0 + (PI * ((denom - nf) / width - 1.0)).cos())
429        }
430    })
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    // Helper: compare two slices within tolerance
438    fn assert_close(actual: &[f64], expected: &[f64], tol: f64) {
439        assert_eq!(
440            actual.len(),
441            expected.len(),
442            "length mismatch: {} vs {}",
443            actual.len(),
444            expected.len()
445        );
446        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
447            assert!(
448                (a - e).abs() <= tol,
449                "element {i}: {a} vs {e} (diff = {})",
450                (a - e).abs()
451            );
452        }
453    }
454
455    // -----------------------------------------------------------------------
456    // Edge cases: M=0 and M=1
457    // -----------------------------------------------------------------------
458
459    #[test]
460    fn bartlett_m0() {
461        let w = bartlett(0).unwrap();
462        assert_eq!(w.shape(), &[0]);
463    }
464
465    #[test]
466    fn bartlett_m1() {
467        let w = bartlett(1).unwrap();
468        assert_eq!(w.as_slice().unwrap(), &[1.0]);
469    }
470
471    #[test]
472    fn blackman_m0() {
473        let w = blackman(0).unwrap();
474        assert_eq!(w.shape(), &[0]);
475    }
476
477    #[test]
478    fn blackman_m1() {
479        let w = blackman(1).unwrap();
480        assert_eq!(w.as_slice().unwrap(), &[1.0]);
481    }
482
483    #[test]
484    fn hamming_m0() {
485        let w = hamming(0).unwrap();
486        assert_eq!(w.shape(), &[0]);
487    }
488
489    #[test]
490    fn hamming_m1() {
491        let w = hamming(1).unwrap();
492        assert_eq!(w.as_slice().unwrap(), &[1.0]);
493    }
494
495    #[test]
496    fn hanning_m0() {
497        let w = hanning(0).unwrap();
498        assert_eq!(w.shape(), &[0]);
499    }
500
501    #[test]
502    fn hanning_m1() {
503        let w = hanning(1).unwrap();
504        assert_eq!(w.as_slice().unwrap(), &[1.0]);
505    }
506
507    #[test]
508    fn kaiser_m0() {
509        let w = kaiser(0, 14.0).unwrap();
510        assert_eq!(w.shape(), &[0]);
511    }
512
513    #[test]
514    fn kaiser_m1() {
515        let w = kaiser(1, 14.0).unwrap();
516        assert_eq!(w.as_slice().unwrap(), &[1.0]);
517    }
518
519    #[test]
520    fn kaiser_negative_beta() {
521        // Negative beta should produce the same result as positive beta (I0 is even)
522        let pos = kaiser(5, 1.0).unwrap();
523        let neg = kaiser(5, -1.0).unwrap();
524        assert_eq!(pos.as_slice().unwrap(), neg.as_slice().unwrap());
525    }
526
527    #[test]
528    fn kaiser_nan_beta() {
529        assert!(kaiser(5, f64::NAN).is_err());
530    }
531
532    // -----------------------------------------------------------------------
533    // AC-1: bartlett(5) matches np.bartlett(5) to within 4 ULPs
534    // -----------------------------------------------------------------------
535    // np.bartlett(5) = [0.0, 0.5, 1.0, 0.5, 0.0]
536    #[test]
537    fn bartlett_5_ac1() {
538        let w = bartlett(5).unwrap();
539        let expected = [0.0, 0.5, 1.0, 0.5, 0.0];
540        assert_close(w.as_slice().unwrap(), &expected, 1e-14);
541    }
542
543    // -----------------------------------------------------------------------
544    // AC-2: kaiser(5, 14.0) matches np.kaiser(5, 14.0) to within 4 ULPs
545    // -----------------------------------------------------------------------
546    // np.kaiser(5, 14.0) = [7.72686684e-06, 1.64932188e-01, 1.0, 1.64932188e-01, 7.72686684e-06]
547    #[test]
548    fn kaiser_5_14_ac2() {
549        let w = kaiser(5, 14.0).unwrap();
550        let s = w.as_slice().unwrap();
551        assert_eq!(s.len(), 5);
552        // NumPy reference values (verified via np.kaiser(5, 14.0))
553        let expected: [f64; 5] = [
554            7.72686684e-06,
555            1.64932188e-01,
556            1.0,
557            1.64932188e-01,
558            7.72686684e-06,
559        ];
560        // Bessel polynomial approximation has limited precision (~6 digits)
561        for (i, (&a, &e)) in s.iter().zip(expected.iter()).enumerate() {
562            let tol = if e.abs() < 1e-4 { 1e-8 } else { 1e-6 };
563            assert!(
564                (a - e).abs() <= tol,
565                "kaiser element {i}: {a} vs {e} (diff = {})",
566                (a - e).abs()
567            );
568        }
569    }
570
571    // -----------------------------------------------------------------------
572    // AC-3: All 5 window functions return correct length and match NumPy fixtures
573    // -----------------------------------------------------------------------
574
575    // np.blackman(5) = [-1.38777878e-17, 3.40000000e-01, 1.00000000e+00, 3.40000000e-01, -1.38777878e-17]
576    #[test]
577    fn blackman_5() {
578        let w = blackman(5).unwrap();
579        assert_eq!(w.shape(), &[5]);
580        let s = w.as_slice().unwrap();
581        let expected = [
582            -1.38777878e-17,
583            3.40000000e-01,
584            1.00000000e+00,
585            3.40000000e-01,
586            -1.38777878e-17,
587        ];
588        assert_close(s, &expected, 1e-10);
589    }
590
591    // np.hamming(5) = [0.08, 0.54, 1.0, 0.54, 0.08]
592    #[test]
593    fn hamming_5() {
594        let w = hamming(5).unwrap();
595        assert_eq!(w.shape(), &[5]);
596        let s = w.as_slice().unwrap();
597        let expected = [0.08, 0.54, 1.0, 0.54, 0.08];
598        assert_close(s, &expected, 1e-14);
599    }
600
601    // np.hanning(5) = [0.0, 0.5, 1.0, 0.5, 0.0]
602    #[test]
603    fn hanning_5() {
604        let w = hanning(5).unwrap();
605        assert_eq!(w.shape(), &[5]);
606        let s = w.as_slice().unwrap();
607        let expected = [0.0, 0.5, 1.0, 0.5, 0.0];
608        assert_close(s, &expected, 1e-14);
609    }
610
611    // Larger window: np.bartlett(12)
612    #[test]
613    fn bartlett_12() {
614        let w = bartlett(12).unwrap();
615        assert_eq!(w.shape(), &[12]);
616        let s = w.as_slice().unwrap();
617        // First and last should be 0, peak near center
618        assert!((s[0] - 0.0).abs() < 1e-14);
619        assert!((s[11] - 0.0).abs() < 1e-14);
620        // Symmetric
621        for i in 0..6 {
622            assert!((s[i] - s[11 - i]).abs() < 1e-14, "symmetry at {i}");
623        }
624    }
625
626    // Symmetry test for all windows
627    #[test]
628    fn all_windows_symmetric() {
629        let m = 7;
630        let windows: Vec<Array<f64, Ix1>> = vec![
631            bartlett(m).unwrap(),
632            blackman(m).unwrap(),
633            hamming(m).unwrap(),
634            hanning(m).unwrap(),
635            kaiser(m, 5.0).unwrap(),
636        ];
637        for (idx, w) in windows.iter().enumerate() {
638            let s = w.as_slice().unwrap();
639            for i in 0..m / 2 {
640                assert!(
641                    (s[i] - s[m - 1 - i]).abs() < 1e-12,
642                    "window {idx} not symmetric at {i}"
643                );
644            }
645        }
646    }
647
648    // All windows peak at center
649    #[test]
650    fn all_windows_peak_at_center() {
651        let m = 9;
652        let windows: Vec<Array<f64, Ix1>> = vec![
653            bartlett(m).unwrap(),
654            blackman(m).unwrap(),
655            hamming(m).unwrap(),
656            hanning(m).unwrap(),
657            kaiser(m, 5.0).unwrap(),
658        ];
659        for (idx, w) in windows.iter().enumerate() {
660            let s = w.as_slice().unwrap();
661            let center = s[m / 2];
662            assert!(
663                (center - 1.0).abs() < 1e-10,
664                "window {idx} center = {center}, expected 1.0"
665            );
666        }
667    }
668
669    // Kaiser with beta=0 should be a rectangular window (all ones)
670    #[test]
671    fn kaiser_beta_zero() {
672        let w = kaiser(5, 0.0).unwrap();
673        let s = w.as_slice().unwrap();
674        for &v in s {
675            assert!((v - 1.0).abs() < 1e-10, "expected 1.0, got {v}");
676        }
677    }
678
679    #[test]
680    fn bessel_i0_scalar_zero() {
681        assert!((bessel_i0_scalar::<f64>(0.0) - 1.0).abs() < 1e-6);
682    }
683
684    #[test]
685    fn bessel_i0_scalar_known() {
686        // Issue #296: tighten the tolerances. The Abramowitz & Stegun
687        // approximation is rated ~1e-7 in the polynomial branch and
688        // ~1e-7 in the asymptotic branch per its documentation.
689        // I0(1) ~ 1.2660658777520082
690        assert!((bessel_i0_scalar::<f64>(1.0) - 1.266_065_877_752_008_2).abs() < 1e-7);
691        // I0(5) ~ 27.239871823604443 (asymptotic branch)
692        assert!((bessel_i0_scalar::<f64>(5.0) - 27.239_871_823_604_443).abs() < 1e-5);
693        // I0(10) ~ 2815.7166284662544
694        let expected_i0_10 = 2_815.716_628_466_254;
695        assert!((bessel_i0_scalar::<f64>(10.0) - expected_i0_10).abs() / expected_i0_10 < 1e-5);
696    }
697
698    // ----- Edge-length window coverage (#295) -----
699
700    #[test]
701    fn bartlett_m2_is_zeros() {
702        // Bartlett of length 2: w(n) = 1 - |2n/(M-1) - 1|
703        // n=0: 1 - |0 - 1| = 0
704        // n=1: 1 - |2 - 1| = 0
705        let w = bartlett(2).unwrap();
706        assert_eq!(w.as_slice().unwrap(), &[0.0, 0.0]);
707    }
708
709    #[test]
710    fn hanning_m2_is_zeros() {
711        // Hann of length 2: w(n) = 0.5 * (1 - cos(2*pi*n/(M-1)))
712        // n=0: 0.5 * (1 - cos(0)) = 0
713        // n=1: 0.5 * (1 - cos(2*pi)) = 0
714        let w = hanning(2).unwrap();
715        let s = w.as_slice().unwrap();
716        assert!(s[0].abs() < 1e-14);
717        assert!(s[1].abs() < 1e-14);
718    }
719
720    #[test]
721    fn bartlett_even_length_is_symmetric() {
722        // Even-length windows have no single "center"; symmetry holds
723        // around the midpoint between indices (M/2 - 1) and (M/2).
724        for &m in &[4usize, 6, 8, 10] {
725            let w = bartlett(m).unwrap();
726            let s = w.as_slice().unwrap();
727            for i in 0..m / 2 {
728                assert!(
729                    (s[i] - s[m - 1 - i]).abs() < 1e-14,
730                    "bartlett({m}) not symmetric at {i}"
731                );
732            }
733        }
734    }
735
736    #[test]
737    fn blackman_even_length_is_symmetric() {
738        for &m in &[4usize, 6, 8, 10] {
739            let w = blackman(m).unwrap();
740            let s = w.as_slice().unwrap();
741            for i in 0..m / 2 {
742                assert!(
743                    (s[i] - s[m - 1 - i]).abs() < 1e-14,
744                    "blackman({m}) not symmetric at {i}"
745                );
746            }
747        }
748    }
749
750    #[test]
751    fn kaiser_large_beta_errors() {
752        // Issue #294: beta above the f64 safe range should error,
753        // not silently produce NaN.
754        assert!(kaiser(8, 800.0).is_err());
755        assert!(kaiser(8, 1000.0).is_err());
756        // 700 is still safe.
757        assert!(kaiser(8, 700.0).is_ok());
758    }
759
760    // =======================================================================
761    // SciPy-extended windows (cosine, exponential, gaussian, general_cosine,
762    // general_hamming, nuttall, parzen, taylor, tukey).
763    // =======================================================================
764
765    fn close(a: f64, b: f64, tol: f64) -> bool {
766        (a - b).abs() < tol
767    }
768
769    // ----- cosine -----
770
771    #[test]
772    fn cosine_length_and_endpoints() {
773        // cosine(M) = sin(pi(n + 0.5)/M); endpoints are sin(pi/2M) and
774        // sin(pi(M-0.5)/M) — both small but nonzero.
775        let w = cosine(8).unwrap();
776        let s = w.as_slice().unwrap();
777        assert_eq!(s.len(), 8);
778        // Symmetric.
779        for i in 0..4 {
780            assert!(close(s[i], s[7 - i], 1e-14));
781        }
782    }
783
784    #[test]
785    fn cosine_m1_and_m0() {
786        assert_eq!(cosine(0).unwrap().shape(), &[0]);
787        assert_eq!(cosine(1).unwrap().as_slice().unwrap(), &[1.0]);
788    }
789
790    // ----- exponential -----
791
792    #[test]
793    fn exponential_centred_default() {
794        // tau=1, default centre = (M-1)/2 = 3.5 for M=8 — midpoint
795        // between samples 3 and 4.
796        let w = exponential(8, None, 1.0).unwrap();
797        let s = w.as_slice().unwrap();
798        // Symmetric.
799        for i in 0..4 {
800            assert!(close(s[i], s[7 - i], 1e-14));
801        }
802        // Centre samples have largest values.
803        let centre_max = s[3].max(s[4]);
804        for &v in s {
805            assert!(v <= centre_max + 1e-14);
806        }
807    }
808
809    #[test]
810    fn exponential_rejects_nonpositive_tau() {
811        assert!(exponential(8, None, 0.0).is_err());
812        assert!(exponential(8, None, -1.0).is_err());
813        assert!(exponential(8, None, f64::NAN).is_err());
814    }
815
816    // ----- gaussian -----
817
818    #[test]
819    fn gaussian_centre_is_one() {
820        // For odd M, centre sample is exactly 1.
821        let w = gaussian(11, 2.0).unwrap();
822        let s = w.as_slice().unwrap();
823        assert!(close(s[5], 1.0, 1e-14));
824    }
825
826    #[test]
827    fn gaussian_known_value() {
828        // gaussian(7, 1) at n=4: z = (4 - 3) / 1 = 1, exp(-0.5) = 0.6065...
829        let w = gaussian(7, 1.0).unwrap();
830        let s = w.as_slice().unwrap();
831        assert!(close(s[4], (-0.5_f64).exp(), 1e-14));
832    }
833
834    #[test]
835    fn gaussian_rejects_nonpositive_std() {
836        assert!(gaussian(8, 0.0).is_err());
837        assert!(gaussian(8, -1.0).is_err());
838    }
839
840    // ----- general_cosine -----
841
842    #[test]
843    fn general_cosine_with_hann_coeffs_matches_hann() {
844        // [0.5, 0.5] → 0.5 - 0.5 cos(2πn/(M-1)) = Hann.
845        let m = 9;
846        let gc = general_cosine(m, &[0.5, 0.5]).unwrap();
847        let hn = hanning(m).unwrap();
848        for i in 0..m {
849            assert!(close(
850                gc.as_slice().unwrap()[i],
851                hn.as_slice().unwrap()[i],
852                1e-14,
853            ));
854        }
855    }
856
857    #[test]
858    fn general_cosine_with_blackman_coeffs_matches_blackman() {
859        // [0.42, 0.5, 0.08] → classical Blackman.
860        let m = 9;
861        let gc = general_cosine(m, &[0.42, 0.5, 0.08]).unwrap();
862        let bk = blackman(m).unwrap();
863        for i in 0..m {
864            assert!(close(
865                gc.as_slice().unwrap()[i],
866                bk.as_slice().unwrap()[i],
867                1e-12,
868            ));
869        }
870    }
871
872    #[test]
873    fn general_cosine_rejects_empty_coeffs() {
874        assert!(general_cosine(8, &[]).is_err());
875    }
876
877    // ----- general_hamming -----
878
879    #[test]
880    fn general_hamming_alpha_half_matches_hann() {
881        let m = 9;
882        let gh = general_hamming(m, 0.5).unwrap();
883        let hn = hanning(m).unwrap();
884        for i in 0..m {
885            assert!(close(
886                gh.as_slice().unwrap()[i],
887                hn.as_slice().unwrap()[i],
888                1e-14,
889            ));
890        }
891    }
892
893    #[test]
894    fn general_hamming_alpha_054_matches_hamming() {
895        let m = 9;
896        let gh = general_hamming(m, 0.54).unwrap();
897        let hm = hamming(m).unwrap();
898        for i in 0..m {
899            assert!(close(
900                gh.as_slice().unwrap()[i],
901                hm.as_slice().unwrap()[i],
902                1e-14,
903            ));
904        }
905    }
906
907    // ----- nuttall -----
908
909    #[test]
910    fn nuttall_endpoints_are_small() {
911        // Nuttall is engineered to have very low sidelobes; endpoints
912        // are O(1e-3) for typical M.
913        let w = nuttall(64).unwrap();
914        let s = w.as_slice().unwrap();
915        assert!(s[0].abs() < 1e-2);
916        assert!(s[s.len() - 1].abs() < 1e-2);
917    }
918
919    #[test]
920    fn nuttall_is_symmetric() {
921        let m = 33;
922        let w = nuttall(m).unwrap();
923        let s = w.as_slice().unwrap();
924        for i in 0..m / 2 {
925            assert!(close(s[i], s[m - 1 - i], 1e-14));
926        }
927    }
928
929    // ----- parzen -----
930
931    #[test]
932    fn parzen_endpoints_are_zero() {
933        let w = parzen(16).unwrap();
934        let s = w.as_slice().unwrap();
935        // Outer edge of Parzen has very small value (cubic falloff).
936        assert!(s[0].abs() < 1e-2);
937        assert!(s[s.len() - 1].abs() < 1e-2);
938    }
939
940    #[test]
941    fn parzen_centre_is_one() {
942        // For odd M, centre is at sample (M-1)/2; r = 0 → w = 1.
943        let w = parzen(13).unwrap();
944        let s = w.as_slice().unwrap();
945        assert!(close(s[6], 1.0, 1e-14));
946    }
947
948    #[test]
949    fn parzen_is_symmetric() {
950        let m = 21;
951        let w = parzen(m).unwrap();
952        let s = w.as_slice().unwrap();
953        for i in 0..m / 2 {
954            assert!(close(s[i], s[m - 1 - i], 1e-14));
955        }
956    }
957
958    // ----- taylor -----
959
960    #[test]
961    fn taylor_default_normalised_centre_is_one() {
962        // With norm=true the centre sample is 1.0.
963        let w = taylor(33, 4, 30.0, true).unwrap();
964        let s = w.as_slice().unwrap();
965        assert!(close(s[16], 1.0, 1e-12));
966    }
967
968    #[test]
969    fn taylor_is_symmetric() {
970        let m = 33;
971        let w = taylor(m, 4, 30.0, true).unwrap();
972        let s = w.as_slice().unwrap();
973        for i in 0..m / 2 {
974            assert!(close(s[i], s[m - 1 - i], 1e-12));
975        }
976    }
977
978    #[test]
979    fn taylor_rejects_nbar_zero() {
980        assert!(taylor(8, 0, 30.0, true).is_err());
981        assert!(taylor(8, 4, f64::NAN, true).is_err());
982    }
983
984    // ----- tukey -----
985
986    #[test]
987    fn tukey_alpha_zero_is_rectangular() {
988        let w = tukey(8, 0.0).unwrap();
989        for &v in w.as_slice().unwrap() {
990            assert!(close(v, 1.0, 1e-14));
991        }
992    }
993
994    #[test]
995    fn tukey_alpha_one_matches_hann() {
996        // alpha=1 → fully Hann-shaped.
997        let m = 9;
998        let tk = tukey(m, 1.0).unwrap();
999        let hn = hanning(m).unwrap();
1000        for i in 0..m {
1001            assert!(close(
1002                tk.as_slice().unwrap()[i],
1003                hn.as_slice().unwrap()[i],
1004                1e-12,
1005            ));
1006        }
1007    }
1008
1009    #[test]
1010    fn tukey_centre_is_one() {
1011        let m = 21;
1012        let w = tukey(m, 0.5).unwrap();
1013        // Wide flat-top region: middle must be 1.
1014        assert!(close(w.as_slice().unwrap()[m / 2], 1.0, 1e-14));
1015    }
1016
1017    #[test]
1018    fn tukey_rejects_invalid_alpha() {
1019        assert!(tukey(8, -0.1).is_err());
1020        assert!(tukey(8, 1.1).is_err());
1021        assert!(tukey(8, f64::NAN).is_err());
1022    }
1023}