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// ## REQ status — NumPy core window functions (design `.design/ferray-window.md`)
10//
11// SHIPPED:
12//   - REQ-1: `bartlett` (this file) — triangular window, `Array1<f64>`;
13//     matches `numpy.bartlett(M)`.
14//   - REQ-2: `blackman` (this file) — `0.42 - 0.5 cos + 0.08 cos2`; matches
15//     `numpy.blackman(M)`.
16//   - REQ-3: `hamming` (this file) — `0.54 - 0.46 cos`; matches
17//     `numpy.hamming(M)`.
18//   - REQ-4: `hanning` (this file) — Hann window `0.5 (1 - cos)`; matches
19//     `numpy.hanning(M)` (NumPy spells the Hann window `hanning`).
20//   - REQ-5: `kaiser` (this file) — `I_0(beta·sqrt(1 - t²)) / I_0(beta)`
21//     reusing `ferray_ufunc::ops::special::bessel_i0_scalar` (#530). Rejects
22//     only NaN `beta`; every finite `beta` is computed directly, including
23//     large `beta` (e.g. `beta = 709` stays finite, `beta >= 710` reproduces
24//     numpy's `Inf/Inf = NaN` degenerate window) — matches `numpy.kaiser`
25//     (`_function_base_impl.py:3735`) with no threshold rejection (#294,
26//     #1087).
27//   - REQ-6: M<=1 edge case — every window routes through `gen_window`
28//     (this file), which returns `[]` for `m == 0` and `[1.0]` for `m == 1`,
29//     matching NumPy. (Windows with bespoke loops — `cosine`, `parzen`,
30//     `tukey`, etc. — special-case the same two values inline.)
31//
32// Consumers (non-test, in production): `ferray-python/src/window.rs` exposes
33// `hanning`/`hamming`/`blackman`/`bartlett` via the `bind_window_n!` macro
34// (each routing to `fw::<name>`) and `kaiser` via its own `#[pyfunction]`
35// calling `fw::kaiser(m, beta)` — the `numpy.{hanning,…,kaiser}` top-level
36// surface.
37//
38// NOT-STARTED: none — REQ-1..6 (the five NumPy core windows plus the shared
39// M<=1 contract) are fully shipped and green, including the #1087
40// large-finite-`beta` kaiser fix. The SciPy/torch extras below
41// (`cosine`..`tukey`, `chebwin`, `dpss`, `boxcar`, …) are additional surface
42// beyond the REQ-1..6 NumPy-core mandate.
43
44// Window-function coefficients (Blackman 0.42/0.5/0.08, Hamming 0.54/0.46,
45// Kaiser modified-Bessel I0 series) are textbook constants reproduced
46// without separators to match the reference papers / NumPy source.
47#![allow(clippy::unreadable_literal)]
48
49use ferray_core::Array;
50use ferray_core::dimension::Ix1;
51use ferray_core::error::{FerrayError, FerrayResult};
52
53use std::f64::consts::PI;
54
55// Re-use the Abramowitz & Stegun Bessel I_0 polynomial approximation
56// from ferray-ufunc instead of shipping a second copy (see #530).
57// The previous copy had already drifted in whitespace; funnelling
58// through the canonical crate keeps both the window code and the
59// ufunc in sync.
60use ferray_ufunc::ops::special::bessel_i0_scalar;
61
62/// Build a window array of length `m` by evaluating `f` at every sample
63/// `0..m`. Handles the shared `m == 0` / `m == 1` edge cases that every
64/// NumPy-equivalent window has (see issue #292 for the original
65/// five-way copy of this pattern).
66#[inline]
67fn gen_window<F: FnMut(usize) -> f64>(m: usize, mut f: F) -> FerrayResult<Array<f64, Ix1>> {
68    if m == 0 {
69        return Array::from_vec(Ix1::new([0]), vec![]);
70    }
71    if m == 1 {
72        return Array::from_vec(Ix1::new([1]), vec![1.0]);
73    }
74    let mut data = Vec::with_capacity(m);
75    for n in 0..m {
76        data.push(f(n));
77    }
78    Array::from_vec(Ix1::new([m]), data)
79}
80
81/// Internal helper: cast a length-`m` f64 window into an f32 array.
82/// We compute in f64 (ferray-window's native precision) and then
83/// down-cast — the windows are smooth, so this preserves all useful
84/// precision.
85fn cast_to_f32(arr: &Array<f64, Ix1>) -> FerrayResult<Array<f32, Ix1>> {
86    let data: Vec<f32> = arr.iter().map(|&x| x as f32).collect();
87    Array::<f32, Ix1>::from_vec(Ix1::new([data.len()]), data)
88}
89
90/// `f32` version of [`bartlett`] (#529).
91///
92/// # Errors
93/// Same as `bartlett`.
94pub fn bartlett_f32(m: usize) -> FerrayResult<Array<f32, Ix1>> {
95    cast_to_f32(&bartlett(m)?)
96}
97
98/// `f32` version of [`blackman`] (#529).
99///
100/// # Errors
101/// Same as `blackman`.
102pub fn blackman_f32(m: usize) -> FerrayResult<Array<f32, Ix1>> {
103    cast_to_f32(&blackman(m)?)
104}
105
106/// `f32` version of [`hamming`] (#529).
107///
108/// # Errors
109/// Same as `hamming`.
110pub fn hamming_f32(m: usize) -> FerrayResult<Array<f32, Ix1>> {
111    cast_to_f32(&hamming(m)?)
112}
113
114/// `f32` version of [`hanning`] (#529).
115///
116/// # Errors
117/// Same as `hanning`.
118pub fn hanning_f32(m: usize) -> FerrayResult<Array<f32, Ix1>> {
119    cast_to_f32(&hanning(m)?)
120}
121
122/// `f32` version of [`kaiser`] (#529).
123///
124/// # Errors
125/// Same as `kaiser`.
126pub fn kaiser_f32(m: usize, beta: f64) -> FerrayResult<Array<f32, Ix1>> {
127    cast_to_f32(&kaiser(m, beta)?)
128}
129
130/// Return the Bartlett (triangular) window of length `m`.
131///
132/// The Bartlett window is defined as:
133///   w(n) = 2/(M-1) * ((M-1)/2 - |n - (M-1)/2|)
134///
135/// This is equivalent to `numpy.bartlett(M)`.
136///
137/// # Errors
138/// Returns an error only if internal array construction fails.
139pub fn bartlett(m: usize) -> FerrayResult<Array<f64, Ix1>> {
140    let half = (m.saturating_sub(1)) as f64 / 2.0;
141    gen_window(m, |n| 1.0 - ((n as f64 - half) / half).abs())
142}
143
144/// Return the Blackman window of length `m`.
145///
146/// The Blackman window is defined as:
147///   w(n) = 0.42 - 0.5 * cos(2*pi*n/(M-1)) + 0.08 * cos(4*pi*n/(M-1))
148///
149/// This is equivalent to `numpy.blackman(M)`.
150///
151/// # Errors
152/// Returns an error only if internal array construction fails.
153pub fn blackman(m: usize) -> FerrayResult<Array<f64, Ix1>> {
154    let denom = (m.saturating_sub(1)) as f64;
155    gen_window(m, |n| {
156        let x = n as f64;
157        0.08f64.mul_add(
158            (4.0 * PI * x / denom).cos(),
159            0.5f64.mul_add(-(2.0 * PI * x / denom).cos(), 0.42),
160        )
161    })
162}
163
164/// Return the Hamming window of length `m`.
165///
166/// The Hamming window is defined as:
167///   w(n) = 0.54 - 0.46 * cos(2*pi*n/(M-1))
168///
169/// This is equivalent to `numpy.hamming(M)`.
170///
171/// # Errors
172/// Returns an error only if internal array construction fails.
173pub fn hamming(m: usize) -> FerrayResult<Array<f64, Ix1>> {
174    let denom = (m.saturating_sub(1)) as f64;
175    gen_window(m, |n| {
176        0.46f64.mul_add(-(2.0 * PI * n as f64 / denom).cos(), 0.54)
177    })
178}
179
180/// Return the Hann (Hanning) window of length `m`.
181///
182/// The Hann window is defined as:
183///   w(n) = 0.5 * (1 - cos(2*pi*n/(M-1)))
184///
185/// `NumPy` calls this function `hanning`. This is equivalent to `numpy.hanning(M)`.
186///
187/// # Errors
188/// Returns an error only if internal array construction fails.
189pub fn hanning(m: usize) -> FerrayResult<Array<f64, Ix1>> {
190    let denom = (m.saturating_sub(1)) as f64;
191    gen_window(m, |n| 0.5 * (1.0 - (2.0 * PI * n as f64 / denom).cos()))
192}
193
194/// Return the Kaiser window of length `m` with shape parameter `beta`.
195///
196/// The Kaiser window is defined as:
197///   w(n) = `I_0(beta` * sqrt(1 - ((2n/(M-1)) - 1)^2)) / `I_0(beta)`
198///
199/// where `I_0` is the modified Bessel function of the first kind, order 0.
200///
201/// This is equivalent to `numpy.kaiser(M, beta)`.
202///
203/// # Edge Cases
204/// - `m == 0`: returns an empty array.
205/// - `m == 1`: returns `[1.0]`.
206///
207/// # Errors
208/// Returns `FerrayError::InvalidValue` only if `beta` is NaN. For any
209/// finite `beta` the window is computed directly as
210/// `I_0(beta * sqrt(...)) / I_0(beta)`, matching `numpy.kaiser`
211/// (`_function_base_impl.py:3735`), which never rejects a finite `beta`.
212/// `I_0(beta)` stays finite up to the f64 `exp` overflow point
213/// `ln(f64::MAX) ≈ 709.78`, so e.g. `beta = 709` yields a finite window;
214/// for `beta >= 710` both numerator and denominator overflow and NumPy
215/// returns its `Inf/Inf = NaN` degenerate window, which this function
216/// reproduces exactly (#294, #1087).
217pub fn kaiser(m: usize, beta: f64) -> FerrayResult<Array<f64, Ix1>> {
218    if beta.is_nan() {
219        return Err(FerrayError::invalid_value("kaiser: beta must not be NaN"));
220    }
221    // I_0 is an even function, so kaiser(m, -beta) == kaiser(m, beta).
222    // Accept negative beta for NumPy compatibility.
223    let beta = beta.abs();
224    // No threshold rejection: numpy.kaiser never raises on a finite beta.
225    // I_0(beta) is finite up to ln(f64::MAX) ≈ 709.78; beyond that it
226    // overflows to +inf and the I_0/I_0 ratio yields numpy's exact
227    // NaN/0.0 degenerate window (verified beta>=710 against the oracle).
228    let i0_beta = bessel_i0_scalar::<f64>(beta);
229    let alpha = (m.saturating_sub(1)) as f64 / 2.0;
230    gen_window(m, |n| {
231        let t = (n as f64 - alpha) / alpha;
232        let arg = beta * (1.0 - t * t).max(0.0).sqrt();
233        bessel_i0_scalar::<f64>(arg) / i0_beta
234    })
235}
236
237// ===========================================================================
238// SciPy-extended windows (cosine, exponential, gaussian, general_cosine,
239// general_hamming, nuttall, parzen, taylor, tukey).
240//
241// All are symmetric ("sym=True" in scipy parlance); a non-symmetric "periodic"
242// variant can be obtained by computing window of length m+1 and dropping the
243// last sample (NumPy convention).
244// ===========================================================================
245
246/// Half-cycle cosine window (also known as the "sine" window).
247///
248/// `w(n) = sin(pi * (n + 0.5) / M)` for `n = 0..M-1`.
249///
250/// Mirrors `scipy.signal.windows.cosine` /
251/// `torch.signal.windows.cosine`.
252pub fn cosine(m: usize) -> FerrayResult<Array<f64, Ix1>> {
253    if m == 0 {
254        return Array::from_vec(Ix1::new([0]), vec![]);
255    }
256    if m == 1 {
257        return Array::from_vec(Ix1::new([1]), vec![1.0]);
258    }
259    let mf = m as f64;
260    gen_window(m, |n| (PI * (n as f64 + 0.5) / mf).sin())
261}
262
263/// Exponentially decaying window centred on `center` with time-constant `tau`.
264///
265/// `w(n) = exp(-|n - center| / tau)`. If `center` is `None`, defaults to
266/// the geometric centre `(M - 1) / 2`. `tau` must be > 0.
267///
268/// Mirrors `scipy.signal.windows.exponential` /
269/// `torch.signal.windows.exponential`.
270pub fn exponential(m: usize, center: Option<f64>, tau: f64) -> FerrayResult<Array<f64, Ix1>> {
271    if !tau.is_finite() || tau <= 0.0 {
272        return Err(FerrayError::invalid_value(format!(
273            "exponential: tau must be positive and finite, got {tau}"
274        )));
275    }
276    let centre = center.unwrap_or((m.saturating_sub(1)) as f64 / 2.0);
277    gen_window(m, |n| (-((n as f64) - centre).abs() / tau).exp())
278}
279
280/// Gaussian window with standard deviation `std`.
281///
282/// `w(n) = exp(-((n - (M-1)/2) / std)^2 / 2)`. `std` must be > 0.
283///
284/// Mirrors `scipy.signal.windows.gaussian` /
285/// `torch.signal.windows.gaussian`.
286pub fn gaussian(m: usize, std: f64) -> FerrayResult<Array<f64, Ix1>> {
287    if !std.is_finite() || std <= 0.0 {
288        return Err(FerrayError::invalid_value(format!(
289            "gaussian: std must be positive and finite, got {std}"
290        )));
291    }
292    let centre = (m.saturating_sub(1)) as f64 / 2.0;
293    gen_window(m, |n| {
294        let z = ((n as f64) - centre) / std;
295        (-0.5 * z * z).exp()
296    })
297}
298
299/// Generalized cosine sum window: `w(n) = Σ_k (-1)^k a[k] cos(2π k n / (M-1))`.
300///
301/// `coeffs` is the slice `[a_0, a_1, a_2, ...]`. Setting `[0.5, 0.5]` gives
302/// the Hann window; `[0.42, 0.5, 0.08]` gives the classical Blackman.
303///
304/// Mirrors `scipy.signal.windows.general_cosine` /
305/// `torch.signal.windows.general_cosine`.
306pub fn general_cosine(m: usize, coeffs: &[f64]) -> FerrayResult<Array<f64, Ix1>> {
307    if coeffs.is_empty() {
308        return Err(FerrayError::invalid_value(
309            "general_cosine: coeffs must not be empty",
310        ));
311    }
312    let denom = (m.saturating_sub(1)) as f64;
313    let coeffs = coeffs.to_vec();
314    gen_window(m, |n| {
315        let nf = n as f64;
316        let mut sum = 0.0_f64;
317        for (k, &a) in coeffs.iter().enumerate() {
318            let sign = if k % 2 == 0 { 1.0 } else { -1.0 };
319            sum += sign * a * (2.0 * PI * (k as f64) * nf / denom).cos();
320        }
321        sum
322    })
323}
324
325/// Generalized Hamming window: `w(n) = α - (1 - α) cos(2π n / (M - 1))`.
326///
327/// `alpha = 0.5` gives the Hann window; `alpha = 25/46 ≈ 0.5435` gives a
328/// "perfect" Hamming (NumPy uses 0.54 by tradition).
329///
330/// Mirrors `scipy.signal.windows.general_hamming` /
331/// `torch.signal.windows.general_hamming`.
332pub fn general_hamming(m: usize, alpha: f64) -> FerrayResult<Array<f64, Ix1>> {
333    if !alpha.is_finite() {
334        return Err(FerrayError::invalid_value(format!(
335            "general_hamming: alpha must be finite, got {alpha}"
336        )));
337    }
338    let denom = (m.saturating_sub(1)) as f64;
339    gen_window(m, |n| {
340        alpha - (1.0 - alpha) * (2.0 * PI * (n as f64) / denom).cos()
341    })
342}
343
344/// 4-term Nuttall window with continuous first derivative.
345///
346/// Coefficients [0.3635819, 0.4891775, 0.1365995, 0.0106411] (Nuttall 1981,
347/// "minimum 4-term Blackman-Harris with continuous first derivative").
348///
349/// Mirrors `scipy.signal.windows.nuttall` /
350/// `torch.signal.windows.nuttall`.
351pub fn nuttall(m: usize) -> FerrayResult<Array<f64, Ix1>> {
352    general_cosine(m, &[0.3635819, 0.4891775, 0.1365995, 0.0106411])
353}
354
355/// Parzen (de la Vallée Poussin) window: a piecewise-cubic B-spline.
356///
357/// Mirrors `scipy.signal.windows.parzen` / `torch.signal.windows.parzen`.
358pub fn parzen(m: usize) -> FerrayResult<Array<f64, Ix1>> {
359    if m == 0 {
360        return Array::from_vec(Ix1::new([0]), vec![]);
361    }
362    if m == 1 {
363        return Array::from_vec(Ix1::new([1]), vec![1.0]);
364    }
365    // Parzen formula: |x| in [0, M/4]: 1 - 6 r^2 + 6 |r|^3,
366    //                |x| in (M/4, M/2]: 2 (1 - |r|)^3, where r = x / (M/2).
367    let half = m as f64 / 2.0;
368    gen_window(m, |n| {
369        let x = (n as f64) - (m as f64 - 1.0) / 2.0;
370        let r = x.abs() / half;
371        if r <= 0.5 {
372            1.0 - 6.0 * r * r + 6.0 * r * r * r
373        } else if r <= 1.0 {
374            let one_minus_r = 1.0 - r;
375            2.0 * one_minus_r * one_minus_r * one_minus_r
376        } else {
377            0.0
378        }
379    })
380}
381
382/// Dolph–Chebyshev window with sidelobe attenuation `at` dB (#740).
383///
384/// Mirrors `scipy.signal.windows.chebwin`. The window is the
385/// inverse Fourier transform of the (M-1)-th Chebyshev polynomial
386/// of the first kind evaluated on a cosine sweep, producing an
387/// equiripple sidelobe response at `at` dB below the main lobe.
388///
389/// Implemented via direct DFT (sufficient for typical M ≤ 1024;
390/// the inner loop is O(M²) but allocation-free). Result is
391/// normalised so the centre value is 1.
392///
393/// # Errors
394/// `FerrayError::InvalidValue` if `at` is non-finite or
395/// `at <= 0.0`. `m == 0` returns the empty window; `m == 1`
396/// returns `[1.0]`.
397pub fn chebwin(m: usize, at: f64) -> FerrayResult<Array<f64, Ix1>> {
398    if !at.is_finite() || at <= 0.0 {
399        return Err(FerrayError::invalid_value(format!(
400            "chebwin: at must be a positive finite dB attenuation, got {at}"
401        )));
402    }
403    if m == 0 {
404        return Array::from_vec(Ix1::new([0]), vec![]);
405    }
406    if m == 1 {
407        return Array::from_vec(Ix1::new([1]), vec![1.0]);
408    }
409
410    let order = (m - 1) as f64;
411    let r = 10.0_f64.powf(at / 20.0);
412    let beta = (r.acosh() / order).cosh();
413
414    // Build spectrum P[k] = T_{m-1}(β cos(πk/m)).
415    let mut spectrum = Vec::with_capacity(m);
416    for k in 0..m {
417        let x = beta * (PI * k as f64 / m as f64).cos();
418        spectrum.push(chebyshev_t_extended(x, order));
419    }
420
421    // Direct DFT to time domain. For odd M the spectrum is even-
422    // symmetric about M/2 and the IDFT is purely real; for even M
423    // the (M-1)-th Chebyshev polynomial is odd, so we apply a
424    // half-bin phase shift before transforming. Matches scipy's
425    // FFT-based pipeline up to the trailing peak normalisation.
426    // Forward DFT of the spectrum (matches scipy's fft(p) call).
427    // For odd M the spectrum is even-symmetric so fft is real;
428    // for even M scipy multiplies by exp(iπk/M) phase first to
429    // recover symmetry. After the DFT we keep the first half,
430    // normalise by w[0] so the eventual centre lands at 1, and
431    // mirror to recover the full symmetric window.
432    let mf = m as f64;
433    let half_dft = |idx: usize| -> f64 {
434        if m % 2 == 1 {
435            spectrum
436                .iter()
437                .enumerate()
438                .map(|(k, &pk)| pk * (-2.0 * PI * k as f64 * idx as f64 / mf).cos())
439                .sum()
440        } else {
441            // Forward DFT with the additional exp(iπk/M) phase
442            // shift baked in: p[k] * exp(iπk/M) * exp(-i2πkn/M)
443            //              = p[k] * cos(πk(1 - 2n)/M).
444            spectrum
445                .iter()
446                .enumerate()
447                .map(|(k, &pk)| {
448                    let phase = PI * k as f64 * (1.0 - 2.0 * idx as f64) / mf;
449                    pk * phase.cos()
450                })
451                .sum()
452        }
453    };
454
455    let half_len = m / 2 + 1;
456    let mut half: Vec<f64> = (0..half_len).map(half_dft).collect();
457    let denom = if m % 2 == 1 { half[0] } else { half[1] };
458    if denom != 0.0 {
459        for v in &mut half {
460            *v /= denom;
461        }
462    }
463
464    let mut w = Vec::with_capacity(m);
465    if m % 2 == 1 {
466        // Mirror: reverse(half[1..]) ++ half
467        for &v in half.iter().skip(1).rev() {
468            w.push(v);
469        }
470        w.extend_from_slice(&half);
471    } else {
472        // Even: scipy's `concatenate((w[n-1:0:-1], w[1:n]))` where
473        // n = M/2 + 1; reverse of half[1..n], then half[1..n].
474        let n = m / 2 + 1;
475        for &v in half[1..n].iter().rev() {
476            w.push(v);
477        }
478        w.extend_from_slice(&half[1..n]);
479    }
480
481    Array::from_vec(Ix1::new([m]), w)
482}
483
484/// Discrete prolate spheroidal sequence (Slepian taper) (#745).
485///
486/// Returns the dominant DPSS — the eigenvector of the canonical
487/// time–frequency concentration tridiagonal matrix corresponding
488/// to the largest eigenvalue. Equivalent to
489/// `scipy.signal.windows.dpss(m, NW)` with the default `Kmax=None`
490/// (single window, sym=True, no ratios).
491///
492/// `nw` is the standardised half bandwidth (`NW = M·W`), typically
493/// in the range 2..=4 for spectral analysis.
494///
495/// Computed by warm-up power iteration followed by Rayleigh-quotient
496/// inverse iteration (RQI) on the symmetric tridiagonal
497/// concentration matrix
498///
499/// ```text
500///   d[i]  = ((M-1)/2 − i)² · cos(2π · NW / M)
501///   e[i]  = (i+1)(M−i−1) / 2
502/// ```
503///
504/// Power iteration alone converges only linearly with rate
505/// `λ₂/λ₁`, which is far too slow for the nearly-degenerate
506/// dominant DPSS eigenvalues; RQI achieves cubic local convergence
507/// near the dominant eigenpair via tridiagonal LU (Thomas
508/// algorithm) solves of `(T - σI) w = v`. The result is
509/// sign-flipped if needed so the centre value is positive, and
510/// peak-normalised (matching scipy's default `norm='approximate'`).
511///
512/// Multiple Slepian sequences (`Kmax > 1`) need orthogonal
513/// power-iteration / deflation; tracked as a future extension.
514///
515/// # Errors
516/// `FerrayError::InvalidValue` if `nw` is non-finite, `nw <= 0`,
517/// or `nw >= m / 2` (the band would degenerate).
518pub fn dpss(m: usize, nw: f64) -> FerrayResult<Array<f64, Ix1>> {
519    if !nw.is_finite() || nw <= 0.0 {
520        return Err(FerrayError::invalid_value(format!(
521            "dpss: nw must be a positive finite half bandwidth, got {nw}"
522        )));
523    }
524    if m == 0 {
525        return Array::from_vec(Ix1::new([0]), vec![]);
526    }
527    if m == 1 {
528        return Array::from_vec(Ix1::new([1]), vec![1.0]);
529    }
530    if nw >= (m as f64) / 2.0 {
531        return Err(FerrayError::invalid_value(format!(
532            "dpss: nw = {nw} must be < M/2 = {} for a non-degenerate band",
533            (m as f64) / 2.0
534        )));
535    }
536
537    let mf = m as f64;
538    let cos_w = (2.0 * PI * nw / mf).cos();
539    // Diagonal d[i] = ((M-1)/2 - i)^2 * cos(2π NW / M).
540    let diag: Vec<f64> = (0..m)
541        .map(|i| {
542            let centre = (mf - 1.0) / 2.0 - i as f64;
543            centre * centre * cos_w
544        })
545        .collect();
546    // Off-diagonal e[i] = (i+1)(M-i-1)/2 for i in 0..m-1.
547    let off: Vec<f64> = (0..m.saturating_sub(1))
548        .map(|i| (i as f64 + 1.0) * (mf - i as f64 - 1.0) / 2.0)
549        .collect();
550
551    // Power iteration. Centre-symmetric initial vector accelerates
552    // convergence to the (also symmetric) dominant DPSS.
553    let mut v: Vec<f64> = (0..m)
554        .map(|i| {
555            let centred = (i as f64 - (mf - 1.0) / 2.0) / mf;
556            (-2.0 * centred * centred).exp()
557        })
558        .collect();
559    let n0 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
560    for x in &mut v {
561        *x /= n0.max(f64::MIN_POSITIVE);
562    }
563
564    // Helper: y = T v for the symmetric tridiagonal T.
565    let apply_t = |x: &[f64]| -> Vec<f64> {
566        let mut y = vec![0.0_f64; m];
567        for i in 0..m {
568            y[i] = diag[i] * x[i];
569            if i > 0 {
570                y[i] += off[i - 1] * x[i - 1];
571            }
572            if i + 1 < m {
573                y[i] += off[i] * x[i + 1];
574            }
575        }
576        y
577    };
578
579    // Stage 1: warm-up power iteration (~50 steps) to land near the
580    // dominant eigenspace before switching to RQI.
581    for _ in 0..50 {
582        let mut next = apply_t(&v);
583        let norm = next.iter().map(|x| x * x).sum::<f64>().sqrt();
584        if norm < f64::MIN_POSITIVE {
585            return Err(FerrayError::invalid_value(
586                "dpss: power iteration produced a zero vector",
587            ));
588        }
589        for x in &mut next {
590            *x /= norm;
591        }
592        v = next;
593    }
594
595    // Initial eigenvalue estimate σ = v^T (T v).
596    let tv0 = apply_t(&v);
597    let mut sigma: f64 = v.iter().zip(tv0.iter()).map(|(a, b)| a * b).sum();
598
599    // Stage 2: Rayleigh-quotient inverse iteration. Each step solves
600    // (T - σI) w = v via the Thomas algorithm (tridiagonal LU with
601    // no pivoting), normalises w, and updates σ = w^T (T w). Cubic
602    // local convergence near the dominant eigenpair.
603    for _ in 0..50 {
604        // Build the shifted tridiagonal: lower = upper = off (symmetric).
605        let mut d_shift: Vec<f64> = diag.iter().map(|d| d - sigma).collect();
606        let sup: Vec<f64> = off.clone();
607        let mut rhs: Vec<f64> = v.clone();
608
609        // Forward sweep: eliminate sub-diagonal.
610        let mut singular = false;
611        for i in 1..m {
612            if d_shift[i - 1].abs() < 1e-30 {
613                // σ is essentially an eigenvalue — v is already the
614                // eigenvector to machine precision; stop here.
615                singular = true;
616                break;
617            }
618            let factor = off[i - 1] / d_shift[i - 1];
619            d_shift[i] -= factor * sup[i - 1];
620            rhs[i] -= factor * rhs[i - 1];
621        }
622        if singular {
623            break;
624        }
625        if d_shift[m - 1].abs() < 1e-30 {
626            break;
627        }
628
629        // Back sweep.
630        let mut w = vec![0.0_f64; m];
631        w[m - 1] = rhs[m - 1] / d_shift[m - 1];
632        for i in (0..m - 1).rev() {
633            w[i] = (rhs[i] - sup[i] * w[i + 1]) / d_shift[i];
634        }
635
636        let norm = w.iter().map(|x| x * x).sum::<f64>().sqrt();
637        if norm < f64::MIN_POSITIVE {
638            break;
639        }
640        for x in &mut w {
641            *x /= norm;
642        }
643
644        let tw = apply_t(&w);
645        let new_sigma: f64 = w.iter().zip(tw.iter()).map(|(a, b)| a * b).sum();
646
647        // Residual ||T w - σ w||.
648        let resid: f64 = tw
649            .iter()
650            .zip(w.iter())
651            .map(|(t, x)| {
652                let r = t - new_sigma * x;
653                r * r
654            })
655            .sum::<f64>()
656            .sqrt();
657
658        v = w;
659        sigma = new_sigma;
660        if resid < 1e-14 {
661            break;
662        }
663    }
664    // Silence unused-assignment lint: sigma is the final eigenvalue
665    // estimate, kept for clarity even though it is not read after
666    // the loop.
667    let _ = sigma;
668
669    // Sign convention: scipy returns the dominant DPSS with a
670    // positive centre value.
671    let centre_val = if m % 2 == 1 {
672        v[m / 2]
673    } else {
674        v[m / 2 - 1] + v[m / 2]
675    };
676    if centre_val < 0.0 {
677        for x in &mut v {
678            *x = -*x;
679        }
680    }
681
682    // Match scipy's default `norm='approximate'`: rescale so the
683    // peak (after sign-fix, this is the centre value) is 1. Power
684    // iteration left v at unit ℓ²; switch to max-normalised so
685    // numeric values match scipy.signal.windows.dpss directly.
686    let peak = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
687    if peak > 0.0 {
688        for x in &mut v {
689            *x /= peak;
690        }
691    }
692
693    Array::from_vec(Ix1::new([m]), v)
694}
695
696/// Chebyshev polynomial of the first kind T_n(x) extended to all
697/// real x via cosh — `T_n(cos θ) = cos(nθ)` for `|x| ≤ 1`,
698/// `T_n(cosh θ) = cosh(nθ)` for `x > 1`. Sign handling on the
699/// negative branch uses `(-1)^n`.
700fn chebyshev_t_extended(x: f64, n: f64) -> f64 {
701    if x.abs() <= 1.0 {
702        (n * x.acos()).cos()
703    } else {
704        let mag = (n * x.abs().acosh()).cosh();
705        if x < 0.0 && (n as i64) % 2 != 0 {
706            -mag
707        } else {
708            mag
709        }
710    }
711}
712
713/// Boxcar (rectangular) window of length `m` (#738).
714///
715/// All-ones window. Equivalent to `scipy.signal.windows.boxcar`.
716///
717/// # Errors
718/// Only on internal array construction failure.
719pub fn boxcar(m: usize) -> FerrayResult<Array<f64, Ix1>> {
720    gen_window(m, |_| 1.0)
721}
722
723/// Triangular window of length `m` (#738).
724///
725/// Centre = 1, linearly decreasing to (1/m) at the edges (slightly
726/// different from Bartlett, which decays to 0 at the edges).
727/// Equivalent to `scipy.signal.windows.triang`.
728///
729/// # Errors
730/// Only on internal array construction failure.
731pub fn triang(m: usize) -> FerrayResult<Array<f64, Ix1>> {
732    if m == 0 {
733        return Array::from_vec(Ix1::new([0]), vec![]);
734    }
735    if m == 1 {
736        return Array::from_vec(Ix1::new([1]), vec![1.0]);
737    }
738    let mf = m as f64;
739    let centre = (mf - 1.0) / 2.0;
740    if m.is_multiple_of(2) {
741        // Even-m formula: w[n] = 1 - |2n - (m-1)| / m
742        gen_window(m, |n| 1.0 - (2.0 * n as f64 - centre - centre).abs() / mf)
743    } else {
744        // Odd-m formula: w[n] = 1 - |2n - (m-1)| / (m + 1)
745        gen_window(m, |n| {
746            1.0 - (2.0 * n as f64 - centre - centre).abs() / (mf + 1.0)
747        })
748    }
749}
750
751/// Bohman window of length `m` (#738).
752///
753/// w(n) = (1 - |r|) cos(π|r|) + (1/π) sin(π|r|), where r is the
754/// normalised position in [-1, 1]. Equivalent to
755/// `scipy.signal.windows.bohman`.
756///
757/// # Errors
758/// Only on internal array construction failure.
759pub fn bohman(m: usize) -> FerrayResult<Array<f64, Ix1>> {
760    if m == 0 {
761        return Array::from_vec(Ix1::new([0]), vec![]);
762    }
763    if m == 1 {
764        return Array::from_vec(Ix1::new([1]), vec![1.0]);
765    }
766    // scipy: linspace(-1, 1, m)[1:-1] core, with explicit zeros at endpoints.
767    let mf = (m - 1) as f64;
768    let mut data = Vec::with_capacity(m);
769    for n in 0..m {
770        if n == 0 || n == m - 1 {
771            data.push(0.0);
772            continue;
773        }
774        let r = (2.0 * n as f64 / mf - 1.0).abs();
775        let v = (1.0 - r) * (PI * r).cos() + (PI * r).sin() / PI;
776        data.push(v);
777    }
778    Array::from_vec(Ix1::new([m]), data)
779}
780
781/// Flat-top window of length `m` (#738).
782///
783/// 5-term cosine window with coefficients chosen for minimum
784/// passband ripple (used for amplitude calibration). Equivalent to
785/// `scipy.signal.windows.flattop`.
786///
787/// # Errors
788/// Only on internal array construction failure.
789pub fn flattop(m: usize) -> FerrayResult<Array<f64, Ix1>> {
790    // scipy.signal.windows.flattop coefficients (a0..a4), verbatim from
791    // scipy 1.13.0 signal/windows/_windows.py.
792    const COEFFS: [f64; 5] = [
793        0.215_578_95,
794        0.416_631_58,
795        0.277_263_158,
796        0.083_578_947,
797        0.006_947_368,
798    ];
799    if m == 0 {
800        return Array::from_vec(Ix1::new([0]), vec![]);
801    }
802    let denom = (m - 1).max(1) as f64;
803    gen_window(m, |n| {
804        let phase = 2.0 * PI * n as f64 / denom;
805        let mut acc = COEFFS[0];
806        for (k, &c) in COEFFS.iter().enumerate().skip(1) {
807            let sign = if k % 2 == 0 { 1.0 } else { -1.0 };
808            acc += sign * c * (k as f64 * phase).cos();
809        }
810        acc
811    })
812}
813
814/// Lanczos (sinc) window of length `m` (#738).
815///
816/// w(n) = sinc(2 n / (m-1) - 1). Equivalent to
817/// `scipy.signal.windows.lanczos`.
818///
819/// # Errors
820/// Only on internal array construction failure.
821pub fn lanczos(m: usize) -> FerrayResult<Array<f64, Ix1>> {
822    if m == 0 {
823        return Array::from_vec(Ix1::new([0]), vec![]);
824    }
825    if m == 1 {
826        return Array::from_vec(Ix1::new([1]), vec![1.0]);
827    }
828    let denom = (m - 1) as f64;
829    gen_window(m, |n| {
830        let x = 2.0 * n as f64 / denom - 1.0;
831        if x.abs() < f64::EPSILON {
832            1.0
833        } else {
834            let pi_x = PI * x;
835            pi_x.sin() / pi_x
836        }
837    })
838}
839
840/// Taylor window with `nbar` near-side lobes and a sidelobe level of `sll`
841/// dB below the main lobe.
842///
843/// `nbar` (typically 4) controls how many sidelobes are constrained at the
844/// design level; `sll` (typically 30) is the desired peak sidelobe
845/// attenuation in **positive** dB. When `norm` is true the window is
846/// normalised so `w[(M-1)/2] = 1`.
847///
848/// Implementation follows Carrara & Goodman, "Symmetric Taylor Window"
849/// (Synthetic Aperture Radar, 1995). For the radar/array-processing
850/// applications where Taylor windows are used, `nbar = 4`, `sll = 30`
851/// gives equiripple sidelobes ~30 dB down — the usual default.
852///
853/// Mirrors `scipy.signal.windows.taylor` / `torch.signal.windows.taylor`.
854pub fn taylor(m: usize, nbar: usize, sll: f64, norm: bool) -> FerrayResult<Array<f64, Ix1>> {
855    if m == 0 {
856        return Array::from_vec(Ix1::new([0]), vec![]);
857    }
858    if m == 1 {
859        return Array::from_vec(Ix1::new([1]), vec![1.0]);
860    }
861    if nbar == 0 {
862        return Err(FerrayError::invalid_value("taylor: nbar must be >= 1"));
863    }
864    if !sll.is_finite() {
865        return Err(FerrayError::invalid_value("taylor: sll must be finite"));
866    }
867    // R = 10^(sll/20), B = (1/π) acosh(R)
868    let r = 10.0_f64.powf(sll / 20.0);
869    let b = r.acosh() / PI;
870    let nbar_f = nbar as f64;
871    // sigma^2 chosen so the (nbar)-th zero of the Taylor pattern is at
872    // n = nbar (Carrara & Goodman eq. 13).
873    let sigma2 = (nbar_f * nbar_f) / (b * b + (nbar_f - 0.5) * (nbar_f - 0.5));
874
875    // Compute coefficients F_m for m = 1..nbar-1.
876    let mut f_coeffs = Vec::with_capacity(nbar.saturating_sub(1));
877    for mm in 1..nbar {
878        let mmf = mm as f64;
879        // Numerator: ((-1)^(m+1) / 2) * Π_{n=1..nbar-1} [1 - m^2 / (sigma^2 * (B^2 + (n - 0.5)^2))]
880        // Denominator: Π_{n=1..nbar-1, n != m} [1 - m^2 / n^2]
881        let mut num = 1.0_f64;
882        for n in 1..nbar {
883            let nf = n as f64;
884            num *= 1.0 - mmf * mmf / (sigma2 * (b * b + (nf - 0.5) * (nf - 0.5)));
885        }
886        let sign = if mm % 2 == 0 { -1.0 } else { 1.0 };
887        let mut den = 1.0_f64;
888        for n in 1..nbar {
889            if n == mm {
890                continue;
891            }
892            let nf = n as f64;
893            den *= 1.0 - mmf * mmf / (nf * nf);
894        }
895        // The 0.5 prefactor: F_0 = 1 contributes the constant term, and
896        // each F_m doubles when reflected about zero in the cosine sum,
897        // so we halve the inverse-Fourier coefficients here.
898        f_coeffs.push(0.5 * sign * num / den);
899    }
900
901    let denom = (m - 1) as f64;
902    let arr = gen_window(m, |n| {
903        let xn = (n as f64) - denom / 2.0;
904        let mut w = 1.0_f64;
905        for (idx, &fk) in f_coeffs.iter().enumerate() {
906            let kk = (idx + 1) as f64;
907            w += 2.0 * fk * (2.0 * PI * kk * xn / m as f64).cos();
908        }
909        w
910    })?;
911
912    if !norm {
913        return Ok(arr);
914    }
915    // Analytic peak of the Taylor cosine sum at the fractional midpoint
916    // (m-1)/2. ferray's xn = n - (m-1)/2 means the midpoint is xn = 0,
917    // so every cosine collapses to 1 and the sum reduces to
918    // 1 + 2·Σ f_coeffs. This matches scipy's `scale = 1.0 / W((M-1)/2)`
919    // analytically, and avoids the sample-averaging error that affected
920    // even-M windows (#810 upstream from ferrotorch).
921    let centre_val: f64 = 1.0 + 2.0 * f_coeffs.iter().sum::<f64>();
922    if centre_val == 0.0 {
923        return Ok(arr); // pathological; leave un-normalised
924    }
925    let normed: Vec<f64> = arr
926        .as_slice()
927        .unwrap()
928        .iter()
929        .map(|&v| v / centre_val)
930        .collect();
931    Array::from_vec(Ix1::new([m]), normed)
932}
933
934/// Tukey (cosine-tapered) window with taper ratio `alpha` ∈ [0, 1].
935///
936/// `alpha = 0` gives a rectangular window; `alpha = 1` gives a Hann
937/// window. The middle `(1 - alpha) * (M - 1)` samples are unity, and the
938/// edges are tapered with a half-cosine.
939///
940/// Mirrors `scipy.signal.windows.tukey` / `torch.signal.windows.tukey`.
941pub fn tukey(m: usize, alpha: f64) -> FerrayResult<Array<f64, Ix1>> {
942    if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
943        return Err(FerrayError::invalid_value(format!(
944            "tukey: alpha must be in [0, 1], got {alpha}"
945        )));
946    }
947    if m == 0 {
948        return Array::from_vec(Ix1::new([0]), vec![]);
949    }
950    if m == 1 {
951        return Array::from_vec(Ix1::new([1]), vec![1.0]);
952    }
953    if alpha == 0.0 {
954        return Array::from_vec(Ix1::new([m]), vec![1.0; m]);
955    }
956    let denom = (m - 1) as f64;
957    let width = alpha * denom / 2.0;
958    gen_window(m, |n| {
959        let nf = n as f64;
960        if nf < width {
961            0.5 * (1.0 + (PI * (nf / width - 1.0)).cos())
962        } else if nf <= denom - width {
963            1.0
964        } else {
965            0.5 * (1.0 + (PI * ((denom - nf) / width - 1.0)).cos())
966        }
967    })
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973
974    // ---- dpss (#745) ---------------------------------------------------
975
976    #[test]
977    fn dpss_max_one_and_symmetric() {
978        let w = dpss(64, 3.0).unwrap();
979        let s = w.as_slice().unwrap();
980        // scipy's default `norm='approximate'`: max value is 1.
981        let max = s.iter().copied().fold(f64::NEG_INFINITY, f64::max);
982        assert!((max - 1.0).abs() < 1e-12, "max = {max}");
983        // Symmetric about the centre.
984        for i in 0..s.len() / 2 {
985            let mirror = s.len() - 1 - i;
986            assert!(
987                (s[i] - s[mirror]).abs() < 1e-7,
988                "asymmetric at i={i}: {} vs {}",
989                s[i],
990                s[mirror]
991            );
992        }
993    }
994
995    #[test]
996    fn dpss_centre_is_positive_and_peak() {
997        // Sign convention: centre value > 0 and is the maximum
998        // across the window (now exactly 1 after scipy-style
999        // max-normalisation).
1000        let w = dpss(48, 2.5).unwrap();
1001        let s = w.as_slice().unwrap();
1002        let mid = s.len() / 2;
1003        assert!(s[mid] > 0.0);
1004        assert!((s[mid] - 1.0).abs() < 1e-12);
1005    }
1006
1007    #[test]
1008    fn dpss_endpoints_smaller_than_centre() {
1009        // For NW=3 the dominant DPSS should be heavily concentrated
1010        // near the centre — endpoints decay to small magnitudes.
1011        let w = dpss(64, 3.0).unwrap();
1012        let s = w.as_slice().unwrap();
1013        let mid = s.len() / 2;
1014        assert!(s[mid] > s[0]);
1015        assert!(s[mid] > s[s.len() - 1]);
1016        assert!(s[0].abs() < 0.1 * s[mid]);
1017    }
1018
1019    #[test]
1020    fn dpss_rejects_invalid_nw() {
1021        assert!(dpss(32, -1.0).is_err());
1022        assert!(dpss(32, 0.0).is_err());
1023        assert!(dpss(32, f64::NAN).is_err());
1024        // nw >= M/2 is degenerate.
1025        assert!(dpss(8, 4.0).is_err());
1026        assert!(dpss(8, 5.0).is_err());
1027    }
1028
1029    #[test]
1030    fn dpss_matches_scipy_within_relative_tolerance() {
1031        // scipy.signal.windows.dpss(16, 3.0) reference values for
1032        // the first half (window is symmetric, max-normalised).
1033        // Power iteration converges to within ~1% of scipy's
1034        // LAPACK-based eigensolver result for this small M; the
1035        // shape and concentration profile are identical up to a
1036        // small numerical drift.
1037        let want_first_half: [f64; 8] = [
1038            0.00710856, 0.03620714, 0.10884379, 0.24276927, 0.43733689, 0.6634221, 0.86701952,
1039            0.98841699,
1040        ];
1041        let got = dpss(16, 3.0).unwrap();
1042        let s = got.as_slice().unwrap();
1043        for (i, (&g, &w)) in s[..8].iter().zip(want_first_half.iter()).enumerate() {
1044            // Use generous relative tolerance — the small endpoint
1045            // values are most sensitive to power-iteration
1046            // convergence accuracy. 5% relative covers all
1047            // samples; the larger central values stay within 0.1%.
1048            let tol = 0.05 * w.abs().max(1e-3);
1049            assert!((g - w).abs() <= tol, "i={i}: got={g}, want={w}, tol={tol}");
1050        }
1051    }
1052
1053    #[test]
1054    fn dpss_m0_m1_edge_cases() {
1055        let z = dpss(0, 2.0).unwrap();
1056        assert_eq!(z.shape(), &[0]);
1057        let one = dpss(1, 0.5).unwrap();
1058        assert_eq!(one.as_slice().unwrap(), &[1.0]);
1059    }
1060
1061    // ---- chebwin (#740) ------------------------------------------------
1062
1063    #[test]
1064    fn chebwin_centre_is_one_and_symmetric() {
1065        let w = chebwin(11, 50.0).unwrap();
1066        let s = w.as_slice().unwrap();
1067        let mid = s.len() / 2;
1068        assert!((s[mid] - 1.0).abs() < 1e-6, "centre = {}", s[mid]);
1069        for i in 0..s.len() / 2 {
1070            assert!(
1071                (s[i] - s[s.len() - 1 - i]).abs() < 1e-9,
1072                "asymmetric at i={i}: {} vs {}",
1073                s[i],
1074                s[s.len() - 1 - i]
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn chebwin_even_length_symmetric() {
1081        let w = chebwin(8, 40.0).unwrap();
1082        let s = w.as_slice().unwrap();
1083        for i in 0..s.len() / 2 {
1084            assert!(
1085                (s[i] - s[s.len() - 1 - i]).abs() < 1e-9,
1086                "asymmetric at i={i}"
1087            );
1088        }
1089        // No element should exceed the centre by more than 1e-9.
1090        let max = s.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1091        assert!((max - 1.0).abs() < 1e-6);
1092    }
1093
1094    #[test]
1095    fn chebwin_endpoints_smaller_than_centre() {
1096        // High attenuation → endpoints should be very small.
1097        let w = chebwin(31, 80.0).unwrap();
1098        let s = w.as_slice().unwrap();
1099        let mid = s.len() / 2;
1100        assert!(s[mid] > s[0]);
1101        assert!(s[mid] > s[s.len() - 1]);
1102    }
1103
1104    #[test]
1105    fn chebwin_rejects_invalid_attenuation() {
1106        assert!(chebwin(16, -10.0).is_err());
1107        assert!(chebwin(16, 0.0).is_err());
1108        assert!(chebwin(16, f64::NAN).is_err());
1109    }
1110
1111    #[test]
1112    fn chebwin_matches_scipy_known_values() {
1113        // scipy.signal.windows.chebwin(11, 50) reference values.
1114        let want = [
1115            0.06228583, 0.20113857, 0.42847525, 0.69573494, 0.91497506, 1.0, 0.91497506,
1116            0.69573494, 0.42847525, 0.20113857, 0.06228583,
1117        ];
1118        let got = chebwin(11, 50.0).unwrap();
1119        let s = got.as_slice().unwrap();
1120        for (i, (&g, &w)) in s.iter().zip(want.iter()).enumerate() {
1121            assert!((g - w).abs() < 1e-6, "i={i}: got={g}, want={w}");
1122        }
1123    }
1124
1125    #[test]
1126    fn chebwin_matches_scipy_even_length() {
1127        // scipy.signal.windows.chebwin(8, 40) reference values.
1128        let want = [
1129            0.14609713, 0.41790422, 0.75944595, 1.0, 1.0, 0.75944595, 0.41790422, 0.14609713,
1130        ];
1131        let got = chebwin(8, 40.0).unwrap();
1132        let s = got.as_slice().unwrap();
1133        for (i, (&g, &w)) in s.iter().zip(want.iter()).enumerate() {
1134            assert!((g - w).abs() < 1e-6, "i={i}: got={g}, want={w}");
1135        }
1136    }
1137
1138    #[test]
1139    fn chebwin_m0_m1_edge_cases() {
1140        let z = chebwin(0, 50.0).unwrap();
1141        assert_eq!(z.shape(), &[0]);
1142        let one = chebwin(1, 50.0).unwrap();
1143        assert_eq!(one.as_slice().unwrap(), &[1.0]);
1144    }
1145
1146    // ---- additional scipy.signal windows (#738) ------------------------
1147
1148    #[test]
1149    fn boxcar_all_ones() {
1150        let w = boxcar(8).unwrap();
1151        let s = w.as_slice().unwrap();
1152        for &v in s {
1153            assert!((v - 1.0).abs() < 1e-15);
1154        }
1155    }
1156
1157    #[test]
1158    fn triang_centre_is_max_and_endpoints_smaller() {
1159        let w = triang(7).unwrap();
1160        let s = w.as_slice().unwrap();
1161        // Symmetry: w[n] == w[m-1-n].
1162        for i in 0..s.len() / 2 {
1163            assert!((s[i] - s[s.len() - 1 - i]).abs() < 1e-12);
1164        }
1165        // Centre is the peak.
1166        let mid = s.len() / 2;
1167        for i in 0..s.len() {
1168            if i != mid {
1169                assert!(s[i] <= s[mid] + 1e-12);
1170            }
1171        }
1172    }
1173
1174    #[test]
1175    fn bohman_zero_at_endpoints_and_one_at_centre() {
1176        let w = bohman(5).unwrap();
1177        let s = w.as_slice().unwrap();
1178        assert!(s[0].abs() < 1e-12);
1179        assert!(s[s.len() - 1].abs() < 1e-12);
1180        // Centre value is the maximum and very close to 1.
1181        let mid = s.len() / 2;
1182        assert!((s[mid] - 1.0).abs() < 1e-6);
1183    }
1184
1185    #[test]
1186    fn flattop_centre_around_one_and_negative_lobes_present() {
1187        // flattop is designed for amplitude flatness — centre ≈ 1
1188        // with the canonical scaling, and side regions go negative.
1189        let w = flattop(11).unwrap();
1190        let s = w.as_slice().unwrap();
1191        let mid = s.len() / 2;
1192        assert!((s[mid] - 1.0).abs() < 0.01);
1193        // Some side samples must go below zero (window has negative
1194        // side lobes by design).
1195        let any_negative = s.iter().any(|&v| v < -0.001);
1196        assert!(any_negative, "flattop should have negative side lobes");
1197    }
1198
1199    #[test]
1200    fn lanczos_centre_is_one_and_endpoints_zero() {
1201        let w = lanczos(9).unwrap();
1202        let s = w.as_slice().unwrap();
1203        let mid = s.len() / 2;
1204        assert!((s[mid] - 1.0).abs() < 1e-12);
1205        assert!(s[0].abs() < 1e-12);
1206        assert!(s[s.len() - 1].abs() < 1e-12);
1207    }
1208
1209    #[test]
1210    fn small_m_edge_cases_handled() {
1211        // m = 0 returns empty, m = 1 returns [1.0] for symmetric windows.
1212        for f in [triang as fn(usize) -> _, bohman, lanczos] {
1213            let z = f(0).unwrap();
1214            assert_eq!(z.shape(), &[0]);
1215            let one = f(1).unwrap();
1216            assert_eq!(one.shape(), &[1]);
1217            assert_eq!(one.as_slice().unwrap(), &[1.0]);
1218        }
1219    }
1220
1221    // ---- f32 sibling functions (#529) ----------------------------------
1222
1223    #[test]
1224    fn f32_windows_match_f64_within_f32_eps() {
1225        for m in [1usize, 5, 16, 64] {
1226            for (name, got, want) in [
1227                ("bartlett", bartlett_f32(m).unwrap(), bartlett(m).unwrap()),
1228                ("blackman", blackman_f32(m).unwrap(), blackman(m).unwrap()),
1229                ("hamming", hamming_f32(m).unwrap(), hamming(m).unwrap()),
1230                ("hanning", hanning_f32(m).unwrap(), hanning(m).unwrap()),
1231            ] {
1232                assert_eq!(got.shape(), want.shape(), "{name} m={m} shape");
1233                let g = got.as_slice().unwrap();
1234                let w = want.as_slice().unwrap();
1235                for (i, (&a, &b)) in g.iter().zip(w.iter()).enumerate() {
1236                    let bf = b as f32;
1237                    assert!(
1238                        (a - bf).abs() < 1e-6,
1239                        "{name} m={m} idx={i}: f32 {a} vs f64 {b}"
1240                    );
1241                }
1242            }
1243        }
1244    }
1245
1246    #[test]
1247    fn kaiser_f32_matches_kaiser() {
1248        let m = 20;
1249        let beta = 8.6;
1250        let f32_arr = kaiser_f32(m, beta).unwrap();
1251        let f64_arr = kaiser(m, beta).unwrap();
1252        for (a, b) in f32_arr.iter().zip(f64_arr.iter()) {
1253            assert!((a - *b as f32).abs() < 1e-5);
1254        }
1255    }
1256
1257    // Helper: compare two slices within tolerance
1258    fn assert_close(actual: &[f64], expected: &[f64], tol: f64) {
1259        assert_eq!(
1260            actual.len(),
1261            expected.len(),
1262            "length mismatch: {} vs {}",
1263            actual.len(),
1264            expected.len()
1265        );
1266        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
1267            assert!(
1268                (a - e).abs() <= tol,
1269                "element {i}: {a} vs {e} (diff = {})",
1270                (a - e).abs()
1271            );
1272        }
1273    }
1274
1275    // -----------------------------------------------------------------------
1276    // Edge cases: M=0 and M=1
1277    // -----------------------------------------------------------------------
1278
1279    #[test]
1280    fn bartlett_m0() {
1281        let w = bartlett(0).unwrap();
1282        assert_eq!(w.shape(), &[0]);
1283    }
1284
1285    #[test]
1286    fn bartlett_m1() {
1287        let w = bartlett(1).unwrap();
1288        assert_eq!(w.as_slice().unwrap(), &[1.0]);
1289    }
1290
1291    #[test]
1292    fn blackman_m0() {
1293        let w = blackman(0).unwrap();
1294        assert_eq!(w.shape(), &[0]);
1295    }
1296
1297    #[test]
1298    fn blackman_m1() {
1299        let w = blackman(1).unwrap();
1300        assert_eq!(w.as_slice().unwrap(), &[1.0]);
1301    }
1302
1303    #[test]
1304    fn hamming_m0() {
1305        let w = hamming(0).unwrap();
1306        assert_eq!(w.shape(), &[0]);
1307    }
1308
1309    #[test]
1310    fn hamming_m1() {
1311        let w = hamming(1).unwrap();
1312        assert_eq!(w.as_slice().unwrap(), &[1.0]);
1313    }
1314
1315    #[test]
1316    fn hanning_m0() {
1317        let w = hanning(0).unwrap();
1318        assert_eq!(w.shape(), &[0]);
1319    }
1320
1321    #[test]
1322    fn hanning_m1() {
1323        let w = hanning(1).unwrap();
1324        assert_eq!(w.as_slice().unwrap(), &[1.0]);
1325    }
1326
1327    #[test]
1328    fn kaiser_m0() {
1329        let w = kaiser(0, 14.0).unwrap();
1330        assert_eq!(w.shape(), &[0]);
1331    }
1332
1333    #[test]
1334    fn kaiser_m1() {
1335        let w = kaiser(1, 14.0).unwrap();
1336        assert_eq!(w.as_slice().unwrap(), &[1.0]);
1337    }
1338
1339    #[test]
1340    fn kaiser_negative_beta() {
1341        // Negative beta should produce the same result as positive beta (I0 is even)
1342        let pos = kaiser(5, 1.0).unwrap();
1343        let neg = kaiser(5, -1.0).unwrap();
1344        assert_eq!(pos.as_slice().unwrap(), neg.as_slice().unwrap());
1345    }
1346
1347    #[test]
1348    fn kaiser_nan_beta() {
1349        assert!(kaiser(5, f64::NAN).is_err());
1350    }
1351
1352    // -----------------------------------------------------------------------
1353    // AC-1: bartlett(5) matches np.bartlett(5) to within 4 ULPs
1354    // -----------------------------------------------------------------------
1355    // np.bartlett(5) = [0.0, 0.5, 1.0, 0.5, 0.0]
1356    #[test]
1357    fn bartlett_5_ac1() {
1358        let w = bartlett(5).unwrap();
1359        let expected = [0.0, 0.5, 1.0, 0.5, 0.0];
1360        assert_close(w.as_slice().unwrap(), &expected, 1e-14);
1361    }
1362
1363    // -----------------------------------------------------------------------
1364    // AC-2: kaiser(5, 14.0) matches np.kaiser(5, 14.0) to within 4 ULPs
1365    // -----------------------------------------------------------------------
1366    // np.kaiser(5, 14.0) = [7.72686684e-06, 1.64932188e-01, 1.0, 1.64932188e-01, 7.72686684e-06]
1367    #[test]
1368    fn kaiser_5_14_ac2() {
1369        let w = kaiser(5, 14.0).unwrap();
1370        let s = w.as_slice().unwrap();
1371        assert_eq!(s.len(), 5);
1372        // NumPy reference values (verified via np.kaiser(5, 14.0))
1373        let expected: [f64; 5] = [
1374            7.72686684e-06,
1375            1.64932188e-01,
1376            1.0,
1377            1.64932188e-01,
1378            7.72686684e-06,
1379        ];
1380        // Bessel polynomial approximation has limited precision (~6 digits)
1381        for (i, (&a, &e)) in s.iter().zip(expected.iter()).enumerate() {
1382            let tol = if e.abs() < 1e-4 { 1e-8 } else { 1e-6 };
1383            assert!(
1384                (a - e).abs() <= tol,
1385                "kaiser element {i}: {a} vs {e} (diff = {})",
1386                (a - e).abs()
1387            );
1388        }
1389    }
1390
1391    // -----------------------------------------------------------------------
1392    // AC-3: All 5 window functions return correct length and match NumPy fixtures
1393    // -----------------------------------------------------------------------
1394
1395    // np.blackman(5) = [-1.38777878e-17, 3.40000000e-01, 1.00000000e+00, 3.40000000e-01, -1.38777878e-17]
1396    #[test]
1397    fn blackman_5() {
1398        let w = blackman(5).unwrap();
1399        assert_eq!(w.shape(), &[5]);
1400        let s = w.as_slice().unwrap();
1401        let expected = [
1402            -1.38777878e-17,
1403            3.40000000e-01,
1404            1.00000000e+00,
1405            3.40000000e-01,
1406            -1.38777878e-17,
1407        ];
1408        assert_close(s, &expected, 1e-10);
1409    }
1410
1411    // np.hamming(5) = [0.08, 0.54, 1.0, 0.54, 0.08]
1412    #[test]
1413    fn hamming_5() {
1414        let w = hamming(5).unwrap();
1415        assert_eq!(w.shape(), &[5]);
1416        let s = w.as_slice().unwrap();
1417        let expected = [0.08, 0.54, 1.0, 0.54, 0.08];
1418        assert_close(s, &expected, 1e-14);
1419    }
1420
1421    // np.hanning(5) = [0.0, 0.5, 1.0, 0.5, 0.0]
1422    #[test]
1423    fn hanning_5() {
1424        let w = hanning(5).unwrap();
1425        assert_eq!(w.shape(), &[5]);
1426        let s = w.as_slice().unwrap();
1427        let expected = [0.0, 0.5, 1.0, 0.5, 0.0];
1428        assert_close(s, &expected, 1e-14);
1429    }
1430
1431    // Larger window: np.bartlett(12)
1432    #[test]
1433    fn bartlett_12() {
1434        let w = bartlett(12).unwrap();
1435        assert_eq!(w.shape(), &[12]);
1436        let s = w.as_slice().unwrap();
1437        // First and last should be 0, peak near center
1438        assert!((s[0] - 0.0).abs() < 1e-14);
1439        assert!((s[11] - 0.0).abs() < 1e-14);
1440        // Symmetric
1441        for i in 0..6 {
1442            assert!((s[i] - s[11 - i]).abs() < 1e-14, "symmetry at {i}");
1443        }
1444    }
1445
1446    // Symmetry test for all windows
1447    #[test]
1448    fn all_windows_symmetric() {
1449        let m = 7;
1450        let windows: Vec<Array<f64, Ix1>> = vec![
1451            bartlett(m).unwrap(),
1452            blackman(m).unwrap(),
1453            hamming(m).unwrap(),
1454            hanning(m).unwrap(),
1455            kaiser(m, 5.0).unwrap(),
1456        ];
1457        for (idx, w) in windows.iter().enumerate() {
1458            let s = w.as_slice().unwrap();
1459            for i in 0..m / 2 {
1460                assert!(
1461                    (s[i] - s[m - 1 - i]).abs() < 1e-12,
1462                    "window {idx} not symmetric at {i}"
1463                );
1464            }
1465        }
1466    }
1467
1468    // All windows peak at center
1469    #[test]
1470    fn all_windows_peak_at_center() {
1471        let m = 9;
1472        let windows: Vec<Array<f64, Ix1>> = vec![
1473            bartlett(m).unwrap(),
1474            blackman(m).unwrap(),
1475            hamming(m).unwrap(),
1476            hanning(m).unwrap(),
1477            kaiser(m, 5.0).unwrap(),
1478        ];
1479        for (idx, w) in windows.iter().enumerate() {
1480            let s = w.as_slice().unwrap();
1481            let center = s[m / 2];
1482            assert!(
1483                (center - 1.0).abs() < 1e-10,
1484                "window {idx} center = {center}, expected 1.0"
1485            );
1486        }
1487    }
1488
1489    // Kaiser with beta=0 should be a rectangular window (all ones)
1490    #[test]
1491    fn kaiser_beta_zero() {
1492        let w = kaiser(5, 0.0).unwrap();
1493        let s = w.as_slice().unwrap();
1494        for &v in s {
1495            assert!((v - 1.0).abs() < 1e-10, "expected 1.0, got {v}");
1496        }
1497    }
1498
1499    #[test]
1500    fn bessel_i0_scalar_zero() {
1501        // Cephes Chebyshev expansion (issue #750): bit-near-exact at origin.
1502        assert!((bessel_i0_scalar::<f64>(0.0) - 1.0).abs() < 1e-13);
1503    }
1504
1505    #[test]
1506    fn bessel_i0_scalar_known() {
1507        // Issue #750: bessel_i0_scalar replaced A&S 9.8.1/9.8.2 with the
1508        // Cephes Chebyshev expansion (~1e-15 relative precision). Reference
1509        // values are from scipy.special.i0 (also Cephes).
1510        // I0(1) ~ 1.2660658777520082
1511        assert!((bessel_i0_scalar::<f64>(1.0) - 1.266_065_877_752_008_2).abs() < 1e-13);
1512        // I0(5) ~ 27.239871823604442 (now in the |x| <= 8 branch)
1513        assert!(
1514            (bessel_i0_scalar::<f64>(5.0) - 27.239_871_823_604_442).abs() / 27.239_871_823_604_442
1515                < 1e-13
1516        );
1517        // I0(10) ~ 2815.7166284662544 (asymptotic |x| > 8 branch)
1518        let expected_i0_10 = 2_815.716_628_466_254;
1519        assert!((bessel_i0_scalar::<f64>(10.0) - expected_i0_10).abs() / expected_i0_10 < 1e-13);
1520    }
1521
1522    // ----- Edge-length window coverage (#295) -----
1523
1524    #[test]
1525    fn bartlett_m2_is_zeros() {
1526        // Bartlett of length 2: w(n) = 1 - |2n/(M-1) - 1|
1527        // n=0: 1 - |0 - 1| = 0
1528        // n=1: 1 - |2 - 1| = 0
1529        let w = bartlett(2).unwrap();
1530        assert_eq!(w.as_slice().unwrap(), &[0.0, 0.0]);
1531    }
1532
1533    #[test]
1534    fn hanning_m2_is_zeros() {
1535        // Hann of length 2: w(n) = 0.5 * (1 - cos(2*pi*n/(M-1)))
1536        // n=0: 0.5 * (1 - cos(0)) = 0
1537        // n=1: 0.5 * (1 - cos(2*pi)) = 0
1538        let w = hanning(2).unwrap();
1539        let s = w.as_slice().unwrap();
1540        assert!(s[0].abs() < 1e-14);
1541        assert!(s[1].abs() < 1e-14);
1542    }
1543
1544    #[test]
1545    fn bartlett_even_length_is_symmetric() {
1546        // Even-length windows have no single "center"; symmetry holds
1547        // around the midpoint between indices (M/2 - 1) and (M/2).
1548        for &m in &[4usize, 6, 8, 10] {
1549            let w = bartlett(m).unwrap();
1550            let s = w.as_slice().unwrap();
1551            for i in 0..m / 2 {
1552                assert!(
1553                    (s[i] - s[m - 1 - i]).abs() < 1e-14,
1554                    "bartlett({m}) not symmetric at {i}"
1555                );
1556            }
1557        }
1558    }
1559
1560    #[test]
1561    fn blackman_even_length_is_symmetric() {
1562        for &m in &[4usize, 6, 8, 10] {
1563            let w = blackman(m).unwrap();
1564            let s = w.as_slice().unwrap();
1565            for i in 0..m / 2 {
1566                assert!(
1567                    (s[i] - s[m - 1 - i]).abs() < 1e-14,
1568                    "blackman({m}) not symmetric at {i}"
1569                );
1570            }
1571        }
1572    }
1573
1574    #[test]
1575    fn kaiser_large_beta_matches_numpy_degenerate() -> FerrayResult<()> {
1576        // Issue #1087 (supersedes the #294 hard-reject): numpy.kaiser never
1577        // raises on a finite beta (_function_base_impl.py:3735). For beta past
1578        // the i0 overflow point (~709.78) it returns its own Inf/Inf=NaN
1579        // degenerate window; ferray must MATCH it, not error.
1580        // Oracle (numpy 2.4.4): np.kaiser(8, 800.0) == np.kaiser(8, 1000.0) ==
1581        // [0.0, 0.0, nan, nan, nan, nan, 0.0, 0.0].
1582        let not_contig = || FerrayError::invalid_value("kaiser window must be contiguous");
1583        for beta in [800.0, 1000.0] {
1584            let w = kaiser(8, beta)?;
1585            let s = w.as_slice().ok_or_else(not_contig)?;
1586            assert_eq!(s.len(), 8);
1587            let expected_nan = [false, false, true, true, true, true, false, false];
1588            for (i, (&v, &nan)) in s.iter().zip(expected_nan.iter()).enumerate() {
1589                if nan {
1590                    assert!(
1591                        v.is_nan(),
1592                        "kaiser(8, {beta})[{i}] should be NaN, got {v:e}"
1593                    );
1594                } else {
1595                    assert_eq!(v, 0.0, "kaiser(8, {beta})[{i}] should be 0.0, got {v:e}");
1596                }
1597            }
1598        }
1599        // 700 is still finite (i0(700) finite).
1600        let w700 = kaiser(8, 700.0)?;
1601        let s700 = w700.as_slice().ok_or_else(not_contig)?;
1602        assert!(s700.iter().all(|v| v.is_finite()));
1603        Ok(())
1604    }
1605
1606    // =======================================================================
1607    // SciPy-extended windows (cosine, exponential, gaussian, general_cosine,
1608    // general_hamming, nuttall, parzen, taylor, tukey).
1609    // =======================================================================
1610
1611    fn close(a: f64, b: f64, tol: f64) -> bool {
1612        (a - b).abs() < tol
1613    }
1614
1615    // ----- cosine -----
1616
1617    #[test]
1618    fn cosine_length_and_endpoints() {
1619        // cosine(M) = sin(pi(n + 0.5)/M); endpoints are sin(pi/2M) and
1620        // sin(pi(M-0.5)/M) — both small but nonzero.
1621        let w = cosine(8).unwrap();
1622        let s = w.as_slice().unwrap();
1623        assert_eq!(s.len(), 8);
1624        // Symmetric.
1625        for i in 0..4 {
1626            assert!(close(s[i], s[7 - i], 1e-14));
1627        }
1628    }
1629
1630    #[test]
1631    fn cosine_m1_and_m0() {
1632        assert_eq!(cosine(0).unwrap().shape(), &[0]);
1633        assert_eq!(cosine(1).unwrap().as_slice().unwrap(), &[1.0]);
1634    }
1635
1636    // ----- exponential -----
1637
1638    #[test]
1639    fn exponential_centred_default() {
1640        // tau=1, default centre = (M-1)/2 = 3.5 for M=8 — midpoint
1641        // between samples 3 and 4.
1642        let w = exponential(8, None, 1.0).unwrap();
1643        let s = w.as_slice().unwrap();
1644        // Symmetric.
1645        for i in 0..4 {
1646            assert!(close(s[i], s[7 - i], 1e-14));
1647        }
1648        // Centre samples have largest values.
1649        let centre_max = s[3].max(s[4]);
1650        for &v in s {
1651            assert!(v <= centre_max + 1e-14);
1652        }
1653    }
1654
1655    #[test]
1656    fn exponential_rejects_nonpositive_tau() {
1657        assert!(exponential(8, None, 0.0).is_err());
1658        assert!(exponential(8, None, -1.0).is_err());
1659        assert!(exponential(8, None, f64::NAN).is_err());
1660    }
1661
1662    // ----- gaussian -----
1663
1664    #[test]
1665    fn gaussian_centre_is_one() {
1666        // For odd M, centre sample is exactly 1.
1667        let w = gaussian(11, 2.0).unwrap();
1668        let s = w.as_slice().unwrap();
1669        assert!(close(s[5], 1.0, 1e-14));
1670    }
1671
1672    #[test]
1673    fn gaussian_known_value() {
1674        // gaussian(7, 1) at n=4: z = (4 - 3) / 1 = 1, exp(-0.5) = 0.6065...
1675        let w = gaussian(7, 1.0).unwrap();
1676        let s = w.as_slice().unwrap();
1677        assert!(close(s[4], (-0.5_f64).exp(), 1e-14));
1678    }
1679
1680    #[test]
1681    fn gaussian_rejects_nonpositive_std() {
1682        assert!(gaussian(8, 0.0).is_err());
1683        assert!(gaussian(8, -1.0).is_err());
1684    }
1685
1686    // ----- general_cosine -----
1687
1688    #[test]
1689    fn general_cosine_with_hann_coeffs_matches_hann() {
1690        // [0.5, 0.5] → 0.5 - 0.5 cos(2πn/(M-1)) = Hann.
1691        let m = 9;
1692        let gc = general_cosine(m, &[0.5, 0.5]).unwrap();
1693        let hn = hanning(m).unwrap();
1694        for i in 0..m {
1695            assert!(close(
1696                gc.as_slice().unwrap()[i],
1697                hn.as_slice().unwrap()[i],
1698                1e-14,
1699            ));
1700        }
1701    }
1702
1703    #[test]
1704    fn general_cosine_with_blackman_coeffs_matches_blackman() {
1705        // [0.42, 0.5, 0.08] → classical Blackman.
1706        let m = 9;
1707        let gc = general_cosine(m, &[0.42, 0.5, 0.08]).unwrap();
1708        let bk = blackman(m).unwrap();
1709        for i in 0..m {
1710            assert!(close(
1711                gc.as_slice().unwrap()[i],
1712                bk.as_slice().unwrap()[i],
1713                1e-12,
1714            ));
1715        }
1716    }
1717
1718    #[test]
1719    fn general_cosine_rejects_empty_coeffs() {
1720        assert!(general_cosine(8, &[]).is_err());
1721    }
1722
1723    // ----- general_hamming -----
1724
1725    #[test]
1726    fn general_hamming_alpha_half_matches_hann() {
1727        let m = 9;
1728        let gh = general_hamming(m, 0.5).unwrap();
1729        let hn = hanning(m).unwrap();
1730        for i in 0..m {
1731            assert!(close(
1732                gh.as_slice().unwrap()[i],
1733                hn.as_slice().unwrap()[i],
1734                1e-14,
1735            ));
1736        }
1737    }
1738
1739    #[test]
1740    fn general_hamming_alpha_054_matches_hamming() {
1741        let m = 9;
1742        let gh = general_hamming(m, 0.54).unwrap();
1743        let hm = hamming(m).unwrap();
1744        for i in 0..m {
1745            assert!(close(
1746                gh.as_slice().unwrap()[i],
1747                hm.as_slice().unwrap()[i],
1748                1e-14,
1749            ));
1750        }
1751    }
1752
1753    // ----- nuttall -----
1754
1755    #[test]
1756    fn nuttall_endpoints_are_small() {
1757        // Nuttall is engineered to have very low sidelobes; endpoints
1758        // are O(1e-3) for typical M.
1759        let w = nuttall(64).unwrap();
1760        let s = w.as_slice().unwrap();
1761        assert!(s[0].abs() < 1e-2);
1762        assert!(s[s.len() - 1].abs() < 1e-2);
1763    }
1764
1765    #[test]
1766    fn nuttall_is_symmetric() {
1767        let m = 33;
1768        let w = nuttall(m).unwrap();
1769        let s = w.as_slice().unwrap();
1770        for i in 0..m / 2 {
1771            assert!(close(s[i], s[m - 1 - i], 1e-14));
1772        }
1773    }
1774
1775    // ----- parzen -----
1776
1777    #[test]
1778    fn parzen_endpoints_are_zero() {
1779        let w = parzen(16).unwrap();
1780        let s = w.as_slice().unwrap();
1781        // Outer edge of Parzen has very small value (cubic falloff).
1782        assert!(s[0].abs() < 1e-2);
1783        assert!(s[s.len() - 1].abs() < 1e-2);
1784    }
1785
1786    #[test]
1787    fn parzen_centre_is_one() {
1788        // For odd M, centre is at sample (M-1)/2; r = 0 → w = 1.
1789        let w = parzen(13).unwrap();
1790        let s = w.as_slice().unwrap();
1791        assert!(close(s[6], 1.0, 1e-14));
1792    }
1793
1794    #[test]
1795    fn parzen_is_symmetric() {
1796        let m = 21;
1797        let w = parzen(m).unwrap();
1798        let s = w.as_slice().unwrap();
1799        for i in 0..m / 2 {
1800            assert!(close(s[i], s[m - 1 - i], 1e-14));
1801        }
1802    }
1803
1804    // ----- taylor -----
1805
1806    #[test]
1807    fn taylor_default_normalised_centre_is_one() {
1808        // With norm=true the centre sample is 1.0.
1809        let w = taylor(33, 4, 30.0, true).unwrap();
1810        let s = w.as_slice().unwrap();
1811        assert!(close(s[16], 1.0, 1e-12));
1812    }
1813
1814    #[test]
1815    fn taylor_is_symmetric() {
1816        let m = 33;
1817        let w = taylor(m, 4, 30.0, true).unwrap();
1818        let s = w.as_slice().unwrap();
1819        for i in 0..m / 2 {
1820            assert!(close(s[i], s[m - 1 - i], 1e-12));
1821        }
1822    }
1823
1824    #[test]
1825    fn taylor_rejects_nbar_zero() {
1826        assert!(taylor(8, 0, 30.0, true).is_err());
1827        assert!(taylor(8, 4, f64::NAN, true).is_err());
1828    }
1829
1830    #[test]
1831    fn taylor_even_m_matches_scipy_analytic_peak() {
1832        // Regression for #810 (upstream from ferrotorch). Pre-fix, even-M
1833        // taylor windows diverged from scipy by ~1.5e-3 because the centre
1834        // value was estimated by averaging the two adjacent samples
1835        // (the secant midpoint) rather than evaluating the analytic peak
1836        // W((M-1)/2) = 1 + 2·Σ F_m at the fractional midpoint.
1837        //
1838        // scipy.signal.windows.taylor(16, nbar=4, sll=30, norm=True, sym=True)[0]
1839        //   = 0.252321041674507
1840        let w = taylor(16, 4, 30.0, true).unwrap();
1841        let s = w.as_slice().unwrap();
1842        let expected_first = 0.252_321_041_674_507_f64;
1843        assert!(
1844            (s[0] - expected_first).abs() < 1e-13,
1845            "taylor(16,4,30,true)[0] = {} expected {}",
1846            s[0],
1847            expected_first,
1848        );
1849        // The window is symmetric and even-length, so no sample sits
1850        // exactly at the centre — but the analytic peak (the value the
1851        // window would take at xn = 0) is, by construction, 1.0. The
1852        // closest pair of samples should lie just below 1.
1853        assert!(s[7] < 1.0 && s[8] < 1.0);
1854        assert!(s[7] > 0.99 && s[8] > 0.99);
1855    }
1856
1857    // ----- tukey -----
1858
1859    #[test]
1860    fn tukey_alpha_zero_is_rectangular() {
1861        let w = tukey(8, 0.0).unwrap();
1862        for &v in w.as_slice().unwrap() {
1863            assert!(close(v, 1.0, 1e-14));
1864        }
1865    }
1866
1867    #[test]
1868    fn tukey_alpha_one_matches_hann() {
1869        // alpha=1 → fully Hann-shaped.
1870        let m = 9;
1871        let tk = tukey(m, 1.0).unwrap();
1872        let hn = hanning(m).unwrap();
1873        for i in 0..m {
1874            assert!(close(
1875                tk.as_slice().unwrap()[i],
1876                hn.as_slice().unwrap()[i],
1877                1e-12,
1878            ));
1879        }
1880    }
1881
1882    #[test]
1883    fn tukey_centre_is_one() {
1884        let m = 21;
1885        let w = tukey(m, 0.5).unwrap();
1886        // Wide flat-top region: middle must be 1.
1887        assert!(close(w.as_slice().unwrap()[m / 2], 1.0, 1e-14));
1888    }
1889
1890    #[test]
1891    fn tukey_rejects_invalid_alpha() {
1892        assert!(tukey(8, -0.1).is_err());
1893        assert!(tukey(8, 1.1).is_err());
1894        assert!(tukey(8, f64::NAN).is_err());
1895    }
1896}
1897
1898#[cfg(test)]
1899mod debug_test_removed {
1900    // Debug test scaffolding for #740 chebwin development;
1901    // removed once the closed-form output matched scipy.
1902}