Skip to main content

fdars_core/
detrend.rs

1//! Detrending and decomposition functions for non-stationary functional data.
2//!
3//! This module provides methods for removing trends from functional data
4//! to enable more accurate seasonal analysis. It includes:
5//! - Linear detrending (least squares)
6//! - Polynomial detrending (QR decomposition)
7//! - Differencing (first and second order)
8//! - LOESS detrending (local polynomial regression)
9//! - Spline detrending (P-splines)
10//! - Automatic method selection via AIC
11
12use crate::iter_maybe_parallel;
13use crate::matrix::FdMatrix;
14use crate::smoothing::local_polynomial;
15use nalgebra::{DMatrix, DVector, Dyn, SVD};
16#[cfg(feature = "parallel")]
17use rayon::iter::ParallelIterator;
18use std::borrow::Cow;
19
20/// Result of detrending operation.
21#[derive(Debug, Clone)]
22pub struct TrendResult {
23    /// Estimated trend values (n x m)
24    pub trend: FdMatrix,
25    /// Detrended data (n x m)
26    pub detrended: FdMatrix,
27    /// Method used for detrending
28    pub method: Cow<'static, str>,
29    /// Polynomial coefficients (for polynomial methods, per sample)
30    /// For n samples with polynomial degree d: n x (d+1)
31    pub coefficients: Option<FdMatrix>,
32    /// Residual sum of squares for each sample
33    pub rss: Vec<f64>,
34    /// Number of parameters (for AIC calculation)
35    pub n_params: usize,
36}
37
38impl TrendResult {
39    /// Construct a no-op TrendResult (zero trend, data copied to detrended).
40    fn empty(
41        data: &FdMatrix,
42        n: usize,
43        m: usize,
44        method: Cow<'static, str>,
45        n_params: usize,
46    ) -> Self {
47        TrendResult {
48            trend: FdMatrix::zeros(n, m),
49            detrended: FdMatrix::from_slice(data.as_slice(), n, m)
50                .unwrap_or_else(|| FdMatrix::zeros(n, m)),
51            method,
52            coefficients: None,
53            rss: vec![0.0; n],
54            n_params,
55        }
56    }
57}
58
59/// Result of seasonal decomposition.
60#[derive(Debug, Clone)]
61pub struct DecomposeResult {
62    /// Trend component (n x m)
63    pub trend: FdMatrix,
64    /// Seasonal component (n x m)
65    pub seasonal: FdMatrix,
66    /// Remainder/residual component (n x m)
67    pub remainder: FdMatrix,
68    /// Period used for decomposition
69    pub period: f64,
70    /// Decomposition method ("additive" or "multiplicative")
71    pub method: Cow<'static, str>,
72}
73
74/// Remove linear trend from functional data using least squares.
75///
76/// # Arguments
77/// * `data` - Matrix (n x m): n samples, m evaluation points
78/// * `argvals` - Time/argument values of length m
79///
80/// # Returns
81/// TrendResult with trend, detrended data, and coefficients (intercept, slope)
82pub fn detrend_linear(data: &FdMatrix, argvals: &[f64]) -> TrendResult {
83    let (n, m) = data.shape();
84    if n == 0 || m < 2 || argvals.len() != m {
85        return TrendResult::empty(data, n, m, Cow::Borrowed("linear"), 2);
86    }
87
88    let mean_t: f64 = argvals.iter().sum::<f64>() / m as f64;
89    let ss_t: f64 = argvals.iter().map(|&t| (t - mean_t).powi(2)).sum();
90
91    // Compute only scalar coefficients in parallel (no Vec allocations per sample)
92    let scalars: Vec<(f64, f64, f64)> = iter_maybe_parallel!(0..n)
93        .map(|i| {
94            let mut sum_y = 0.0;
95            for j in 0..m {
96                sum_y += data[(i, j)];
97            }
98            let mean_y = sum_y / m as f64;
99            let mut sp = 0.0;
100            for j in 0..m {
101                sp += (argvals[j] - mean_t) * (data[(i, j)] - mean_y);
102            }
103            let slope = if ss_t.abs() > 1e-15 { sp / ss_t } else { 0.0 };
104            let intercept = mean_y - slope * mean_t;
105            let mut rss = 0.0;
106            for j in 0..m {
107                let residual = data[(i, j)] - (intercept + slope * argvals[j]);
108                rss += residual * residual;
109            }
110            (intercept, slope, rss)
111        })
112        .collect();
113
114    // Write directly into pre-allocated output matrices
115    let mut trend = FdMatrix::zeros(n, m);
116    let mut detrended = FdMatrix::zeros(n, m);
117    let mut coefficients = FdMatrix::zeros(n, 2);
118    let mut rss = vec![0.0; n];
119
120    for (i, &(intercept, slope, r)) in scalars.iter().enumerate() {
121        coefficients[(i, 0)] = intercept;
122        coefficients[(i, 1)] = slope;
123        rss[i] = r;
124        for j in 0..m {
125            let trend_val = intercept + slope * argvals[j];
126            trend[(i, j)] = trend_val;
127            detrended[(i, j)] = data[(i, j)] - trend_val;
128        }
129    }
130
131    TrendResult {
132        trend,
133        detrended,
134        method: Cow::Borrowed("linear"),
135        coefficients: Some(coefficients),
136        rss,
137        n_params: 2,
138    }
139}
140
141fn build_vandermonde_matrix(t_norm: &[f64], m: usize, n_coef: usize) -> DMatrix<f64> {
142    let mut design = DMatrix::zeros(m, n_coef);
143    for j in 0..m {
144        let t = t_norm[j];
145        let mut power = 1.0;
146        for k in 0..n_coef {
147            design[(j, k)] = power;
148            power *= t;
149        }
150    }
151    design
152}
153
154fn fit_polynomial_single_curve(
155    curve: &[f64],
156    svd: &SVD<f64, Dyn, Dyn>,
157    design: &DMatrix<f64>,
158    n_coef: usize,
159    m: usize,
160) -> (Vec<f64>, Vec<f64>, Vec<f64>, f64) {
161    let y = DVector::from_row_slice(curve);
162    let beta = svd
163        .solve(&y, 1e-10)
164        .unwrap_or_else(|_| DVector::zeros(n_coef));
165    let fitted = design * &beta;
166    let mut trend = vec![0.0; m];
167    let mut detrended = vec![0.0; m];
168    let mut rss = 0.0;
169    for j in 0..m {
170        trend[j] = fitted[j];
171        detrended[j] = curve[j] - fitted[j];
172        rss += detrended[j].powi(2);
173    }
174    let coefs: Vec<f64> = beta.iter().cloned().collect();
175    (trend, detrended, coefs, rss)
176}
177
178fn diff_single_curve(curve: &[f64], m: usize, order: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>, f64) {
179    let diff1: Vec<f64> = (0..m - 1).map(|j| curve[j + 1] - curve[j]).collect();
180    let detrended = if order == 2 {
181        (0..diff1.len() - 1)
182            .map(|j| diff1[j + 1] - diff1[j])
183            .collect()
184    } else {
185        diff1.clone()
186    };
187    let initial_values = if order == 2 {
188        vec![curve[0], curve[1]]
189    } else {
190        vec![curve[0]]
191    };
192    let rss: f64 = detrended.iter().map(|&x| x.powi(2)).sum();
193    let new_m = m - order;
194    let mut trend = vec![0.0; m];
195    trend[0] = curve[0];
196    if order == 1 {
197        for j in 1..m {
198            trend[j] = curve[j] - if j <= new_m { detrended[j - 1] } else { 0.0 };
199        }
200    } else {
201        trend = curve.to_vec();
202    }
203    let mut det_full = vec![0.0; m];
204    det_full[..new_m].copy_from_slice(&detrended[..new_m]);
205    (trend, det_full, initial_values, rss)
206}
207
208fn reassemble_polynomial_results(
209    results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, f64)>,
210    n: usize,
211    m: usize,
212    n_coef: usize,
213) -> (FdMatrix, FdMatrix, FdMatrix, Vec<f64>) {
214    let mut trend = FdMatrix::zeros(n, m);
215    let mut detrended = FdMatrix::zeros(n, m);
216    let mut coefficients = FdMatrix::zeros(n, n_coef);
217    let mut rss = vec![0.0; n];
218    for (i, (t, d, coefs, r)) in results.into_iter().enumerate() {
219        for j in 0..m {
220            trend[(i, j)] = t[j];
221            detrended[(i, j)] = d[j];
222        }
223        for k in 0..n_coef {
224            coefficients[(i, k)] = coefs[k];
225        }
226        rss[i] = r;
227    }
228    (trend, detrended, coefficients, rss)
229}
230
231/// Reassemble per-curve (trend, detrended, rss) results into FdMatrix outputs.
232fn reassemble_trend_results(
233    results: Vec<(Vec<f64>, Vec<f64>, f64)>,
234    n: usize,
235    m: usize,
236) -> (FdMatrix, FdMatrix, Vec<f64>) {
237    let mut trend = FdMatrix::zeros(n, m);
238    let mut detrended = FdMatrix::zeros(n, m);
239    let mut rss = vec![0.0; n];
240    for (i, (t, d, r)) in results.into_iter().enumerate() {
241        for j in 0..m {
242            trend[(i, j)] = t[j];
243            detrended[(i, j)] = d[j];
244        }
245        rss[i] = r;
246    }
247    (trend, detrended, rss)
248}
249
250/// Remove polynomial trend from functional data using QR decomposition.
251pub fn detrend_polynomial(data: &FdMatrix, argvals: &[f64], degree: usize) -> TrendResult {
252    let (n, m) = data.shape();
253    if n == 0 || m < degree + 1 || argvals.len() != m || degree == 0 {
254        return TrendResult::empty(
255            data,
256            n,
257            m,
258            Cow::Owned(format!("polynomial({})", degree)),
259            degree + 1,
260        );
261    }
262    if degree == 1 {
263        let mut result = detrend_linear(data, argvals);
264        result.method = Cow::Borrowed("polynomial(1)");
265        return result;
266    }
267    let n_coef = degree + 1;
268    let t_min = argvals.iter().cloned().fold(f64::INFINITY, f64::min);
269    let t_max = argvals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
270    let t_range = if (t_max - t_min).abs() > 1e-15 {
271        t_max - t_min
272    } else {
273        1.0
274    };
275    let t_norm: Vec<f64> = argvals.iter().map(|&t| (t - t_min) / t_range).collect();
276    let design = build_vandermonde_matrix(&t_norm, m, n_coef);
277    let svd = design.clone().svd(true, true);
278    let results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, f64)> = iter_maybe_parallel!(0..n)
279        .map(|i| {
280            let curve: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
281            fit_polynomial_single_curve(&curve, &svd, &design, n_coef, m)
282        })
283        .collect();
284    let (trend, detrended, coefficients, rss) =
285        reassemble_polynomial_results(results, n, m, n_coef);
286    TrendResult {
287        trend,
288        detrended,
289        method: Cow::Owned(format!("polynomial({})", degree)),
290        coefficients: Some(coefficients),
291        rss,
292        n_params: n_coef,
293    }
294}
295
296/// Remove trend by differencing.
297pub fn detrend_diff(data: &FdMatrix, order: usize) -> TrendResult {
298    let (n, m) = data.shape();
299    if n == 0 || m <= order || order == 0 || order > 2 {
300        return TrendResult::empty(data, n, m, Cow::Owned(format!("diff{}", order)), order);
301    }
302    let results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, f64)> = iter_maybe_parallel!(0..n)
303        .map(|i| {
304            let curve: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
305            diff_single_curve(&curve, m, order)
306        })
307        .collect();
308    let (trend, detrended, coefficients, rss) = reassemble_polynomial_results(results, n, m, order);
309    TrendResult {
310        trend,
311        detrended,
312        method: Cow::Owned(format!("diff{}", order)),
313        coefficients: Some(coefficients),
314        rss,
315        n_params: order,
316    }
317}
318
319/// Remove trend using LOESS (local polynomial regression).
320pub fn detrend_loess(
321    data: &FdMatrix,
322    argvals: &[f64],
323    bandwidth: f64,
324    degree: usize,
325) -> TrendResult {
326    let (n, m) = data.shape();
327    if n == 0 || m < 3 || argvals.len() != m || bandwidth <= 0.0 {
328        return TrendResult::empty(
329            data,
330            n,
331            m,
332            Cow::Borrowed("loess"),
333            (m as f64 * bandwidth.max(0.0)).ceil() as usize,
334        );
335    }
336    let t_min = argvals.iter().cloned().fold(f64::INFINITY, f64::min);
337    let t_max = argvals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
338    let abs_bandwidth = (t_max - t_min) * bandwidth;
339    let results: Vec<(Vec<f64>, Vec<f64>, f64)> = iter_maybe_parallel!(0..n)
340        .map(|i| {
341            let curve: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
342            let trend =
343                local_polynomial(argvals, &curve, argvals, abs_bandwidth, degree, "gaussian");
344            let mut detrended = vec![0.0; m];
345            let mut rss = 0.0;
346            for j in 0..m {
347                detrended[j] = curve[j] - trend[j];
348                rss += detrended[j].powi(2);
349            }
350            (trend, detrended, rss)
351        })
352        .collect();
353    let (trend, detrended, rss) = reassemble_trend_results(results, n, m);
354    let n_params = (m as f64 * bandwidth).ceil() as usize;
355    TrendResult {
356        trend,
357        detrended,
358        method: Cow::Borrowed("loess"),
359        coefficients: None,
360        rss,
361        n_params,
362    }
363}
364
365/// Automatically select the best detrending method using AIC.
366pub fn auto_detrend(data: &FdMatrix, argvals: &[f64]) -> TrendResult {
367    let (n, m) = data.shape();
368    if n == 0 || m < 4 || argvals.len() != m {
369        return TrendResult::empty(data, n, m, Cow::Borrowed("auto(none)"), 0);
370    }
371    let compute_aic = |result: &TrendResult| -> f64 {
372        let mut total_aic = 0.0;
373        for i in 0..n {
374            let rss = result.rss[i];
375            let k = result.n_params as f64;
376            let aic = if rss > 1e-15 {
377                m as f64 * (rss / m as f64).ln() + 2.0 * k
378            } else {
379                f64::NEG_INFINITY
380            };
381            total_aic += aic;
382        }
383        total_aic / n as f64
384    };
385    let linear = detrend_linear(data, argvals);
386    let poly2 = detrend_polynomial(data, argvals, 2);
387    let poly3 = detrend_polynomial(data, argvals, 3);
388    let loess = detrend_loess(data, argvals, 0.3, 2);
389    let aic_linear = compute_aic(&linear);
390    let aic_poly2 = compute_aic(&poly2);
391    let aic_poly3 = compute_aic(&poly3);
392    let aic_loess = compute_aic(&loess);
393    let methods = [
394        (aic_linear, "linear", linear),
395        (aic_poly2, "polynomial(2)", poly2),
396        (aic_poly3, "polynomial(3)", poly3),
397        (aic_loess, "loess", loess),
398    ];
399    let (_, best_name, mut best_result) = methods
400        .into_iter()
401        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
402        .unwrap();
403    best_result.method = Cow::Owned(format!("auto({})", best_name));
404    best_result
405}
406
407fn fit_fourier_seasonal(
408    detrended_i: &[f64],
409    argvals: &[f64],
410    omega: f64,
411    n_harm: usize,
412    m: usize,
413) -> (Vec<f64>, Vec<f64>) {
414    let n_coef = 2 * n_harm;
415    let mut design = DMatrix::zeros(m, n_coef);
416    for j in 0..m {
417        let t = argvals[j];
418        for k in 0..n_harm {
419            let freq = (k + 1) as f64 * omega;
420            design[(j, 2 * k)] = (freq * t).cos();
421            design[(j, 2 * k + 1)] = (freq * t).sin();
422        }
423    }
424    let y = DVector::from_row_slice(detrended_i);
425    let svd = design.clone().svd(true, true);
426    let coef = svd
427        .solve(&y, 1e-10)
428        .unwrap_or_else(|_| DVector::zeros(n_coef));
429    let fitted = &design * &coef;
430    let seasonal: Vec<f64> = fitted.iter().cloned().collect();
431    let remainder: Vec<f64> = (0..m).map(|j| detrended_i[j] - seasonal[j]).collect();
432    (seasonal, remainder)
433}
434
435/// Additive seasonal decomposition: data = trend + seasonal + remainder
436pub fn decompose_additive(
437    data: &FdMatrix,
438    argvals: &[f64],
439    period: f64,
440    trend_method: &str,
441    bandwidth: f64,
442    n_harmonics: usize,
443) -> DecomposeResult {
444    let (n, m) = data.shape();
445    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
446        return DecomposeResult {
447            trend: FdMatrix::zeros(n, m),
448            seasonal: FdMatrix::zeros(n, m),
449            remainder: FdMatrix::from_slice(data.as_slice(), n, m)
450                .unwrap_or_else(|| FdMatrix::zeros(n, m)),
451            period,
452            method: Cow::Borrowed("additive"),
453        };
454    }
455    let _ = trend_method;
456    let trend_result = detrend_loess(data, argvals, bandwidth.max(0.3), 2);
457    let n_harm = n_harmonics.max(1).min(m / 4);
458    let omega = 2.0 * std::f64::consts::PI / period;
459    let results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>)> = iter_maybe_parallel!(0..n)
460        .map(|i| {
461            let trend_i: Vec<f64> = (0..m).map(|j| trend_result.trend[(i, j)]).collect();
462            let detrended_i: Vec<f64> = (0..m).map(|j| trend_result.detrended[(i, j)]).collect();
463            let (seasonal, remainder) =
464                fit_fourier_seasonal(&detrended_i, argvals, omega, n_harm, m);
465            (trend_i, seasonal, remainder)
466        })
467        .collect();
468    let mut trend = FdMatrix::zeros(n, m);
469    let mut seasonal = FdMatrix::zeros(n, m);
470    let mut remainder = FdMatrix::zeros(n, m);
471    for (i, (t, s, r)) in results.into_iter().enumerate() {
472        for j in 0..m {
473            trend[(i, j)] = t[j];
474            seasonal[(i, j)] = s[j];
475            remainder[(i, j)] = r[j];
476        }
477    }
478    DecomposeResult {
479        trend,
480        seasonal,
481        remainder,
482        period,
483        method: Cow::Borrowed("additive"),
484    }
485}
486
487/// Multiplicative seasonal decomposition: data = trend * seasonal * remainder
488pub fn decompose_multiplicative(
489    data: &FdMatrix,
490    argvals: &[f64],
491    period: f64,
492    trend_method: &str,
493    bandwidth: f64,
494    n_harmonics: usize,
495) -> DecomposeResult {
496    let (n, m) = data.shape();
497    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
498        return DecomposeResult {
499            trend: FdMatrix::zeros(n, m),
500            seasonal: FdMatrix::zeros(n, m),
501            remainder: FdMatrix::from_slice(data.as_slice(), n, m)
502                .unwrap_or_else(|| FdMatrix::zeros(n, m)),
503            period,
504            method: Cow::Borrowed("multiplicative"),
505        };
506    }
507    let min_val = data
508        .as_slice()
509        .iter()
510        .cloned()
511        .fold(f64::INFINITY, f64::min);
512    let shift = if min_val <= 0.0 { -min_val + 1.0 } else { 0.0 };
513    let log_data_vec: Vec<f64> = data.as_slice().iter().map(|&x| (x + shift).ln()).collect();
514    let log_data = FdMatrix::from_column_major(log_data_vec, n, m).unwrap();
515    let additive_result = decompose_additive(
516        &log_data,
517        argvals,
518        period,
519        trend_method,
520        bandwidth,
521        n_harmonics,
522    );
523    let mut trend = FdMatrix::zeros(n, m);
524    let mut seasonal = FdMatrix::zeros(n, m);
525    let mut remainder = FdMatrix::zeros(n, m);
526    for i in 0..n {
527        for j in 0..m {
528            trend[(i, j)] = additive_result.trend[(i, j)].exp() - shift;
529            seasonal[(i, j)] = additive_result.seasonal[(i, j)].exp();
530            remainder[(i, j)] = additive_result.remainder[(i, j)].exp();
531        }
532    }
533    DecomposeResult {
534        trend,
535        seasonal,
536        remainder,
537        period,
538        method: Cow::Borrowed("multiplicative"),
539    }
540}
541
542// ============================================================================
543// STL Decomposition (Cleveland et al., 1990)
544// ============================================================================
545
546/// Result of STL decomposition including robustness weights.
547#[derive(Debug, Clone)]
548pub struct StlResult {
549    /// Trend component (n x m)
550    pub trend: FdMatrix,
551    /// Seasonal component (n x m)
552    pub seasonal: FdMatrix,
553    /// Remainder/residual component (n x m)
554    pub remainder: FdMatrix,
555    /// Robustness weights per point (n x m)
556    pub weights: FdMatrix,
557    /// Period used for decomposition
558    pub period: usize,
559    /// Seasonal smoothing window
560    pub s_window: usize,
561    /// Trend smoothing window
562    pub t_window: usize,
563    /// Number of inner loop iterations performed
564    pub inner_iterations: usize,
565    /// Number of outer loop iterations performed
566    pub outer_iterations: usize,
567}
568
569/// STL Decomposition: Seasonal and Trend decomposition using LOESS
570pub fn stl_decompose(
571    data: &FdMatrix,
572    period: usize,
573    s_window: Option<usize>,
574    t_window: Option<usize>,
575    l_window: Option<usize>,
576    robust: bool,
577    inner_iterations: Option<usize>,
578    outer_iterations: Option<usize>,
579) -> StlResult {
580    let (n, m) = data.shape();
581    if n == 0 || m < 2 * period || period < 2 {
582        return StlResult {
583            trend: FdMatrix::zeros(n, m),
584            seasonal: FdMatrix::zeros(n, m),
585            remainder: FdMatrix::from_slice(data.as_slice(), n, m)
586                .unwrap_or_else(|| FdMatrix::zeros(n, m)),
587            weights: FdMatrix::from_column_major(vec![1.0; n * m], n, m)
588                .unwrap_or_else(|| FdMatrix::zeros(n, m)),
589            period,
590            s_window: 0,
591            t_window: 0,
592            inner_iterations: 0,
593            outer_iterations: 0,
594        };
595    }
596    let s_win = s_window.unwrap_or(7).max(3) | 1;
597    let t_win = t_window.unwrap_or_else(|| {
598        let ratio = 1.5 * period as f64 / (1.0 - 1.5 / s_win as f64);
599        let val = ratio.ceil() as usize;
600        val.max(3) | 1
601    });
602    let l_win = l_window.unwrap_or(period) | 1;
603    let n_inner = inner_iterations.unwrap_or(2);
604    let n_outer = outer_iterations.unwrap_or(if robust { 15 } else { 1 });
605    let results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)> = iter_maybe_parallel!(0..n)
606        .map(|i| {
607            let curve: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
608            stl_single_series(
609                &curve, period, s_win, t_win, l_win, robust, n_inner, n_outer,
610            )
611        })
612        .collect();
613    let mut trend = FdMatrix::zeros(n, m);
614    let mut seasonal = FdMatrix::zeros(n, m);
615    let mut remainder = FdMatrix::zeros(n, m);
616    let mut weights = FdMatrix::from_column_major(vec![1.0; n * m], n, m).unwrap();
617    for (i, (t, s, r, w)) in results.into_iter().enumerate() {
618        for j in 0..m {
619            trend[(i, j)] = t[j];
620            seasonal[(i, j)] = s[j];
621            remainder[(i, j)] = r[j];
622            weights[(i, j)] = w[j];
623        }
624    }
625    StlResult {
626        trend,
627        seasonal,
628        remainder,
629        weights,
630        period,
631        s_window: s_win,
632        t_window: t_win,
633        inner_iterations: n_inner,
634        outer_iterations: n_outer,
635    }
636}
637
638fn stl_single_series(
639    data: &[f64],
640    period: usize,
641    s_window: usize,
642    t_window: usize,
643    l_window: usize,
644    robust: bool,
645    n_inner: usize,
646    n_outer: usize,
647) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
648    let m = data.len();
649    let mut trend = vec![0.0; m];
650    let mut seasonal = vec![0.0; m];
651    let mut weights = vec![1.0; m];
652    for _outer in 0..n_outer {
653        for _inner in 0..n_inner {
654            let detrended: Vec<f64> = data
655                .iter()
656                .zip(trend.iter())
657                .map(|(&y, &t)| y - t)
658                .collect();
659            let cycle_smoothed = smooth_cycle_subseries(&detrended, period, s_window, &weights);
660            let low_pass = stl_lowpass_filter(&cycle_smoothed, period, l_window);
661            seasonal = cycle_smoothed
662                .iter()
663                .zip(low_pass.iter())
664                .map(|(&c, &l)| c - l)
665                .collect();
666            let deseasonalized: Vec<f64> = data
667                .iter()
668                .zip(seasonal.iter())
669                .map(|(&y, &s)| y - s)
670                .collect();
671            trend = weighted_loess(&deseasonalized, t_window, &weights);
672        }
673        if robust && _outer < n_outer - 1 {
674            let remainder: Vec<f64> = data
675                .iter()
676                .zip(trend.iter())
677                .zip(seasonal.iter())
678                .map(|((&y, &t), &s)| y - t - s)
679                .collect();
680            weights = compute_robustness_weights(&remainder);
681        }
682    }
683    let remainder: Vec<f64> = data
684        .iter()
685        .zip(trend.iter())
686        .zip(seasonal.iter())
687        .map(|((&y, &t), &s)| y - t - s)
688        .collect();
689    (trend, seasonal, remainder, weights)
690}
691
692fn smooth_cycle_subseries(
693    data: &[f64],
694    period: usize,
695    s_window: usize,
696    weights: &[f64],
697) -> Vec<f64> {
698    let m = data.len();
699    let n_cycles = m.div_ceil(period);
700    let mut result = vec![0.0; m];
701    for pos in 0..period {
702        let mut subseries_idx: Vec<usize> = Vec::new();
703        let mut subseries_vals: Vec<f64> = Vec::new();
704        let mut subseries_weights: Vec<f64> = Vec::new();
705        for cycle in 0..n_cycles {
706            let idx = cycle * period + pos;
707            if idx < m {
708                subseries_idx.push(idx);
709                subseries_vals.push(data[idx]);
710                subseries_weights.push(weights[idx]);
711            }
712        }
713        if subseries_vals.is_empty() {
714            continue;
715        }
716        let smoothed = weighted_loess(&subseries_vals, s_window, &subseries_weights);
717        for (i, &idx) in subseries_idx.iter().enumerate() {
718            result[idx] = smoothed[i];
719        }
720    }
721    result
722}
723
724fn stl_lowpass_filter(data: &[f64], period: usize, _l_window: usize) -> Vec<f64> {
725    let ma1 = moving_average(data, period);
726    let ma2 = moving_average(&ma1, period);
727    moving_average(&ma2, 3)
728}
729
730fn moving_average(data: &[f64], window: usize) -> Vec<f64> {
731    let m = data.len();
732    if m == 0 || window == 0 {
733        return data.to_vec();
734    }
735    let half = window / 2;
736    let mut result = vec![0.0; m];
737    for i in 0..m {
738        let start = i.saturating_sub(half);
739        let end = (i + half + 1).min(m);
740        let sum: f64 = data[start..end].iter().sum();
741        let count = (end - start) as f64;
742        result[i] = sum / count;
743    }
744    result
745}
746
747fn weighted_loess(data: &[f64], window: usize, weights: &[f64]) -> Vec<f64> {
748    let m = data.len();
749    if m == 0 {
750        return vec![];
751    }
752    let half = window / 2;
753    let mut result = vec![0.0; m];
754    for i in 0..m {
755        let start = i.saturating_sub(half);
756        let end = (i + half + 1).min(m);
757        let mut sum_w = 0.0;
758        let mut sum_wx = 0.0;
759        let mut sum_wy = 0.0;
760        let mut sum_wxx = 0.0;
761        let mut sum_wxy = 0.0;
762        for j in start..end {
763            let dist = (j as f64 - i as f64).abs() / (half.max(1) as f64);
764            let tricube = if dist < 1.0 {
765                (1.0 - dist.powi(3)).powi(3)
766            } else {
767                0.0
768            };
769            let w = tricube * weights[j];
770            let x = j as f64;
771            let y = data[j];
772            sum_w += w;
773            sum_wx += w * x;
774            sum_wy += w * y;
775            sum_wxx += w * x * x;
776            sum_wxy += w * x * y;
777        }
778        if sum_w > 1e-10 {
779            let denom = sum_w * sum_wxx - sum_wx * sum_wx;
780            if denom.abs() > 1e-10 {
781                let intercept = (sum_wxx * sum_wy - sum_wx * sum_wxy) / denom;
782                let slope = (sum_w * sum_wxy - sum_wx * sum_wy) / denom;
783                result[i] = intercept + slope * i as f64;
784            } else {
785                result[i] = sum_wy / sum_w;
786            }
787        } else {
788            result[i] = data[i];
789        }
790    }
791    result
792}
793
794fn compute_robustness_weights(residuals: &[f64]) -> Vec<f64> {
795    let m = residuals.len();
796    if m == 0 {
797        return vec![];
798    }
799    let mut abs_residuals: Vec<f64> = residuals.iter().map(|&r| r.abs()).collect();
800    abs_residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
801    let median_idx = m / 2;
802    let mad = if m % 2 == 0 {
803        (abs_residuals[median_idx - 1] + abs_residuals[median_idx]) / 2.0
804    } else {
805        abs_residuals[median_idx]
806    };
807    let h = 6.0 * mad.max(1e-10);
808    residuals
809        .iter()
810        .map(|&r| {
811            let u = r.abs() / h;
812            if u < 1.0 {
813                (1.0 - u * u).powi(2)
814            } else {
815                0.0
816            }
817        })
818        .collect()
819}
820
821/// Wrapper function for functional data STL decomposition.
822pub fn stl_fdata(
823    data: &FdMatrix,
824    _argvals: &[f64],
825    period: usize,
826    s_window: Option<usize>,
827    t_window: Option<usize>,
828    robust: bool,
829) -> StlResult {
830    stl_decompose(data, period, s_window, t_window, None, robust, None, None)
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use std::f64::consts::PI;
837
838    #[test]
839    fn test_detrend_linear_removes_linear_trend() {
840        let m = 100;
841        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
842        let data_vec: Vec<f64> = argvals
843            .iter()
844            .map(|&t| 2.0 + 0.5 * t + (2.0 * PI * t / 2.0).sin())
845            .collect();
846        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
847        let result = detrend_linear(&data, &argvals);
848        let expected: Vec<f64> = argvals
849            .iter()
850            .map(|&t| (2.0 * PI * t / 2.0).sin())
851            .collect();
852        let mut max_diff = 0.0f64;
853        for j in 0..m {
854            let diff = (result.detrended[(0, j)] - expected[j]).abs();
855            max_diff = max_diff.max(diff);
856        }
857        assert!(max_diff < 0.2, "Max difference: {}", max_diff);
858    }
859
860    #[test]
861    fn test_detrend_polynomial_removes_quadratic_trend() {
862        let m = 100;
863        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
864        let data_vec: Vec<f64> = argvals
865            .iter()
866            .map(|&t| 1.0 + 0.5 * t - 0.1 * t * t + (2.0 * PI * t / 2.0).sin())
867            .collect();
868        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
869        let result = detrend_polynomial(&data, &argvals, 2);
870        let expected: Vec<f64> = argvals
871            .iter()
872            .map(|&t| (2.0 * PI * t / 2.0).sin())
873            .collect();
874        let detrended_vec: Vec<f64> = (0..m).map(|j| result.detrended[(0, j)]).collect();
875        let mean_det: f64 = detrended_vec.iter().sum::<f64>() / m as f64;
876        let mean_exp: f64 = expected.iter().sum::<f64>() / m as f64;
877        let mut num = 0.0;
878        let mut den_det = 0.0;
879        let mut den_exp = 0.0;
880        for j in 0..m {
881            num += (detrended_vec[j] - mean_det) * (expected[j] - mean_exp);
882            den_det += (detrended_vec[j] - mean_det).powi(2);
883            den_exp += (expected[j] - mean_exp).powi(2);
884        }
885        let corr = num / (den_det.sqrt() * den_exp.sqrt());
886        assert!(corr > 0.95, "Correlation: {}", corr);
887    }
888
889    #[test]
890    fn test_detrend_diff1() {
891        let m = 100;
892        let data_vec: Vec<f64> = {
893            let mut v = vec![0.0; m];
894            v[0] = 1.0;
895            for i in 1..m {
896                v[i] = v[i - 1] + 0.1 * (i as f64).sin();
897            }
898            v
899        };
900        let data = FdMatrix::from_column_major(data_vec.clone(), 1, m).unwrap();
901        let result = detrend_diff(&data, 1);
902        for j in 0..m - 1 {
903            let expected = data_vec[j + 1] - data_vec[j];
904            assert!(
905                (result.detrended[(0, j)] - expected).abs() < 1e-10,
906                "Mismatch at {}: {} vs {}",
907                j,
908                result.detrended[(0, j)],
909                expected
910            );
911        }
912    }
913
914    #[test]
915    fn test_auto_detrend_selects_linear_for_linear_data() {
916        let m = 100;
917        let argvals: Vec<f64> = (0..m).map(|i| i as f64).collect();
918        let data_vec: Vec<f64> = argvals.iter().map(|&t| 2.0 + 0.5 * t).collect();
919        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
920        let result = auto_detrend(&data, &argvals);
921        assert!(
922            result.method.contains("linear") || result.method.contains("polynomial"),
923            "Method: {}",
924            result.method
925        );
926    }
927
928    #[test]
929    fn test_detrend_loess_removes_linear_trend() {
930        let m = 100;
931        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
932        let data_vec: Vec<f64> = argvals
933            .iter()
934            .map(|&t| 2.0 + 0.5 * t + (2.0 * PI * t / 2.0).sin())
935            .collect();
936        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
937        let result = detrend_loess(&data, &argvals, 0.3, 1);
938        let expected: Vec<f64> = argvals
939            .iter()
940            .map(|&t| (2.0 * PI * t / 2.0).sin())
941            .collect();
942        let detrended_vec: Vec<f64> = (0..m).map(|j| result.detrended[(0, j)]).collect();
943        let mean_det: f64 = detrended_vec.iter().sum::<f64>() / m as f64;
944        let mean_exp: f64 = expected.iter().sum::<f64>() / m as f64;
945        let mut num = 0.0;
946        let mut den_det = 0.0;
947        let mut den_exp = 0.0;
948        for j in 0..m {
949            num += (detrended_vec[j] - mean_det) * (expected[j] - mean_exp);
950            den_det += (detrended_vec[j] - mean_det).powi(2);
951            den_exp += (expected[j] - mean_exp).powi(2);
952        }
953        let corr = num / (den_det.sqrt() * den_exp.sqrt());
954        assert!(corr > 0.9, "Correlation: {}", corr);
955        assert_eq!(result.method, "loess");
956    }
957
958    #[test]
959    fn test_detrend_loess_removes_quadratic_trend() {
960        let m = 100;
961        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
962        let data_vec: Vec<f64> = argvals
963            .iter()
964            .map(|&t| 1.0 + 0.3 * t - 0.05 * t * t + (2.0 * PI * t / 2.0).sin())
965            .collect();
966        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
967        let result = detrend_loess(&data, &argvals, 0.3, 2);
968        assert_eq!(result.trend.ncols(), m);
969        assert_eq!(result.detrended.ncols(), m);
970        assert!(result.rss[0] > 0.0);
971    }
972
973    #[test]
974    fn test_detrend_loess_different_bandwidths() {
975        let m = 100;
976        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
977        let data_vec: Vec<f64> = argvals
978            .iter()
979            .enumerate()
980            .map(|(i, &t)| (2.0 * PI * t / 2.0).sin() + 0.1 * ((i * 17) % 100) as f64 / 100.0)
981            .collect();
982        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
983        let result_small = detrend_loess(&data, &argvals, 0.1, 1);
984        let result_large = detrend_loess(&data, &argvals, 0.5, 1);
985        assert_eq!(result_small.trend.ncols(), m);
986        assert_eq!(result_large.trend.ncols(), m);
987        assert!(result_large.n_params >= result_small.n_params);
988    }
989
990    #[test]
991    fn test_detrend_loess_short_series() {
992        let m = 10;
993        let argvals: Vec<f64> = (0..m).map(|i| i as f64).collect();
994        let data_vec: Vec<f64> = argvals.iter().map(|&t| t * 2.0).collect();
995        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
996        let result = detrend_loess(&data, &argvals, 0.3, 1);
997        assert_eq!(result.trend.ncols(), m);
998        assert_eq!(result.detrended.ncols(), m);
999    }
1000
1001    #[test]
1002    fn test_decompose_additive_separates_components() {
1003        let m = 200;
1004        let period = 2.0;
1005        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1006        let data_vec: Vec<f64> = argvals
1007            .iter()
1008            .map(|&t| 2.0 + 0.5 * t + (2.0 * PI * t / period).sin())
1009            .collect();
1010        let data = FdMatrix::from_column_major(data_vec.clone(), 1, m).unwrap();
1011        let result = decompose_additive(&data, &argvals, period, "loess", 0.3, 3);
1012        assert_eq!(result.trend.ncols(), m);
1013        assert_eq!(result.seasonal.ncols(), m);
1014        assert_eq!(result.remainder.ncols(), m);
1015        assert_eq!(result.method, "additive");
1016        assert_eq!(result.period, period);
1017        for j in 0..m {
1018            let reconstructed =
1019                result.trend[(0, j)] + result.seasonal[(0, j)] + result.remainder[(0, j)];
1020            assert!(
1021                (reconstructed - data_vec[j]).abs() < 0.5,
1022                "Reconstruction error at {}: {} vs {}",
1023                j,
1024                reconstructed,
1025                data_vec[j]
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn test_decompose_additive_different_harmonics() {
1032        let m = 200;
1033        let period = 2.0;
1034        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1035        let data_vec: Vec<f64> = argvals
1036            .iter()
1037            .map(|&t| 1.0 + (2.0 * PI * t / period).sin())
1038            .collect();
1039        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1040        let result1 = decompose_additive(&data, &argvals, period, "loess", 0.3, 1);
1041        let result5 = decompose_additive(&data, &argvals, period, "loess", 0.3, 5);
1042        assert_eq!(result1.seasonal.ncols(), m);
1043        assert_eq!(result5.seasonal.ncols(), m);
1044    }
1045
1046    #[test]
1047    fn test_decompose_additive_residual_properties() {
1048        let m = 200;
1049        let period = 2.0;
1050        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1051        let data_vec: Vec<f64> = argvals
1052            .iter()
1053            .map(|&t| 2.0 + 0.3 * t + (2.0 * PI * t / period).sin())
1054            .collect();
1055        let data = FdMatrix::from_column_major(data_vec.clone(), 1, m).unwrap();
1056        let result = decompose_additive(&data, &argvals, period, "loess", 0.3, 3);
1057        let remainder_vec: Vec<f64> = (0..m).map(|j| result.remainder[(0, j)]).collect();
1058        let mean_rem: f64 = remainder_vec.iter().sum::<f64>() / m as f64;
1059        assert!(mean_rem.abs() < 0.5, "Remainder mean: {}", mean_rem);
1060        let data_mean: f64 = data_vec.iter().sum::<f64>() / m as f64;
1061        let var_data: f64 = data_vec
1062            .iter()
1063            .map(|&x| (x - data_mean).powi(2))
1064            .sum::<f64>()
1065            / m as f64;
1066        let var_rem: f64 = remainder_vec
1067            .iter()
1068            .map(|&x| (x - mean_rem).powi(2))
1069            .sum::<f64>()
1070            / m as f64;
1071        assert!(
1072            var_rem < var_data,
1073            "Remainder variance {} should be < data variance {}",
1074            var_rem,
1075            var_data
1076        );
1077    }
1078
1079    #[test]
1080    fn test_decompose_additive_multi_sample() {
1081        let n = 3;
1082        let m = 100;
1083        let period = 2.0;
1084        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1085        let mut data = FdMatrix::zeros(n, m);
1086        for i in 0..n {
1087            let amp = (i + 1) as f64;
1088            for j in 0..m {
1089                data[(i, j)] =
1090                    1.0 + 0.1 * argvals[j] + amp * (2.0 * PI * argvals[j] / period).sin();
1091            }
1092        }
1093        let result = decompose_additive(&data, &argvals, period, "loess", 0.3, 2);
1094        assert_eq!(result.trend.shape(), (n, m));
1095        assert_eq!(result.seasonal.shape(), (n, m));
1096        assert_eq!(result.remainder.shape(), (n, m));
1097    }
1098
1099    #[test]
1100    fn test_decompose_multiplicative_basic() {
1101        let m = 200;
1102        let period = 2.0;
1103        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1104        let data_vec: Vec<f64> = argvals
1105            .iter()
1106            .map(|&t| (2.0 + 0.1 * t) * (1.0 + 0.3 * (2.0 * PI * t / period).sin()))
1107            .collect();
1108        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1109        let result = decompose_multiplicative(&data, &argvals, period, "loess", 0.3, 3);
1110        assert_eq!(result.trend.ncols(), m);
1111        assert_eq!(result.seasonal.ncols(), m);
1112        assert_eq!(result.remainder.ncols(), m);
1113        assert_eq!(result.method, "multiplicative");
1114        let seasonal_vec: Vec<f64> = (0..m).map(|j| result.seasonal[(0, j)]).collect();
1115        let mean_seasonal: f64 = seasonal_vec.iter().sum::<f64>() / m as f64;
1116        assert!(
1117            (mean_seasonal - 1.0).abs() < 0.5,
1118            "Mean seasonal factor: {}",
1119            mean_seasonal
1120        );
1121    }
1122
1123    #[test]
1124    fn test_decompose_multiplicative_non_positive_data() {
1125        let m = 100;
1126        let period = 2.0;
1127        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1128        let data_vec: Vec<f64> = argvals
1129            .iter()
1130            .map(|&t| -1.0 + (2.0 * PI * t / period).sin())
1131            .collect();
1132        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1133        let result = decompose_multiplicative(&data, &argvals, period, "loess", 0.3, 2);
1134        assert_eq!(result.trend.ncols(), m);
1135        assert_eq!(result.seasonal.ncols(), m);
1136        for j in 0..m {
1137            let s = result.seasonal[(0, j)];
1138            assert!(s.is_finite(), "Seasonal should be finite");
1139        }
1140    }
1141
1142    #[test]
1143    fn test_decompose_multiplicative_vs_additive() {
1144        let m = 200;
1145        let period = 2.0;
1146        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 10.0).collect();
1147        let data_vec: Vec<f64> = argvals
1148            .iter()
1149            .map(|&t| 5.0 + (2.0 * PI * t / period).sin())
1150            .collect();
1151        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1152        let add_result = decompose_additive(&data, &argvals, period, "loess", 0.3, 3);
1153        let mult_result = decompose_multiplicative(&data, &argvals, period, "loess", 0.3, 3);
1154        assert_eq!(add_result.seasonal.ncols(), m);
1155        assert_eq!(mult_result.seasonal.ncols(), m);
1156        let add_seasonal_vec: Vec<f64> = (0..m).map(|j| add_result.seasonal[(0, j)]).collect();
1157        let add_mean: f64 = add_seasonal_vec.iter().sum::<f64>() / m as f64;
1158        let mult_seasonal_vec: Vec<f64> = (0..m).map(|j| mult_result.seasonal[(0, j)]).collect();
1159        let mult_mean: f64 = mult_seasonal_vec.iter().sum::<f64>() / m as f64;
1160        assert!(
1161            add_mean.abs() < mult_mean,
1162            "Additive mean {} vs mult mean {}",
1163            add_mean,
1164            mult_mean
1165        );
1166    }
1167
1168    #[test]
1169    fn test_decompose_multiplicative_edge_cases() {
1170        let empty = FdMatrix::zeros(0, 0);
1171        let result = decompose_multiplicative(&empty, &[], 2.0, "loess", 0.3, 2);
1172        assert_eq!(result.trend.len(), 0);
1173        let m = 5;
1174        let argvals: Vec<f64> = (0..m).map(|i| i as f64).collect();
1175        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 2.0, 1.0], 1, m).unwrap();
1176        let result = decompose_multiplicative(&data, &argvals, 2.0, "loess", 0.3, 1);
1177        assert_eq!(result.remainder.ncols(), m);
1178    }
1179
1180    #[test]
1181    fn test_stl_decompose_basic() {
1182        let period = 12;
1183        let n_cycles = 10;
1184        let m = period * n_cycles;
1185        let data_vec: Vec<f64> = (0..m)
1186            .map(|i| {
1187                let t = i as f64;
1188                0.01 * t + (2.0 * PI * t / period as f64).sin()
1189            })
1190            .collect();
1191        let data = FdMatrix::from_column_major(data_vec.clone(), 1, m).unwrap();
1192        let result = stl_decompose(&data, period, None, None, None, false, None, None);
1193        assert_eq!(result.trend.ncols(), m);
1194        assert_eq!(result.seasonal.ncols(), m);
1195        assert_eq!(result.remainder.ncols(), m);
1196        assert_eq!(result.period, period);
1197        for j in 0..m {
1198            let reconstructed =
1199                result.trend[(0, j)] + result.seasonal[(0, j)] + result.remainder[(0, j)];
1200            assert!(
1201                (reconstructed - data_vec[j]).abs() < 1e-8,
1202                "Reconstruction error at {}: {} vs {}",
1203                j,
1204                reconstructed,
1205                data_vec[j]
1206            );
1207        }
1208    }
1209
1210    #[test]
1211    fn test_stl_decompose_robust() {
1212        let period = 12;
1213        let n_cycles = 10;
1214        let m = period * n_cycles;
1215        let mut data_vec: Vec<f64> = (0..m)
1216            .map(|i| {
1217                let t = i as f64;
1218                0.01 * t + (2.0 * PI * t / period as f64).sin()
1219            })
1220            .collect();
1221        data_vec[30] += 10.0;
1222        data_vec[60] += 10.0;
1223        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1224        let result = stl_decompose(&data, period, None, None, None, true, None, Some(5));
1225        assert!(
1226            result.weights[(0, 30)] < 1.0,
1227            "Weight at outlier should be < 1.0: {}",
1228            result.weights[(0, 30)]
1229        );
1230        assert!(
1231            result.weights[(0, 60)] < 1.0,
1232            "Weight at outlier should be < 1.0: {}",
1233            result.weights[(0, 60)]
1234        );
1235        let non_outlier_weight = result.weights[(0, 15)];
1236        assert!(
1237            non_outlier_weight > result.weights[(0, 30)],
1238            "Non-outlier weight {} should be > outlier weight {}",
1239            non_outlier_weight,
1240            result.weights[(0, 30)]
1241        );
1242    }
1243
1244    #[test]
1245    fn test_stl_decompose_default_params() {
1246        let period = 10;
1247        let m = period * 8;
1248        let data_vec: Vec<f64> = (0..m)
1249            .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
1250            .collect();
1251        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1252        let result = stl_decompose(&data, period, None, None, None, false, None, None);
1253        assert_eq!(result.trend.ncols(), m);
1254        assert_eq!(result.seasonal.ncols(), m);
1255        assert!(result.s_window >= 3);
1256        assert!(result.t_window >= 3);
1257        assert_eq!(result.inner_iterations, 2);
1258        assert_eq!(result.outer_iterations, 1);
1259    }
1260
1261    #[test]
1262    fn test_stl_decompose_invalid() {
1263        let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
1264        let result = stl_decompose(&data, 1, None, None, None, false, None, None);
1265        assert_eq!(result.s_window, 0);
1266        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1267        let result = stl_decompose(&data, 5, None, None, None, false, None, None);
1268        assert_eq!(result.s_window, 0);
1269        let data = FdMatrix::zeros(0, 0);
1270        let result = stl_decompose(&data, 10, None, None, None, false, None, None);
1271        assert_eq!(result.trend.len(), 0);
1272    }
1273
1274    #[test]
1275    fn test_stl_fdata() {
1276        let n = 3;
1277        let period = 10;
1278        let m = period * 5;
1279        let argvals: Vec<f64> = (0..m).map(|i| i as f64).collect();
1280        let mut data = FdMatrix::zeros(n, m);
1281        for i in 0..n {
1282            let amp = (i + 1) as f64;
1283            for j in 0..m {
1284                data[(i, j)] = amp * (2.0 * PI * argvals[j] / period as f64).sin();
1285            }
1286        }
1287        let result = stl_fdata(&data, &argvals, period, None, None, false);
1288        assert_eq!(result.trend.shape(), (n, m));
1289        assert_eq!(result.seasonal.shape(), (n, m));
1290        assert_eq!(result.remainder.shape(), (n, m));
1291        for i in 0..n {
1292            for j in 0..m {
1293                let reconstructed =
1294                    result.trend[(i, j)] + result.seasonal[(i, j)] + result.remainder[(i, j)];
1295                assert!(
1296                    (reconstructed - data[(i, j)]).abs() < 1e-8,
1297                    "Reconstruction error for sample {} at {}: {} vs {}",
1298                    i,
1299                    j,
1300                    reconstructed,
1301                    data[(i, j)]
1302                );
1303            }
1304        }
1305    }
1306
1307    #[test]
1308    fn test_stl_decompose_multi_sample() {
1309        let n = 5;
1310        let period = 10;
1311        let m = period * 6;
1312        let mut data = FdMatrix::zeros(n, m);
1313        for i in 0..n {
1314            let offset = i as f64 * 0.5;
1315            for j in 0..m {
1316                data[(i, j)] =
1317                    offset + 0.01 * j as f64 + (2.0 * PI * j as f64 / period as f64).sin();
1318            }
1319        }
1320        let result = stl_decompose(&data, period, None, None, None, false, None, None);
1321        assert_eq!(result.trend.shape(), (n, m));
1322        assert_eq!(result.seasonal.shape(), (n, m));
1323        assert_eq!(result.remainder.shape(), (n, m));
1324        assert_eq!(result.weights.shape(), (n, m));
1325    }
1326
1327    #[test]
1328    fn test_detrend_diff_order2() {
1329        let m = 50;
1330        let data_vec: Vec<f64> = (0..m).map(|i| (i as f64).powi(2)).collect();
1331        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1332        let result = detrend_diff(&data, 2);
1333        for j in 0..m - 2 {
1334            assert!(
1335                (result.detrended[(0, j)] - 2.0).abs() < 1e-10,
1336                "Second diff at {}: expected 2.0, got {}",
1337                j,
1338                result.detrended[(0, j)]
1339            );
1340        }
1341    }
1342
1343    #[test]
1344    fn test_detrend_polynomial_degree3() {
1345        let m = 100;
1346        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64 * 5.0).collect();
1347        let data_vec: Vec<f64> = argvals
1348            .iter()
1349            .map(|&t| 1.0 + 2.0 * t - 0.5 * t * t + 0.1 * t * t * t)
1350            .collect();
1351        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1352        let result = detrend_polynomial(&data, &argvals, 3);
1353        assert_eq!(result.method, "polynomial(3)");
1354        assert!(result.coefficients.is_some());
1355        let max_detrend: f64 = (0..m)
1356            .map(|j| result.detrended[(0, j)].abs())
1357            .fold(0.0, f64::max);
1358        assert!(
1359            max_detrend < 0.1,
1360            "Pure cubic should be nearly zero after degree-3 detrend: {}",
1361            max_detrend
1362        );
1363    }
1364
1365    #[test]
1366    fn test_detrend_loess_invalid() {
1367        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0], 1, 5).unwrap();
1368        let argvals = vec![0.0, 1.0, 2.0, 3.0, 4.0];
1369        let result = detrend_loess(&data, &argvals, -0.1, 1);
1370        assert_eq!(result.detrended.as_slice(), data.as_slice());
1371        let data2 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
1372        let result = detrend_loess(&data2, &[0.0, 1.0], 0.3, 1);
1373        assert_eq!(result.detrended.as_slice(), &[1.0, 2.0]);
1374    }
1375
1376    #[test]
1377    fn test_nan_linear_detrend() {
1378        let m = 20;
1379        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1380        let mut data_vec = vec![1.0; m];
1381        data_vec[5] = f64::NAN;
1382        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1383        let result = detrend_linear(&data, &argvals);
1384        // Should not panic
1385        assert_eq!(result.detrended.nrows(), 1);
1386    }
1387
1388    #[test]
1389    fn test_n1_detrend() {
1390        let m = 50;
1391        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1392        let data_vec: Vec<f64> = argvals.iter().map(|&t| 2.0 * t + 1.0).collect();
1393        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
1394        let result = detrend_linear(&data, &argvals);
1395        assert_eq!(result.detrended.ncols(), m);
1396        // Detrended linear should be near zero
1397        for j in 0..m {
1398            assert!(result.detrended[(0, j)].abs() < 0.1);
1399        }
1400    }
1401
1402    #[test]
1403    fn test_constant_signal_detrend() {
1404        let m = 30;
1405        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1406        let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
1407        let result = detrend_linear(&data, &argvals);
1408        // A constant has zero slope, so detrended = data - mean
1409        assert_eq!(result.detrended.ncols(), m);
1410    }
1411
1412    #[test]
1413    fn test_m2_minimal_detrend() {
1414        // Minimal grid: 2 points
1415        let argvals = vec![0.0, 1.0];
1416        let data = FdMatrix::from_column_major(vec![0.0, 1.0], 1, 2).unwrap();
1417        let result = detrend_linear(&data, &argvals);
1418        assert_eq!(result.detrended.ncols(), 2);
1419    }
1420}