Skip to main content

fdars_core/fts/
spectral.rs

1//! Frequency-domain functional time series: spectral density operator and dynamic FPCA.
2//!
3//! This module adds spectral (frequency-domain) analysis for functional time
4//! series, building on the lagged-autocovariance machinery in [`super::acf`]:
5//!
6//! * [`spectral_density`] (FTS-03-01) — the spectral density operator, the
7//!   frequency-domain long-run covariance formed by a Bartlett-weighted DFT
8//!   (via `rustfft`) over the lagged autocovariance operators, evaluated at the
9//!   Fourier frequencies `θ_j = 2πj/N`.
10//! * [`dpca`] (FTS-03-02) — dynamic functional PCA: dynamic eigen-filters and
11//!   dynamic scores obtained by eigendecomposing the spectral density operator
12//!   per frequency and inverse-FFT-ing the eigenvectors into time-domain filter
13//!   taps over a symmetric lag window `[-L, L]`.
14//! * [`dpca_reconstruct`] (FTS-03-03) — curve reconstruction from dynamic scores
15//!   via inverse dynamic filtering, with an integrated-L2 reconstruction error
16//!   that is monotone non-increasing in the number of retained components.
17//!
18//! # R baseline
19//!
20//! Matches `freqdom` / `freqdom.fda` (Hörmann, Kidziński, Hallin 2015, *JRSS-B*)
21//! by capability. Documented divergences: the `1/2π` pre-factor is omitted
22//! (consistent with [`super::long_run_covariance`]); eigendecomposition uses
23//! `Re(f̂(θ))` via [`nalgebra::SymmetricEigen`] rather than a complex Hermitian
24//! path (nalgebra 0.33 has none without `faer`) — exact for the leading dynamic
25//! subspace of a real-lag-window (Bartlett) estimator; dynamic scores are
26//! trimmed to the valid interior `t ∈ [L, N-1-L]` (`valid_range`) rather than
27//! zero-padded. The estimator, scores, and reconstruction all use the same
28//! Simpson-weighted L2 inner product so the DPCA projection is self-consistent.
29//!
30//! # Conventions
31//!
32//! All entry points take explicit `argvals`, return `Result<_, FdarError>`, and
33//! validate inputs at entry. No randomness — outputs are a deterministic function
34//! of the input series.
35
36use super::{DpcaReconstruction, DpcaResult, SpectralDensityResult};
37use crate::error::FdarError;
38use crate::helpers::simpsons_weights;
39use crate::matrix::FdMatrix;
40use nalgebra::DMatrix;
41use rustfft::num_complex::Complex;
42use rustfft::FftPlanner;
43
44// ─── Input validation (mirrors fts/acf.rs; forecast.rs re-implements it too) ──
45
46/// Validate that `data` is non-empty and `argvals` length matches data columns.
47/// Returns `(n, m)` on success.
48fn validate_fts_input(data: &FdMatrix, argvals: &[f64]) -> Result<(usize, usize), FdarError> {
49    let (n, m) = data.shape();
50    if n == 0 || m == 0 {
51        return Err(FdarError::InvalidDimension {
52            parameter: "data",
53            expected: "non-empty matrix".to_string(),
54            actual: format!("{n} rows, {m} columns"),
55        });
56    }
57    if argvals.len() != m {
58        return Err(FdarError::InvalidDimension {
59            parameter: "argvals",
60            expected: format!("{m} elements (matching data columns)"),
61            actual: format!("{} elements", argvals.len()),
62        });
63    }
64    Ok((n, m))
65}
66
67/// Sample mean curve `xbar[j] = (1/n) Σ_i data[(i,j)]`.
68fn mean_curve(data: &FdMatrix, n: usize, m: usize) -> Vec<f64> {
69    let mut xbar = vec![0.0f64; m];
70    let inv_n = 1.0 / n as f64;
71    for (j, xb) in xbar.iter_mut().enumerate() {
72        let mut s = 0.0;
73        for i in 0..n {
74            s += data[(i, j)];
75        }
76        *xb = s * inv_n;
77    }
78    xbar
79}
80
81/// Bartlett lag-window weight `w_h = 1 - h/b`.
82#[inline]
83fn bartlett_weight(h: usize, bandwidth: usize) -> f64 {
84    1.0 - (h as f64) / (bandwidth as f64)
85}
86
87// ─── FTS-03-01: spectral density operator ─────────────────────────────────────
88
89/// Estimate the spectral density operator of a functional time series.
90///
91/// Computes the frequency-domain long-run covariance: for each entry `(j1, j2)`
92/// of the lag-`h` autocovariance operator sequence `{C_h}`, a Bartlett-weighted
93/// DFT across the lag index (via `rustfft`) yields the complex operator value at
94/// every Fourier frequency `θ_k = 2πk/N`. Negative lags use the stationarity
95/// identity `C_{-h}[j1,j2] = C_h[j2,j1]`, so the result is Hermitian.
96///
97/// # Arguments
98///
99/// * `data` — `N × m` functional time series (rows = curves, columns = grid).
100/// * `argvals` — grid points, length `m`.
101/// * `bandwidth` — Bartlett lag-window bandwidth; `None` uses `⌊N^{1/3}⌋`
102///   (matching [`super::long_run_covariance`]). `Some(0)` is rejected.
103///
104/// # Errors
105///
106/// [`FdarError::InvalidDimension`] on empty data or `argvals` length mismatch;
107/// [`FdarError::InvalidParameter`] if `bandwidth == Some(0)`.
108///
109/// # Divergence
110///
111/// The `1/2π` pre-factor is omitted; the frequency grid is the DFT grid
112/// `θ_k = 2πk/N` for `k = 0..N`.
113#[must_use = "the estimated spectral density operator is the return value and should be used"]
114pub fn spectral_density(
115    data: &FdMatrix,
116    argvals: &[f64],
117    bandwidth: Option<usize>,
118) -> Result<SpectralDensityResult, FdarError> {
119    let (n, m) = validate_fts_input(data, argvals)?;
120
121    let resolved_bandwidth = match bandwidth {
122        None => (n as f64).cbrt().floor().max(1.0) as usize,
123        Some(0) => {
124            return Err(FdarError::InvalidParameter {
125                parameter: "bandwidth",
126                message: "must be >= 1".to_string(),
127            });
128        }
129        Some(b) => b,
130    };
131    // Guard against usize underflow in autocovariance_matrix (needs h < n).
132    let max_h = resolved_bandwidth.min(n - 1);
133
134    let xbar = mean_curve(data, n, m);
135    // Lag operators C_0..=max_h (each flat column-major m×m).
136    let mut lag_ops: Vec<Vec<f64>> = Vec::with_capacity(max_h + 1);
137    for h in 0..=max_h {
138        lag_ops.push(super::acf::autocovariance_matrix(data, &xbar, h, n, m));
139    }
140
141    let n_freq = n;
142    let mut planner = FftPlanner::<f64>::new();
143    let fft = planner.plan_fft_forward(n_freq);
144
145    let mut re = vec![vec![0.0f64; m * m]; n_freq];
146    let mut im = vec![vec![0.0f64; m * m]; n_freq];
147
148    for j1 in 0..m {
149        for j2 in 0..m {
150            let mut buf = vec![Complex::new(0.0, 0.0); n_freq];
151            // Positive lags h at circular index h: w_h * C_h[j1, j2].
152            for (h, c_h) in lag_ops.iter().enumerate() {
153                let w_h = bartlett_weight(h, resolved_bandwidth);
154                buf[h] += Complex::new(w_h * c_h[j1 + j2 * m], 0.0);
155            }
156            // Negative lags h at circular index N-h: w_h * C_{-h}[j1,j2] = w_h * C_h[j2,j1].
157            for (h, c_h) in lag_ops.iter().enumerate().skip(1) {
158                let w_h = bartlett_weight(h, resolved_bandwidth);
159                buf[n_freq - h] += Complex::new(w_h * c_h[j2 + j1 * m], 0.0);
160            }
161            fft.process(&mut buf);
162            for k in 0..n_freq {
163                re[k][j1 + j2 * m] = buf[k].re;
164                im[k][j1 + j2 * m] = buf[k].im;
165            }
166        }
167    }
168
169    let freqs: Vec<f64> = (0..n_freq)
170        .map(|k| 2.0 * std::f64::consts::PI * (k as f64) / (n as f64))
171        .collect();
172
173    Ok(SpectralDensityResult {
174        freqs,
175        re,
176        im,
177        m,
178        n_curves: n,
179        bandwidth: resolved_bandwidth,
180    })
181}
182
183// ─── Eigendecomposition helper (real, Simpson-metric-scaled) ──────────────────
184
185/// Eigendecompose the metric-scaled real spectral operator `A = W^{1/2} Re(f̂) W^{1/2}`
186/// (`W` = Simpson weights). Returns the top-`ncomp` eigenvalues (descending) and
187/// the corresponding **scaled** eigenvectors `ψ_c` (Euclidean-orthonormal),
188/// sign-aligned so each vector's largest-magnitude entry is positive. The caller
189/// recovers physical filters via `φ_c[j] = ψ_c[j] / sqrt(w[j])`.
190fn eigen_at_frequency(
191    spec_real: &[f64],
192    m: usize,
193    ncomp: usize,
194    sqrt_w: &[f64],
195) -> (Vec<f64>, Vec<Vec<f64>>) {
196    // Build W^{1/2} Re(f̂) W^{1/2} directly (no intermediate `scaled` Vec), symmetrised defensively.
197    // (row, col) = (j1, j2); the column-major source index `j1 + j2 * m` matches `DMatrix::from_fn`.
198    let mut mat = DMatrix::from_fn(m, m, |j1, j2| {
199        spec_real[j1 + j2 * m] * sqrt_w[j1] * sqrt_w[j2]
200    });
201    for j1 in 0..m {
202        for j2 in (j1 + 1)..m {
203            let avg = 0.5 * (mat[(j1, j2)] + mat[(j2, j1)]);
204            mat[(j1, j2)] = avg;
205            mat[(j2, j1)] = avg;
206        }
207    }
208    let eig = nalgebra::SymmetricEigen::new(mat);
209    // Index-sort by eigenvalue descending — stable, matching the previous pair-sort's tie order
210    // (ascending original index). Materialise ONLY the retained `ncomp` eigenvectors as Vecs instead
211    // of all `m` (OPT-A: eliminates ~m per-call `col.iter().copied().collect()` allocations).
212    let mut idx: Vec<usize> = (0..m).collect();
213    idx.sort_by(|&a, &b| {
214        eig.eigenvalues[b]
215            .partial_cmp(&eig.eigenvalues[a])
216            .unwrap_or(std::cmp::Ordering::Equal)
217    });
218    let take = ncomp.min(m);
219    let mut eigenvalues: Vec<f64> = Vec::with_capacity(take);
220    let mut eigenvectors: Vec<Vec<f64>> = Vec::with_capacity(take);
221    for &col in idx.iter().take(take) {
222        eigenvalues.push(eig.eigenvalues[col]);
223        let mut evec: Vec<f64> = eig.eigenvectors.column(col).iter().copied().collect();
224        // Sign-align: make the largest-magnitude entry positive (Pitfall 2). Track argmax directly.
225        let mut arg = 0usize;
226        let mut best = 0.0f64;
227        for (i, &x) in evec.iter().enumerate() {
228            if x.abs() > best {
229                best = x.abs();
230                arg = i;
231            }
232        }
233        if evec[arg] < 0.0 {
234            evec.iter_mut().for_each(|x| *x = -*x);
235        }
236        eigenvectors.push(evec);
237    }
238    (eigenvalues, eigenvectors)
239}
240
241// ─── FTS-03-02: dynamic functional PCA ────────────────────────────────────────
242
243/// Compute dynamic functional PCA (DPCA) from the spectral density operator.
244///
245/// Eigendecomposes the (Simpson-metric-scaled) spectral density operator at each
246/// Fourier frequency, sign-aligns the eigenvectors across frequencies, and
247/// inverse-FFTs each eigenvector trajectory into real time-domain filter taps
248/// over the symmetric lag window `[-L, L]`. Dynamic scores are the Simpson-weighted
249/// time-domain convolution of the curve series with the filters, over the valid
250/// interior `t ∈ [L, N-1-L]`.
251///
252/// # Arguments
253///
254/// * `data` — `N × m` functional time series.
255/// * `argvals` — grid points, length `m`.
256/// * `ncomp` — number of dynamic components (`1..=m`).
257/// * `bandwidth` — Bartlett bandwidth forwarded to [`spectral_density`].
258/// * `filter_lag` — symmetric filter half-width `L`; `None` uses the resolved
259///   bandwidth. Must satisfy `L < N/2`.
260///
261/// # Errors
262///
263/// [`FdarError::InvalidParameter`] if `ncomp` is not in `1..=m` or `filter_lag`
264/// is `>= N/2`; propagates [`spectral_density`] validation errors.
265#[must_use = "the DPCA filters and scores are the return value and should be used"]
266pub fn dpca(
267    data: &FdMatrix,
268    argvals: &[f64],
269    ncomp: usize,
270    bandwidth: Option<usize>,
271    filter_lag: Option<usize>,
272) -> Result<DpcaResult, FdarError> {
273    let (n, m) = validate_fts_input(data, argvals)?;
274    if ncomp == 0 || ncomp > m {
275        return Err(FdarError::InvalidParameter {
276            parameter: "ncomp",
277            message: format!("must be in 1..={m}"),
278        });
279    }
280
281    let sd = spectral_density(data, argvals, bandwidth)?;
282    let l = filter_lag.unwrap_or(sd.bandwidth);
283    if l >= n / 2 {
284        return Err(FdarError::InvalidParameter {
285            parameter: "filter_lag",
286            message: format!("must be < N/2 = {}", n / 2),
287        });
288    }
289
290    let weights = simpsons_weights(argvals);
291    let sqrt_w: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
292    let n_freq = sd.n_curves;
293
294    // Eigendecompose per frequency; collect scaled eigenvectors ψ_c(θ_k).
295    let mut eigenvalues = vec![vec![0.0f64; n_freq]; ncomp];
296    // freq_vecs[k][c] = ψ_c(θ_k) (length m, scaled/Euclidean-orthonormal).
297    let mut freq_vecs: Vec<Vec<Vec<f64>>> = Vec::with_capacity(n_freq);
298    for k in 0..n_freq {
299        let (vals, vecs) = eigen_at_frequency(&sd.re[k], m, ncomp, &sqrt_w);
300        for c in 0..ncomp {
301            eigenvalues[c][k] = vals[c].max(0.0); // clip negative finite-sample eigenvalues
302        }
303        freq_vecs.push(vecs);
304    }
305
306    // Inverse-FFT each ψ_c(·)[j] trajectory → physical filter taps φ_c[j,l] = ψ_c[j,l]/sqrt(w[j]).
307    let mut inv_planner = FftPlanner::<f64>::new();
308    let ifft = inv_planner.plan_fft_inverse(n_freq);
309    let inv_n = 1.0 / (n_freq as f64);
310    let n_rows = 2 * l + 1;
311    let mut filters: Vec<FdMatrix> = Vec::with_capacity(ncomp);
312    for c in 0..ncomp {
313        let mut filt = vec![0.0f64; n_rows * m]; // column-major (2L+1) × m
314        for j in 0..m {
315            let mut buf: Vec<Complex<f64>> = (0..n_freq)
316                .map(|k| Complex::new(freq_vecs[k][c][j], 0.0))
317                .collect();
318            ifft.process(&mut buf);
319            let inv_sw = 1.0 / sqrt_w[j];
320            for lag in 0..=l {
321                let tap = buf[lag].re * inv_n * inv_sw;
322                let row_pos = l + lag; // lag +lag
323                let row_neg = l - lag; // lag -lag (symmetric filter)
324                                       // At lag == 0, row_pos == row_neg (== L): the two writes target the
325                                       // same central tap with the same value, which is intended.
326                filt[row_pos + j * n_rows] = tap;
327                filt[row_neg + j * n_rows] = tap;
328            }
329        }
330        filters.push(
331            FdMatrix::from_column_major(filt, n_rows, m)
332                .expect("dimension invariant: filt.len() == (2L+1) * m"),
333        );
334    }
335
336    // Dynamic scores: Simpson-weighted convolution over the interior t ∈ [L, N-1-L].
337    let n_interior = n - 2 * l;
338    let mut scores_flat = vec![0.0f64; n_interior * ncomp];
339    for (c, filt) in filters.iter().enumerate() {
340        for t in l..=(n - 1 - l) {
341            let mut s = 0.0;
342            for lag_idx in 0..n_rows {
343                let lag = lag_idx as isize - l as isize;
344                let ct = (t as isize + lag) as usize;
345                for j in 0..m {
346                    s += filt[(lag_idx, j)] * data[(ct, j)] * weights[j];
347                }
348            }
349            scores_flat[(t - l) + c * n_interior] = s;
350        }
351    }
352    let scores = FdMatrix::from_column_major(scores_flat, n_interior, ncomp)
353        .expect("dimension invariant: scores.len() == (N-2L) * ncomp");
354
355    Ok(DpcaResult {
356        filters,
357        scores,
358        eigenvalues,
359        n_freqs: n_freq,
360        filter_lag: l,
361        ncomp,
362        valid_range: (l, n - 1 - l),
363    })
364}
365
366// ─── FTS-03-03: reconstruction from dynamic scores ────────────────────────────
367
368/// Reconstruct curves from DPCA dynamic scores via inverse dynamic filtering.
369///
370/// For each retained-component count `K = 1..=ncomp`, reconstructs the interior
371/// series `X̂_K[t,j] = Σ_{c<K} Σ_l filters[c][l,j] · scores[t'+l, c]` and records
372/// the integrated-L2 error over the fully-defined interior. The error is monotone
373/// non-increasing in `K` (optimal DPCA projection). `fitted` holds the full-`ncomp`
374/// reconstruction over the DPCA interior.
375///
376/// # Errors
377///
378/// [`FdarError::InvalidDimension`] if `argvals`/`data` shapes are inconsistent with
379/// `dpca` (grid mismatch or score-length mismatch).
380#[must_use = "the reconstruction and its per-component error are the return value"]
381pub fn dpca_reconstruct(
382    data: &FdMatrix,
383    argvals: &[f64],
384    dpca: &DpcaResult,
385) -> Result<DpcaReconstruction, FdarError> {
386    let (n, m) = validate_fts_input(data, argvals)?;
387    let l = dpca.filter_lag;
388    let ncomp = dpca.ncomp;
389    // Guard the interior-length subtraction BEFORE it runs: passing data shorter
390    // than the DPCA fit (n < 2L+1) would underflow `n - 2*l` (panic in debug,
391    // garbage index in release) ahead of the consistency check below.
392    if n < 2 * l + 1 {
393        return Err(FdarError::InvalidDimension {
394            parameter: "data",
395            expected: format!("at least {} rows (2*filter_lag + 1)", 2 * l + 1),
396            actual: format!("{n} rows"),
397        });
398    }
399    let n_interior = n - 2 * l;
400
401    // Consistency checks against the supplied DpcaResult.
402    if dpca.scores.nrows() != n_interior || dpca.scores.ncols() != ncomp {
403        return Err(FdarError::InvalidDimension {
404            parameter: "dpca.scores",
405            expected: format!("{n_interior} rows × {ncomp} cols (matching data/filter_lag)"),
406            actual: format!(
407                "{} rows × {} cols",
408                dpca.scores.nrows(),
409                dpca.scores.ncols()
410            ),
411        });
412    }
413    if let Some(f0) = dpca.filters.first() {
414        if f0.ncols() != m {
415            return Err(FdarError::InvalidDimension {
416                parameter: "dpca.filters",
417                expected: format!("{m} columns (matching data grid)"),
418                actual: format!("{} columns", f0.ncols()),
419            });
420        }
421    }
422
423    let weights = simpsons_weights(argvals);
424    let n_rows = 2 * l + 1;
425
426    // full-K fitted curves over the DPCA interior [L, N-1-L]; edges use zero-padded
427    // scores (out-of-range score rows contribute 0).
428    let mut fitted_flat = vec![0.0f64; n_interior * m];
429    for c in 0..ncomp {
430        let filt = &dpca.filters[c];
431        for t in l..=(n - 1 - l) {
432            let row = t - l; // interior row
433            for lag_idx in 0..n_rows {
434                let lag = lag_idx as isize - l as isize;
435                let s_idx = row as isize + lag; // score interior index (t' + lag)
436                if s_idx < 0 || s_idx as usize >= n_interior {
437                    continue; // zero-padded score at the boundary
438                }
439                let s = dpca.scores[(s_idx as usize, c)];
440                for j in 0..m {
441                    fitted_flat[row + j * n_interior] += filt[(lag_idx, j)] * s;
442                }
443            }
444        }
445    }
446    let fitted = FdMatrix::from_column_major(fitted_flat, n_interior, m)
447        .expect("dimension invariant: fitted.len() == (N-2L) * m");
448
449    // Per-K cumulative reconstruction error over the fully-defined window
450    // t ∈ [2L, N-1-2L], where every score index (t'+lag) is in range.
451    let lo = 2 * l;
452    let hi = n.saturating_sub(2 * l + 1);
453    let mut reconstruction_error = vec![0.0f64; ncomp];
454    for k in 1..=ncomp {
455        let mut err = 0.0;
456        let mut count = 0usize;
457        for t in lo..=hi {
458            let row = t - l;
459            for j in 0..m {
460                let mut xhat = 0.0;
461                for c in 0..k {
462                    let filt = &dpca.filters[c];
463                    for lag_idx in 0..n_rows {
464                        let lag = lag_idx as isize - l as isize;
465                        let s_idx = (row as isize + lag) as usize;
466                        xhat += filt[(lag_idx, j)] * dpca.scores[(s_idx, c)];
467                    }
468                }
469                let d = data[(t, j)] - xhat;
470                err += d * d * weights[j];
471            }
472            count += 1;
473        }
474        reconstruction_error[k - 1] = if count > 0 { err / count as f64 } else { 0.0 };
475    }
476
477    Ok(DpcaReconstruction {
478        fitted,
479        reconstruction_error,
480        valid_range: dpca.valid_range,
481    })
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use rand::rngs::StdRng;
488    use rand::{Rng, SeedableRng};
489
490    fn uniform_grid(m: usize) -> Vec<f64> {
491        (0..m).map(|j| j as f64 / (m - 1) as f64).collect()
492    }
493
494    /// i.i.d. N(0,1) white-noise curve set, deterministic under `seed`.
495    fn white_noise(n: usize, m: usize, seed: u64) -> FdMatrix {
496        let mut rng = StdRng::seed_from_u64(seed);
497        let mut v = vec![0.0f64; n * m];
498        for x in &mut v {
499            *x = rng.sample::<f64, _>(rand_distr::StandardNormal);
500        }
501        FdMatrix::from_column_major(v, n, m).unwrap()
502    }
503
504    /// Rank-`r` series X_t[j] = Σ_c b_{t,c} ψ_c[j] with AR(1) score processes.
505    fn multimode_series(n: usize, m: usize, ar: &[f64], seed: u64) -> FdMatrix {
506        let r = ar.len();
507        let grid = uniform_grid(m);
508        // Smooth orthogonal-ish shapes: cosine modes.
509        let shapes: Vec<Vec<f64>> = (0..r)
510            .map(|c| {
511                grid.iter()
512                    .map(|&t| ((c + 1) as f64 * std::f64::consts::PI * t).cos())
513                    .collect()
514            })
515            .collect();
516        let mut rng = StdRng::seed_from_u64(seed);
517        let mut b = vec![0.0f64; r];
518        let mut v = vec![0.0f64; n * m];
519        // Burn-in the AR score processes.
520        for _ in 0..200 {
521            for c in 0..r {
522                b[c] = ar[c] * b[c] + rng.sample::<f64, _>(rand_distr::StandardNormal);
523            }
524        }
525        for i in 0..n {
526            for c in 0..r {
527                b[c] = ar[c] * b[c] + rng.sample::<f64, _>(rand_distr::StandardNormal);
528            }
529            for j in 0..m {
530                let mut s = 0.0;
531                for c in 0..r {
532                    s += b[c] * shapes[c][j];
533                }
534                v[i + j * n] = s;
535            }
536        }
537        FdMatrix::from_column_major(v, n, m).unwrap()
538    }
539
540    // ── FTS-03-01 ──────────────────────────────────────────────────────────
541
542    #[test]
543    fn tracer_white_noise_flat() {
544        let (n, m) = (120, 6);
545        let argvals = uniform_grid(m);
546        let data = white_noise(n, m, 7);
547        let sd = spectral_density(&data, &argvals, None).unwrap();
548
549        // Shape invariants.
550        assert_eq!(sd.freqs.len(), n);
551        assert_eq!(sd.re.len(), n);
552        assert_eq!(sd.im.len(), n);
553        assert_eq!(sd.re[0].len(), m * m);
554
555        // Rigorous correctness: FFT path == direct-sum DFT of the weighted lag ops.
556        let xbar = mean_curve(&data, n, m);
557        let bw = sd.bandwidth;
558        let max_h = bw.min(n - 1);
559        let lag_ops: Vec<Vec<f64>> = (0..=max_h)
560            .map(|h| super::super::acf::autocovariance_matrix(&data, &xbar, h, n, m))
561            .collect();
562        for &k in &[0usize, 1, 5, 37, n / 2, n - 1] {
563            let theta = 2.0 * std::f64::consts::PI * (k as f64) / (n as f64);
564            for &(j1, j2) in &[(0usize, 0usize), (1, 3), (4, 2)] {
565                let mut val = Complex::new(0.0, 0.0);
566                for (h, c_h) in lag_ops.iter().enumerate() {
567                    let w = bartlett_weight(h, bw);
568                    let e_neg = Complex::new((h as f64 * theta).cos(), -(h as f64 * theta).sin());
569                    val += Complex::new(w * c_h[j1 + j2 * m], 0.0) * e_neg;
570                    if h > 0 {
571                        let e_pos =
572                            Complex::new((h as f64 * theta).cos(), (h as f64 * theta).sin());
573                        val += Complex::new(w * c_h[j2 + j1 * m], 0.0) * e_pos;
574                    }
575                }
576                assert!((sd.re[k][j1 + j2 * m] - val.re).abs() < 1e-9);
577                assert!((sd.im[k][j1 + j2 * m] - val.im).abs() < 1e-9);
578            }
579        }
580
581        // DC (θ=0) diagonal is the summed real long-run variance and is positive.
582        assert!(sd.re[0][0] > 0.0);
583
584        // Near-flatness for white noise: every frequency's diagonal stays close to
585        // the mean-over-frequencies (deviation bounded by the finite-sample scale).
586        let mean_diag: f64 = (0..n).map(|k| sd.re[k][0]).sum::<f64>() / n as f64;
587        let c0_00 = lag_ops[0][0];
588        for k in 0..n {
589            assert!((sd.re[k][0] - mean_diag).abs() < 0.6 * c0_00.abs());
590        }
591    }
592
593    #[test]
594    fn spectral_density_errors_empty_and_argvals() {
595        let argvals = uniform_grid(5);
596        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
597        assert!(matches!(
598            spectral_density(&empty, &[], None),
599            Err(FdarError::InvalidDimension {
600                parameter: "data",
601                ..
602            })
603        ));
604        let data = white_noise(20, 5, 1);
605        assert!(matches!(
606            spectral_density(&data, &argvals[..3], None),
607            Err(FdarError::InvalidDimension {
608                parameter: "argvals",
609                ..
610            })
611        ));
612        assert!(matches!(
613            spectral_density(&data, &argvals, Some(0)),
614            Err(FdarError::InvalidParameter {
615                parameter: "bandwidth",
616                ..
617            })
618        ));
619    }
620
621    #[test]
622    fn spectral_density_deterministic() {
623        let (n, m) = (30, 5);
624        let argvals = uniform_grid(m);
625        let d1 = white_noise(n, m, 99);
626        let d2 = white_noise(n, m, 99);
627        let s1 = spectral_density(&d1, &argvals, None).unwrap();
628        let s2 = spectral_density(&d2, &argvals, None).unwrap();
629        assert_eq!(s1, s2);
630    }
631
632    #[test]
633    fn spectral_density_hermitian_symmetry() {
634        let (n, m) = (60, 6);
635        let argvals = uniform_grid(m);
636        let data = multimode_series(n, m, &[0.7, 0.4], 11);
637        let sd = spectral_density(&data, &argvals, None).unwrap();
638        for &k in &[1usize, 7, 23] {
639            for &(j1, j2) in &[(0usize, 2usize), (1, 5), (3, 4)] {
640                let a = sd.im[k][j1 + j2 * m];
641                let b = sd.im[k][j2 + j1 * m];
642                assert!((a + b).abs() < 1e-9, "Hermitian im antisymmetry at k={k}");
643            }
644        }
645    }
646
647    // ── FTS-03-02 ──────────────────────────────────────────────────────────
648
649    #[test]
650    fn dpca_shapes_and_finiteness() {
651        let (n, m, ncomp) = (100, 12, 3);
652        let argvals = uniform_grid(m);
653        let data = multimode_series(n, m, &[0.7, 0.5, 0.3], 5);
654        let res = dpca(&data, &argvals, ncomp, None, None).unwrap();
655        let l = res.filter_lag;
656        assert_eq!(res.filters.len(), ncomp);
657        for f in &res.filters {
658            assert_eq!(f.shape(), (2 * l + 1, m));
659            assert!(f.as_slice().iter().all(|x| x.is_finite()));
660        }
661        assert_eq!(res.scores.shape(), (n - 2 * l, ncomp));
662        assert!(res.scores.as_slice().iter().all(|x| x.is_finite()));
663        assert_eq!(res.eigenvalues.len(), ncomp);
664        for ev in &res.eigenvalues {
665            assert_eq!(ev.len(), res.n_freqs);
666        }
667        assert_eq!(res.valid_range, (l, n - 1 - l));
668    }
669
670    #[test]
671    fn dpca_white_noise_flat_eigenvalues() {
672        let (n, m, ncomp) = (120, 8, 2);
673        let argvals = uniform_grid(m);
674        let data = white_noise(n, m, 3);
675        let res = dpca(&data, &argvals, ncomp, None, None).unwrap();
676        // Leading eigenvalue is ~constant across frequencies for a flat spectrum.
677        let lead = &res.eigenvalues[0];
678        let mean: f64 = lead.iter().sum::<f64>() / lead.len() as f64;
679        assert!(mean > 0.0);
680        for &v in lead {
681            assert!((v - mean).abs() < 0.7 * mean);
682        }
683    }
684
685    #[test]
686    fn dpca_parameter_range_errors() {
687        let (n, m) = (60, 6);
688        let argvals = uniform_grid(m);
689        let data = multimode_series(n, m, &[0.6], 2);
690        assert!(matches!(
691            dpca(&data, &argvals, 0, None, None),
692            Err(FdarError::InvalidParameter {
693                parameter: "ncomp",
694                ..
695            })
696        ));
697        assert!(matches!(
698            dpca(&data, &argvals, m + 1, None, None),
699            Err(FdarError::InvalidParameter {
700                parameter: "ncomp",
701                ..
702            })
703        ));
704        assert!(matches!(
705            dpca(&data, &argvals, 2, None, Some(n / 2)),
706            Err(FdarError::InvalidParameter {
707                parameter: "filter_lag",
708                ..
709            })
710        ));
711    }
712
713    // ── FTS-03-03 ──────────────────────────────────────────────────────────
714
715    #[test]
716    fn dpca_reconstruct_monotone_error() {
717        let (n, m, ncomp) = (100, 16, 3);
718        let argvals = uniform_grid(m);
719        let data = multimode_series(n, m, &[0.7, 0.5, 0.3], 21);
720        let res = dpca(&data, &argvals, ncomp, None, Some(2)).unwrap();
721        let rec = dpca_reconstruct(&data, &argvals, &res).unwrap();
722        assert_eq!(rec.reconstruction_error.len(), ncomp);
723        for k in 0..ncomp - 1 {
724            assert!(
725                rec.reconstruction_error[k] >= rec.reconstruction_error[k + 1] - 1e-9,
726                "error not monotone at K={k}: {:?}",
727                rec.reconstruction_error
728            );
729        }
730        assert_eq!(rec.valid_range, res.valid_range);
731    }
732
733    #[test]
734    fn dpca_reconstruct_rank1_exact() {
735        // Rank-1 series X_t[j] = a_t * phi[j], a_t an AR(1) process.
736        let (n, m) = (80, 20);
737        let argvals = uniform_grid(m);
738        let phi: Vec<f64> = argvals
739            .iter()
740            .map(|&t| (2.0 * std::f64::consts::PI * t).sin())
741            .collect();
742        let mut rng = StdRng::seed_from_u64(42);
743        let mut a = 0.0f64;
744        for _ in 0..200 {
745            a = 0.8 * a + rng.sample::<f64, _>(rand_distr::StandardNormal);
746        }
747        let mut v = vec![0.0f64; n * m];
748        for i in 0..n {
749            a = 0.8 * a + rng.sample::<f64, _>(rand_distr::StandardNormal);
750            for j in 0..m {
751                v[i + j * n] = a * phi[j];
752            }
753        }
754        let data = FdMatrix::from_column_major(v, n, m).unwrap();
755        let res = dpca(&data, &argvals, 3, None, Some(3)).unwrap();
756        let rec = dpca_reconstruct(&data, &argvals, &res).unwrap();
757        assert_eq!(rec.fitted.shape(), (n - 2 * res.filter_lag, m));
758        // One dynamic component reconstructs the rank-1 series to near machine precision.
759        assert!(
760            rec.reconstruction_error[0] < 1e-4,
761            "rank-1 K=1 error too large: {}",
762            rec.reconstruction_error[0]
763        );
764    }
765
766    #[test]
767    fn dpca_reconstruct_dimension_mismatch() {
768        let (n, m) = (60, 6);
769        let argvals = uniform_grid(m);
770        let data = multimode_series(n, m, &[0.6, 0.4], 8);
771        let res = dpca(&data, &argvals, 2, None, Some(2)).unwrap();
772        // Wrong grid length → dimension error.
773        assert!(matches!(
774            dpca_reconstruct(&data, &argvals[..m - 1], &res),
775            Err(FdarError::InvalidDimension { .. })
776        ));
777    }
778
779    #[test]
780    fn dpca_reconstruct_short_data_errors_not_panics() {
781        // Regression (CR-01): data shorter than 2L+1 must return an error, not
782        // underflow `n - 2*l`.
783        let (n, m) = (60, 6);
784        let argvals = uniform_grid(m);
785        let data = multimode_series(n, m, &[0.6, 0.4], 8);
786        let res = dpca(&data, &argvals, 2, None, Some(4)).unwrap();
787        let short = multimode_series(2 * res.filter_lag, m, &[0.6, 0.4], 9); // n < 2L+1
788        assert!(matches!(
789            dpca_reconstruct(&short, &argvals, &res),
790            Err(FdarError::InvalidDimension {
791                parameter: "data",
792                ..
793            })
794        ));
795    }
796}