Skip to main content

fdars_core/fts/
forecast.rs

1//! Functional time-series forecasting (FTS-01).
2//!
3//! Forecasts future functional curves from a time-ordered curve series using an
4//! FPCA-based functional time-series model. A curve series is decomposed via the
5//! existing dense FPCA ([`crate::regression::fdata_to_pc`]) into a mean curve,
6//! FPC loadings, and a score-time-series; each retained FPC-score sequence is then
7//! modelled as an independent univariate AR(p) process (Yule-Walker estimation,
8//! AIC order selection) and forecast horizon by horizon, and the forecast scores
9//! are recombined with the loadings and mean into forecast curves.
10//!
11//! # Entry points
12//!
13//! * [`ftsm`] — fit the FPCA-based functional time-series model.
14//! * [`ftsm_forecast`] — h-step-ahead FPC-score-AR forecast reconstructed into
15//!   forecast curves.
16//! * `ftsm_forecast_multistep` — iterative multi-step forecast (added later).
17//! * `ftsm_update` — dynamic forecast update on new observations (added later).
18//! * `fplsr` — functional PLS forecasting variant (added later).
19//!
20//! # R baseline
21//!
22//! `ftsa` (Hyndman & Shang). Matched by capability, not by R's exact signatures.
23//!
24//! # Divergences from `ftsa`
25//!
26//! * **No pre-smoothing.** `ftsa::ftsm` optionally smooths (kernel / P-spline)
27//!   before FPCA; this implementation operates on the raw input grid via dense
28//!   `fdata_to_pc`. Users requiring pre-smoothed curves should smooth before
29//!   calling [`ftsm`].
30//! * **User-provided `ncomp`.** `ftsa::ftsm` defaults to `order = 6`; here the
31//!   number of retained components is a required, validated parameter.
32//! * **AR-only score model.** Each FPC-score sequence is modelled as an
33//!   independent univariate AR(p) (Yule-Walker + AIC), not a general ARIMA.
34//! * **Point forecasts only.** Prediction intervals / forecast bands are out of
35//!   scope for this milestone.
36//!
37//! # Conventions
38//!
39//! All public functions return `Result<_, FdarError>` and validate inputs at
40//! entry (never panic). The AR estimation path is fully deterministic
41//! (Yule-Walker), so no RNG seeding is required. Result structs derive
42//! `Debug, Clone, PartialEq`, are serde-gated, and are declared in
43//! [`super`](crate::fts).
44
45use super::{ArModelResult, FplsrResult, FtsmForecastResult, FtsmResult};
46use crate::error::FdarError;
47use crate::helpers::NUMERICAL_EPS;
48use crate::matrix::FdMatrix;
49use crate::regression::fdata_to_pc;
50use crate::scalar_on_function::{fregre_pls, predict_fregre_pls};
51
52// ─── Input validation ────────────────────────────────────────────────────────
53
54/// Validate that `data` is non-empty and `argvals` length matches data columns.
55///
56/// Returns `(n, m)` on success. Re-implemented verbatim from `fts/acf.rs`
57/// (private there) to keep this module self-contained without modifying `acf.rs`.
58fn validate_fts_input(data: &FdMatrix, argvals: &[f64]) -> Result<(usize, usize), FdarError> {
59    let (n, m) = data.shape();
60    if n == 0 || m == 0 {
61        return Err(FdarError::InvalidDimension {
62            parameter: "data",
63            expected: "non-empty matrix".to_string(),
64            actual: format!("{n} rows, {m} columns"),
65        });
66    }
67    if argvals.len() != m {
68        return Err(FdarError::InvalidDimension {
69            parameter: "argvals",
70            expected: format!("{m} elements (matching data columns)"),
71            actual: format!("{} elements", argvals.len()),
72        });
73    }
74    Ok((n, m))
75}
76
77// ─── Scalar AR (Yule-Walker) machinery ───────────────────────────────────────
78
79/// Scalar autocovariance of a series at lags `0..=max_lag`.
80///
81/// Uses the `1/n` normalization convention (matching `fts/acf.rs`), i.e.
82/// `gamma(h) = (1/n) Σ_{t=0}^{n-h-1} (x[t] - mean) (x[t+h] - mean)`.
83fn scalar_acov(series: &[f64], mean: f64, max_lag: usize) -> Vec<f64> {
84    let n = series.len();
85    let inv_n = 1.0 / n as f64;
86    let mut gamma = vec![0.0f64; max_lag + 1];
87    for h in 0..=max_lag {
88        let mut s = 0.0;
89        for t in 0..(n - h) {
90            s += (series[t] - mean) * (series[t + h] - mean);
91        }
92        gamma[h] = s * inv_n;
93    }
94    gamma
95}
96
97/// Levinson-Durbin recursion solving the Yule-Walker equations.
98///
99/// Takes autocovariances `gamma[0..=p]` and returns `(phi, sigma2)` where `phi`
100/// holds the `p` AR coefficients (`phi[0]` is the lag-1 coefficient) and `sigma2`
101/// is the innovation (residual) variance. Order 0 returns `(vec![], gamma[0])`.
102///
103/// Mirrors the structural pattern of `acf.rs::durbin_levinson_pacf`, including the
104/// `nu[k-1].abs() < 1e-12` early-exit guard (near-unit-root collapse to the
105/// order-`k-1` model). Returns [`FdarError::ComputationFailed`] when
106/// `gamma[0]` is near zero (degenerate constant series).
107fn levinson_durbin_yw(gamma: &[f64]) -> Result<(Vec<f64>, f64), FdarError> {
108    let p = gamma.len() - 1;
109    if p == 0 {
110        return Ok((vec![], gamma[0]));
111    }
112    if gamma[0].abs() < NUMERICAL_EPS {
113        return Err(FdarError::ComputationFailed {
114            operation: "levinson_durbin_yw",
115            detail: "gamma(0) near zero — degenerate score series".to_string(),
116        });
117    }
118    let mut phi = vec![vec![0.0f64; p + 1]; p + 1];
119    let mut nu = vec![0.0f64; p + 1];
120
121    phi[1][1] = gamma[1] / gamma[0];
122    nu[1] = gamma[0] * (1.0 - phi[1][1] * phi[1][1]);
123
124    for k in 2..=p {
125        if nu[k - 1].abs() < 1e-12 {
126            let phi_hat: Vec<f64> = (1..k).map(|j| phi[k - 1][j]).collect();
127            let sigma2 = nu[k - 1].max(0.0);
128            return Ok((phi_hat, sigma2));
129        }
130        let num = gamma[k] - (1..k).map(|j| phi[k - 1][j] * gamma[k - j]).sum::<f64>();
131        phi[k][k] = num / nu[k - 1];
132        for j in 1..k {
133            phi[k][j] = phi[k - 1][j] - phi[k][k] * phi[k - 1][k - j];
134        }
135        nu[k] = nu[k - 1] * (1.0 - phi[k][k] * phi[k][k]);
136    }
137    let phi_hat: Vec<f64> = (1..=p).map(|j| phi[p][j]).collect();
138    let sigma2 = nu[p].max(0.0);
139    Ok((phi_hat, sigma2))
140}
141
142/// A fitted univariate AR(p) model for a single FPC-score sequence.
143///
144/// Private: exposed to callers only through the [`ArModelResult`] diagnostics
145/// carried on [`FtsmResult`].
146#[derive(Debug, Clone, PartialEq)]
147struct ArModel {
148    /// AR coefficients `phi_1..phi_p` (0-indexed: `phi[0]` is the lag-1 coeff).
149    phi: Vec<f64>,
150    /// Innovation (residual) variance.
151    sigma2: f64,
152    /// Series mean (the series is mean-centered before Yule-Walker fitting).
153    mean: f64,
154    /// Selected AR order `p` (0 = white noise).
155    order: usize,
156    /// Last `order` raw observations (oldest first) used to seed forecasting.
157    history: Vec<f64>,
158}
159
160impl ArModel {
161    /// Fit an AR(p) model by Yule-Walker with AIC order selection.
162    ///
163    /// `AIC(p) = n·ln(sigma_p²) + 2p` over `p = 0..=p_max`, with
164    /// `p_max = min(⌊10·log10(n)⌋, n-1, n/4)` (min 1) — R's `ar()` default with a
165    /// conservative short-series cap.
166    fn fit(series: &[f64], n: usize) -> Result<ArModel, FdarError> {
167        let mean = series.iter().sum::<f64>() / n as f64;
168        let p_max = ((10.0 * (n as f64).log10()).floor() as usize)
169            .min(n - 1)
170            .min(n / 4)
171            .max(1);
172        let gamma = scalar_acov(series, mean, p_max);
173
174        // Degenerate constant series → white-noise (order 0) model.
175        if gamma[0].abs() < NUMERICAL_EPS {
176            return Ok(ArModel {
177                phi: vec![],
178                sigma2: gamma[0].max(0.0),
179                mean,
180                order: 0,
181                history: vec![],
182            });
183        }
184
185        // Order 0 baseline.
186        let mut best_order = 0usize;
187        let mut best_phi: Vec<f64> = vec![];
188        let mut best_sigma2 = gamma[0];
189        let mut best_aic = n as f64 * gamma[0].max(NUMERICAL_EPS).ln();
190
191        for p in 1..=p_max {
192            let (phi_p, sigma2_p) = match levinson_durbin_yw(&gamma[0..=p]) {
193                Ok(v) => v,
194                Err(_) => break,
195            };
196            if sigma2_p <= 0.0 {
197                continue;
198            }
199            let aic = n as f64 * sigma2_p.ln() + 2.0 * p as f64;
200            if aic < best_aic {
201                best_aic = aic;
202                best_order = p;
203                best_phi = phi_p;
204                best_sigma2 = sigma2_p;
205            }
206        }
207
208        let history = if best_order == 0 {
209            vec![]
210        } else {
211            series[n - best_order..n].to_vec()
212        };
213
214        Ok(ArModel {
215            phi: best_phi,
216            sigma2: best_sigma2,
217            mean,
218            order: best_order,
219            history,
220        })
221    }
222
223    /// Iterative plug-in `h`-step forecast; returns `h` forecast values.
224    ///
225    /// For each horizon the AR prediction is computed from a sliding window of the
226    /// last `order` (mean-centered) values, mixing observed history with
227    /// already-computed forecasts for horizons beyond the model order.
228    fn forecast(&self, h: usize) -> Vec<f64> {
229        if self.order == 0 {
230            return vec![self.mean; h];
231        }
232        // Centered sliding window, oldest first; buf[order-1] is most recent.
233        let mut buf: Vec<f64> = self.history.iter().map(|x| x - self.mean).collect();
234        let mut out = Vec::with_capacity(h);
235        for _ in 0..h {
236            let mut pred = 0.0;
237            for j in 0..self.order {
238                pred += self.phi[j] * buf[self.order - 1 - j];
239            }
240            out.push(pred + self.mean);
241            buf.remove(0);
242            buf.push(pred);
243        }
244        out
245    }
246}
247
248// ─── ftsm fit ────────────────────────────────────────────────────────────────
249
250/// Fit an FPCA-based functional time-series model over a time-ordered curve series.
251///
252/// Decomposes `data` (rows = time-ordered curves, columns = evaluation points)
253/// via [`fdata_to_pc`], retains the mean curve, FPC loadings, and the
254/// score-time-series, reconstructs fitted curves, and fits an independent
255/// univariate AR(p) model (Yule-Walker + AIC) to each FPC-score sequence.
256///
257/// The number of retained components is `ncomp`, silently clamped by
258/// `fdata_to_pc` to `min(ncomp, n, m)`; the effective value is stored in
259/// [`FtsmResult::ncomp`].
260///
261/// # Errors
262///
263/// Returns [`FdarError`] (never panics) for empty data / mismatched `argvals`
264/// ([`FdarError::InvalidDimension`]), `ncomp == 0`, `n <= ncomp`, or `n < 2`
265/// ([`FdarError::InvalidParameter`]), and propagates FPCA / AR failures.
266#[must_use = "expensive computation whose result should not be discarded"]
267pub fn ftsm(data: &FdMatrix, ncomp: usize, argvals: &[f64]) -> Result<FtsmResult, FdarError> {
268    let (n, _m) = validate_fts_input(data, argvals)?;
269    if ncomp == 0 {
270        return Err(FdarError::InvalidParameter {
271            parameter: "ncomp",
272            message: "ncomp must be >= 1".to_string(),
273        });
274    }
275    if n <= ncomp {
276        return Err(FdarError::InvalidParameter {
277            parameter: "ncomp",
278            message: format!("ncomp ({ncomp}) must be < n ({n})"),
279        });
280    }
281    if n < 2 {
282        return Err(FdarError::InvalidParameter {
283            parameter: "data",
284            message: format!("need at least 2 observations to fit AR score models, got n = {n}"),
285        });
286    }
287
288    let fpca = fdata_to_pc(data, ncomp, argvals)?;
289    let effective_ncomp = fpca.rotation.ncols();
290    let fitted = fpca.reconstruct(&fpca.scores, effective_ncomp)?;
291
292    let mut ar_models = Vec::with_capacity(effective_ncomp);
293    for k in 0..effective_ncomp {
294        let col = fpca.scores.column(k);
295        let ar = ArModel::fit(col, n)?;
296        ar_models.push(ArModelResult {
297            order: ar.order,
298            phi: ar.phi,
299            sigma2: ar.sigma2,
300        });
301    }
302
303    Ok(FtsmResult {
304        mean: fpca.mean,
305        rotation: fpca.rotation,
306        scores: fpca.scores,
307        fitted,
308        weights: fpca.weights,
309        ncomp: effective_ncomp,
310        ar_models,
311    })
312}
313
314/// Reconstruct the private [`ArModel`] for component `k` from the stored
315/// diagnostics plus the score-column history (the score columns are mean ≈ 0
316/// because FPCA centers the data).
317fn ar_model_from_fit(fit: &FtsmResult, k: usize) -> ArModel {
318    let col = fit.scores.column(k);
319    let n = col.len();
320    let mean = col.iter().sum::<f64>() / n as f64;
321    let order = fit.ar_models[k].order;
322    let history = if order == 0 {
323        vec![]
324    } else {
325        col[n - order..n].to_vec()
326    };
327    ArModel {
328        phi: fit.ar_models[k].phi.clone(),
329        sigma2: fit.ar_models[k].sigma2,
330        mean,
331        order,
332        history,
333    }
334}
335
336// ─── ftsm forecast ───────────────────────────────────────────────────────────
337
338/// Forecast `h`-step-ahead curve(s) from a fitted [`FtsmResult`].
339///
340/// Each FPC-score AR model is forecast `h` horizons ahead via the iterative
341/// plug-in recursion, and the forecast scores at each horizon are recombined into
342/// a forecast curve as `mean[j] + Σ_k score_k · rotation[(j,k)]` (the same
343/// arithmetic as [`crate::regression::FpcaResult::reconstruct`]). The returned
344/// [`FtsmForecastResult::forecast`] is an `h × m` matrix (row `i` = horizon
345/// `i + 1`).
346///
347/// For `h = 1` this is the single-step convenience forecast;
348/// [`ftsm_forecast_multistep`] is the underlying multi-step path this delegates to
349/// (so the `h = 1` output is bit-identical to a `h = 1` multi-step call).
350///
351/// # Errors
352///
353/// Returns [`FdarError::InvalidParameter`] for `h < 1` and
354/// [`FdarError::InvalidDimension`] when `argvals` length differs from the fitted
355/// grid.
356#[must_use = "returns forecast result; result should be examined"]
357pub fn ftsm_forecast(
358    fit: &FtsmResult,
359    h: usize,
360    argvals: &[f64],
361) -> Result<FtsmForecastResult, FdarError> {
362    ftsm_forecast_multistep(fit, h, argvals)
363}
364
365/// Iterative multi-step forecast: per-horizon forecast curves for `h >= 1`.
366///
367/// Each FPC-score AR model is forecast `h` horizons ahead via the iterative
368/// plug-in recursion (forecast scores are fed back into the AR window for horizons
369/// beyond the model order), and the forecast scores at each horizon are recombined
370/// into a forecast curve as `mean[j] + Σ_k score_k · rotation[(j,k)]` (the same
371/// arithmetic as [`crate::regression::FpcaResult::reconstruct`]). The returned
372/// [`FtsmForecastResult::forecast`] is an `h × m` matrix (row `i` = horizon
373/// `i + 1`). The `h = 1` row is bit-identical to [`ftsm_forecast`], which delegates
374/// here.
375///
376/// # Errors
377///
378/// Returns [`FdarError::InvalidParameter`] for `h < 1` and
379/// [`FdarError::InvalidDimension`] when `argvals` length differs from the fitted
380/// grid.
381#[must_use = "returns forecast result; result should be examined"]
382pub fn ftsm_forecast_multistep(
383    fit: &FtsmResult,
384    h: usize,
385    argvals: &[f64],
386) -> Result<FtsmForecastResult, FdarError> {
387    if h == 0 {
388        return Err(FdarError::InvalidParameter {
389            parameter: "h",
390            message: "h must be >= 1".to_string(),
391        });
392    }
393    let m = fit.mean.len();
394    if argvals.len() != m {
395        return Err(FdarError::InvalidDimension {
396            parameter: "argvals",
397            expected: format!("{m} elements (matching fitted grid)"),
398            actual: format!("{} elements", argvals.len()),
399        });
400    }
401
402    // Forecast each component's score series h horizons ahead.
403    let mut score_paths: Vec<Vec<f64>> = Vec::with_capacity(fit.ncomp);
404    for k in 0..fit.ncomp {
405        let ar = ar_model_from_fit(fit, k);
406        score_paths.push(ar.forecast(h));
407    }
408
409    // Reconstruct forecast curves horizon by horizon.
410    let mut forecast = FdMatrix::zeros(h, m);
411    for step in 0..h {
412        for j in 0..m {
413            let mut val = fit.mean[j];
414            for k in 0..fit.ncomp {
415                val += score_paths[k][step] * fit.rotation[(j, k)];
416            }
417            forecast[(step, j)] = val;
418        }
419    }
420
421    Ok(FtsmForecastResult { forecast, h })
422}
423
424// ─── ftsm dynamic update ─────────────────────────────────────────────────────
425
426/// Dynamically update a fitted [`FtsmResult`] as new observation(s) arrive,
427/// WITHOUT refitting the FPCA.
428///
429/// New curve row(s) are projected onto the FROZEN FPC loadings (mean, rotation,
430/// weights from `fit`) using the same arithmetic as
431/// [`crate::regression::FpcaResult::project`], the resulting scores are appended
432/// to the score-time-series, and each per-component AR model is re-fit (same
433/// Yule-Walker + AIC selection). The mean curve and loadings are never
434/// recomputed.
435///
436/// # Divergence
437///
438/// The dynamic update freezes the mean curve from the initial [`ftsm`] fit; for
439/// long update sequences the frozen mean can drift from the true sample mean, so
440/// periodic full refit via [`ftsm`] is recommended. This is why the update agrees
441/// with a full refit only up to a documented tolerance, not to machine epsilon.
442///
443/// # Errors
444///
445/// Returns [`FdarError::InvalidDimension`] when `new_curve` has a column count
446/// other than the fitted grid width, or when `argvals` length differs from the
447/// fitted grid.
448#[must_use = "returns forecast result; result should be examined"]
449pub fn ftsm_update(
450    fit: &FtsmResult,
451    new_curve: &FdMatrix,
452    argvals: &[f64],
453) -> Result<FtsmResult, FdarError> {
454    let m = fit.mean.len();
455    let (k_new, m_new) = new_curve.shape();
456    if k_new == 0 || m_new != m {
457        return Err(FdarError::InvalidDimension {
458            parameter: "new_curve",
459            expected: format!("k x {m} (k >= 1 new rows matching the fitted grid)"),
460            actual: format!("{k_new} rows, {m_new} columns"),
461        });
462    }
463    if argvals.len() != m {
464        return Err(FdarError::InvalidDimension {
465            parameter: "argvals",
466            expected: format!("{m} elements (matching fitted grid)"),
467            actual: format!("{} elements", argvals.len()),
468        });
469    }
470
471    let ncomp = fit.ncomp;
472    let n_old = fit.scores.nrows();
473    let n_ext = n_old + k_new;
474
475    // Build the extended score matrix: old scores followed by projected new scores.
476    let mut ext_scores = FdMatrix::zeros(n_ext, ncomp);
477    for i in 0..n_old {
478        for k in 0..ncomp {
479            ext_scores[(i, k)] = fit.scores[(i, k)];
480        }
481    }
482    // Project each new row onto the FROZEN loadings (FpcaResult::project arithmetic).
483    for r in 0..k_new {
484        for k in 0..ncomp {
485            let mut sum = 0.0;
486            for j in 0..m {
487                sum += (new_curve[(r, j)] - fit.mean[j]) * fit.rotation[(j, k)] * fit.weights[j];
488            }
489            ext_scores[(n_old + r, k)] = sum;
490        }
491    }
492
493    // Re-fit AR models per component on the extended score columns (no FPCA refit).
494    let mut ar_models = Vec::with_capacity(ncomp);
495    for k in 0..ncomp {
496        let col = ext_scores.column(k);
497        let ar = ArModel::fit(col, n_ext)?;
498        ar_models.push(ArModelResult {
499            order: ar.order,
500            phi: ar.phi,
501            sigma2: ar.sigma2,
502        });
503    }
504
505    // Rebuild fitted curves for the extended series (mean + Σ_k score·rotation).
506    let mut fitted = FdMatrix::zeros(n_ext, m);
507    for i in 0..n_ext {
508        for j in 0..m {
509            let mut val = fit.mean[j];
510            for k in 0..ncomp {
511                val += ext_scores[(i, k)] * fit.rotation[(j, k)];
512            }
513            fitted[(i, j)] = val;
514        }
515    }
516
517    Ok(FtsmResult {
518        mean: fit.mean.clone(),
519        rotation: fit.rotation.clone(),
520        scores: ext_scores,
521        fitted,
522        weights: fit.weights.clone(),
523        ncomp,
524        ar_models,
525    })
526}
527
528// ─── fplsr: functional PLS forecasting ───────────────────────────────────────
529
530/// Functional PLS forecasting variant (a PLS-score alternative to FPC-score AR).
531///
532/// Fits a lag-1 PLS design — predictor = current curve (rows `0..n-2`), response
533/// = next curve (rows `1..n-1`) — and forecasts the next curve one step ahead.
534/// Because the shipped PLS machinery ([`crate::scalar_on_function::fregre_pls`])
535/// takes a scalar response, the functional (curve) response is handled by fitting
536/// one scalar PLS regression per evaluation point (Option A of the research
537/// note): for each grid point `j`, the next-curve column `j` is regressed on the
538/// current curve via PLS, the in-sample fits populate column `j` of `fitted`, and
539/// the last observed curve is projected + predicted to give the forecast at `j`.
540///
541/// # Divergence from `ftsa`
542///
543/// `ftsa::fplsr` uses a unified NIPALS/SIMPLS functional operator; this
544/// implementation uses per-evaluation-point scalar PLS regression (functionally
545/// equivalent for point prediction, less elegant). Deterministic — no RNG.
546///
547/// # Errors
548///
549/// Returns [`FdarError::InvalidDimension`] for empty data / mismatched `argvals`,
550/// and [`FdarError::InvalidParameter`] for `ncomp == 0` or `n < 3` (need at least
551/// two lag-1 rows plus a forecast origin). PLS/rank failures propagate as
552/// [`FdarError::ComputationFailed`].
553#[must_use = "expensive computation whose result should not be discarded"]
554pub fn fplsr(data: &FdMatrix, ncomp: usize, argvals: &[f64]) -> Result<FplsrResult, FdarError> {
555    let (n, m) = validate_fts_input(data, argvals)?;
556    if ncomp == 0 {
557        return Err(FdarError::InvalidParameter {
558            parameter: "ncomp",
559            message: "ncomp must be >= 1".to_string(),
560        });
561    }
562    if n < 3 {
563        return Err(FdarError::InvalidParameter {
564            parameter: "data",
565            message: format!("need at least 3 observations for a lag-1 PLS forecast, got n = {n}"),
566        });
567    }
568    // Lag-1 design has n-1 rows; clamp ncomp to avoid overfitting / rank deficiency.
569    let nrows = n - 1;
570    let ncomp = ncomp.min(nrows).min(m);
571
572    let mut x_cur = FdMatrix::zeros(nrows, m);
573    let mut x_next = FdMatrix::zeros(nrows, m);
574    for i in 0..nrows {
575        for j in 0..m {
576            x_cur[(i, j)] = data[(i, j)];
577            x_next[(i, j)] = data[(i + 1, j)];
578        }
579    }
580    // Forecast origin: the last observed curve (row n-1), as a 1 x m matrix.
581    let mut last = FdMatrix::zeros(1, m);
582    for j in 0..m {
583        last[(0, j)] = data[(n - 1, j)];
584    }
585
586    let mut forecast = FdMatrix::zeros(1, m);
587    let mut fitted = FdMatrix::zeros(nrows, m);
588    for j in 0..m {
589        let y_j: Vec<f64> = (0..nrows).map(|i| x_next[(i, j)]).collect();
590        let fit_j = fregre_pls(&x_cur, &y_j, argvals, ncomp, None)?;
591        for i in 0..nrows {
592            fitted[(i, j)] = fit_j.fitted_values[i];
593        }
594        let pred = predict_fregre_pls(&fit_j, &last, None)?;
595        forecast[(0, j)] = pred[0];
596    }
597
598    Ok(FplsrResult {
599        forecast,
600        fitted,
601        ncomp,
602    })
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use crate::scoring::functional_mse;
609
610    fn uniform_grid(m: usize) -> Vec<f64> {
611        (0..m).map(|j| j as f64 / (m - 1) as f64).collect()
612    }
613
614    /// Deterministic pseudo-white-noise in [-0.5, 0.5] via a linear congruential
615    /// generator (no RNG dependency; reproducible across runs).
616    fn lcg_white(n: usize, seed: u64) -> Vec<f64> {
617        let mut state = seed;
618        let mut out = Vec::with_capacity(n);
619        for _ in 0..n {
620            state = state
621                .wrapping_mul(6364136223846793005)
622                .wrapping_add(1442695040888963407);
623            let u = ((state >> 11) as f64) / ((1u64 << 53) as f64); // in [0,1)
624            out.push(u - 0.5);
625        }
626        out
627    }
628
629    /// Build a synthetic curve series whose dominant FPC score follows a genuine
630    /// AR(1) process with coefficient `phi` (near-white LCG innovations).
631    /// curve_t(u) = a_t·f1(u) + b_t·f2(u), with a_t the AR(1) sequence and b_t a
632    /// small deterministic secondary signal.
633    fn ar1_curve_series(n: usize, m: usize, phi: f64) -> (FdMatrix, Vec<f64>) {
634        let argvals = uniform_grid(m);
635        let f1: Vec<f64> = argvals
636            .iter()
637            .map(|u| (std::f64::consts::PI * u).sin())
638            .collect();
639        let f2: Vec<f64> = argvals
640            .iter()
641            .map(|u| (2.0 * std::f64::consts::PI * u).sin())
642            .collect();
643        let e = lcg_white(n, 0x5eed_1234_abcd_0001);
644        let mut a = vec![0.0f64; n];
645        a[0] = e[0];
646        for t in 1..n {
647            a[t] = phi * a[t - 1] + e[t];
648        }
649        let e2 = lcg_white(n, 0x5eed_1234_abcd_0002);
650        let mut data = FdMatrix::zeros(n, m);
651        for t in 0..n {
652            let b = 0.1 * e2[t];
653            for j in 0..m {
654                data[(t, j)] = a[t] * f1[j] + b * f2[j];
655            }
656        }
657        (data, argvals)
658    }
659
660    #[test]
661    fn scalar_acov_variance_and_decay() {
662        let series = [1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0];
663        let mean = series.iter().sum::<f64>() / series.len() as f64;
664        let g = scalar_acov(&series, mean, 3);
665        assert!(g[0] > 0.0);
666        // gamma(0) is the variance.
667        let var = series.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / series.len() as f64;
668        assert!((g[0] - var).abs() < 1e-12);
669    }
670
671    #[test]
672    fn levinson_durbin_recovers_ar1() {
673        // gamma from a strong AR(1): gamma(h) = phi^h * gamma(0).
674        let phi: f64 = 0.8;
675        let g0: f64 = 1.0;
676        let gamma: Vec<f64> = (0..=1i32).map(|h| g0 * phi.powi(h)).collect();
677        let (phi_hat, sigma2) = levinson_durbin_yw(&gamma).unwrap();
678        assert!((phi_hat[0] - 0.8).abs() < 1e-9);
679        assert!(sigma2 > 0.0);
680    }
681
682    #[test]
683    fn levinson_durbin_rejects_zero_variance() {
684        let gamma = [0.0, 0.0];
685        let err = levinson_durbin_yw(&gamma).unwrap_err();
686        matches!(err, FdarError::ComputationFailed { operation, .. } if operation == "levinson_durbin_yw")
687            .then_some(())
688            .expect("expected ComputationFailed");
689    }
690
691    #[test]
692    fn ar_model_fit_and_forecast_ar1() {
693        let phi = 0.8;
694        let n = 200;
695        let mut a = vec![0.0f64; n];
696        a[0] = 1.0;
697        for t in 1..n {
698            let e = 0.4 * (1.3 * t as f64).sin() + 0.3 * (2.7 * t as f64).cos();
699            a[t] = phi * a[t - 1] + e;
700        }
701        let ar = ArModel::fit(&a, n).unwrap();
702        assert!(ar.order >= 1);
703        // Yule-Walker phi_1 close to the true AR coefficient.
704        assert!((ar.phi[0] - 0.8).abs() < 0.1, "phi[0] = {}", ar.phi[0]);
705        let f = ar.forecast(1);
706        assert_eq!(f.len(), 1);
707        assert!(f[0].is_finite());
708    }
709
710    #[test]
711    fn ftsm_fitted_recovers_input() {
712        let (data, argvals) = ar1_curve_series(120, 25, 0.7);
713        let fit = ftsm(&data, 3, &argvals).unwrap();
714        // Fitted curves recover the input within 1% relative-L2 (T-02).
715        let mse = functional_mse(&data, &fit.fitted, &argvals).unwrap();
716        // data variance proxy: MSE of data vs its column means.
717        let mean_mat = {
718            let (n, m) = data.shape();
719            let mut mm = FdMatrix::zeros(n, m);
720            for j in 0..m {
721                let mu = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
722                for i in 0..n {
723                    mm[(i, j)] = mu;
724                }
725            }
726            mm
727        };
728        let var = functional_mse(&data, &mean_mat, &argvals).unwrap();
729        assert!(mse < 0.01 * var, "mse = {mse}, var = {var}");
730    }
731
732    #[test]
733    fn ftsm_deterministic() {
734        let (data, argvals) = ar1_curve_series(80, 20, 0.6);
735        let a = ftsm(&data, 2, &argvals).unwrap();
736        let b = ftsm(&data, 2, &argvals).unwrap();
737        assert_eq!(a, b);
738    }
739
740    #[test]
741    fn ftsm_rejects_ncomp_ge_n() {
742        let argvals = uniform_grid(10);
743        let mut data = FdMatrix::zeros(3, 10);
744        for i in 0..3 {
745            for j in 0..10 {
746                data[(i, j)] = (i + j) as f64;
747            }
748        }
749        let err = ftsm(&data, 5, &argvals).unwrap_err();
750        assert!(
751            matches!(err, FdarError::InvalidParameter { parameter, .. } if parameter == "ncomp")
752        );
753    }
754
755    #[test]
756    fn ftsm_rejects_empty() {
757        let data = FdMatrix::zeros(0, 0);
758        let err = ftsm(&data, 1, &[]).unwrap_err();
759        assert!(matches!(err, FdarError::InvalidDimension { .. }));
760    }
761
762    #[test]
763    fn ftsm_rejects_argvals_mismatch() {
764        let (data, _argvals) = ar1_curve_series(30, 20, 0.5);
765        let err = ftsm(&data, 2, &uniform_grid(19)).unwrap_err();
766        assert!(matches!(err, FdarError::InvalidDimension { .. }));
767    }
768
769    #[test]
770    fn forecast_recovers_ar_one_step() {
771        let (data, argvals) = ar1_curve_series(400, 25, 0.8);
772        let fit = ftsm(&data, 3, &argvals).unwrap();
773        let fc = ftsm_forecast(&fit, 1, &argvals).unwrap();
774        assert_eq!(fc.forecast.shape(), (1, 25));
775        // Dominant-component AR fit recovers the known AR(1) coefficient near 0.8
776        // (Yule-Walker point estimate; finite-sample slack).
777        let ar0 = ar_model_from_fit(&fit, 0);
778        assert!((ar0.phi[0] - 0.8).abs() < 0.12, "phi[0] = {}", ar0.phi[0]);
779        // The one-step score forecast tracks the AR one-step prediction
780        // 0.8·(last-score) (scores are FPCA-centered, so mean ≈ 0); allow 25%
781        // relative slack for the coefficient estimate + any retained higher lags.
782        let n = fit.scores.nrows();
783        let last = fit.scores[(n - 1, 0)];
784        let fscore = ar0.forecast(1)[0];
785        let target = 0.8 * last;
786        assert!(
787            (fscore - target).abs() < 0.25 * target.abs().max(1.0),
788            "fscore = {fscore}, 0.8*last = {target}"
789        );
790    }
791
792    #[test]
793    fn forecast_beats_naive_baseline() {
794        // Fit on the first n-1 curves, forecast the n-th, compare to naive last-curve.
795        let (data, argvals) = ar1_curve_series(140, 25, 0.75);
796        let (n, m) = data.shape();
797        let mut train = FdMatrix::zeros(n - 1, m);
798        for i in 0..n - 1 {
799            for j in 0..m {
800                train[(i, j)] = data[(i, j)];
801            }
802        }
803        let mut truth = FdMatrix::zeros(1, m);
804        let mut naive = FdMatrix::zeros(1, m);
805        for j in 0..m {
806            truth[(0, j)] = data[(n - 1, j)];
807            naive[(0, j)] = data[(n - 2, j)];
808        }
809        let fit = ftsm(&train, 3, &argvals).unwrap();
810        let fc = ftsm_forecast(&fit, 1, &argvals).unwrap();
811        let model_mse = functional_mse(&truth, &fc.forecast, &argvals).unwrap();
812        let naive_mse = functional_mse(&truth, &naive, &argvals).unwrap();
813        assert!(
814            model_mse < naive_mse,
815            "model_mse = {model_mse}, naive_mse = {naive_mse}"
816        );
817    }
818
819    #[test]
820    fn forecast_rejects_h_zero() {
821        let (data, argvals) = ar1_curve_series(40, 20, 0.5);
822        let fit = ftsm(&data, 2, &argvals).unwrap();
823        let err = ftsm_forecast(&fit, 0, &argvals).unwrap_err();
824        assert!(matches!(err, FdarError::InvalidParameter { parameter, .. } if parameter == "h"));
825    }
826
827    // ── Plan 02: multi-step + dynamic update ──────────────────────────────────
828
829    #[test]
830    fn multistep_h1_equals_single_step() {
831        let (data, argvals) = ar1_curve_series(120, 25, 0.7);
832        let fit = ftsm(&data, 3, &argvals).unwrap();
833        let single = ftsm_forecast(&fit, 1, &argvals).unwrap();
834        let multi = ftsm_forecast_multistep(&fit, 1, &argvals).unwrap();
835        assert_eq!(single.forecast.shape(), multi.forecast.shape());
836        for j in 0..25 {
837            assert!(
838                (single.forecast[(0, j)] - multi.forecast[(0, j)]).abs() < 1e-12,
839                "mismatch at j={j}"
840            );
841        }
842    }
843
844    #[test]
845    fn multistep_returns_h_rows() {
846        let (data, argvals) = ar1_curve_series(120, 20, 0.6);
847        let fit = ftsm(&data, 2, &argvals).unwrap();
848        let fc = ftsm_forecast_multistep(&fit, 5, &argvals).unwrap();
849        assert_eq!(fc.forecast.shape(), (5, 20));
850        assert_eq!(fc.h, 5);
851    }
852
853    #[test]
854    fn multistep_rejects_h_zero() {
855        let (data, argvals) = ar1_curve_series(40, 20, 0.5);
856        let fit = ftsm(&data, 2, &argvals).unwrap();
857        let err = ftsm_forecast_multistep(&fit, 0, &argvals).unwrap_err();
858        assert!(matches!(err, FdarError::InvalidParameter { parameter, .. } if parameter == "h"));
859    }
860
861    #[test]
862    fn update_agrees_with_refit() {
863        let (data, argvals) = ar1_curve_series(120, 25, 0.75);
864        let (n, m) = data.shape();
865        // Fit on data[0..n-1].
866        let mut train = FdMatrix::zeros(n - 1, m);
867        for i in 0..n - 1 {
868            for j in 0..m {
869                train[(i, j)] = data[(i, j)];
870            }
871        }
872        let fit = ftsm(&train, 3, &argvals).unwrap();
873        // The new observation = last row of data.
874        let mut new_curve = FdMatrix::zeros(1, m);
875        for j in 0..m {
876            new_curve[(0, j)] = data[(n - 1, j)];
877        }
878        let updated = ftsm_update(&fit, &new_curve, &argvals).unwrap();
879        // Full refit on data[0..n].
880        let full = ftsm(&data, 3, &argvals).unwrap();
881        let upd_fc = ftsm_forecast(&updated, 1, &argvals).unwrap();
882        let full_fc = ftsm_forecast(&full, 1, &argvals).unwrap();
883        // Relative-L2 between the two 1-step forecasts < 1% (frozen-mean divergence).
884        let err = functional_mse(&full_fc.forecast, &upd_fc.forecast, &argvals).unwrap();
885        let scale = {
886            let mut zero = FdMatrix::zeros(1, m);
887            for j in 0..m {
888                zero[(0, j)] = 0.0;
889            }
890            functional_mse(&full_fc.forecast, &zero, &argvals).unwrap()
891        };
892        assert!(err < 0.01 * scale.max(1e-9), "err = {err}, scale = {scale}");
893    }
894
895    #[test]
896    fn update_freezes_loadings() {
897        let (data, argvals) = ar1_curve_series(80, 20, 0.6);
898        let fit = ftsm(&data, 2, &argvals).unwrap();
899        let mut new_curve = FdMatrix::zeros(1, 20);
900        for j in 0..20 {
901            new_curve[(0, j)] = data[(0, j)];
902        }
903        let updated = ftsm_update(&fit, &new_curve, &argvals).unwrap();
904        assert_eq!(updated.mean, fit.mean);
905        assert_eq!(updated.rotation, fit.rotation);
906        assert_eq!(updated.weights, fit.weights);
907    }
908
909    #[test]
910    fn update_extends_scores() {
911        let (data, argvals) = ar1_curve_series(80, 20, 0.6);
912        let fit = ftsm(&data, 2, &argvals).unwrap();
913        let mut new_curve = FdMatrix::zeros(1, 20);
914        for j in 0..20 {
915            new_curve[(0, j)] = data[(0, j)];
916        }
917        let updated = ftsm_update(&fit, &new_curve, &argvals).unwrap();
918        assert_eq!(updated.scores.nrows(), fit.scores.nrows() + 1);
919        assert_eq!(updated.fitted.nrows(), fit.fitted.nrows() + 1);
920    }
921
922    #[test]
923    fn update_rejects_bad_shape() {
924        let (data, argvals) = ar1_curve_series(40, 20, 0.5);
925        let fit = ftsm(&data, 2, &argvals).unwrap();
926        let bad = FdMatrix::zeros(1, 19);
927        let err = ftsm_update(&fit, &bad, &argvals).unwrap_err();
928        assert!(
929            matches!(err, FdarError::InvalidDimension { parameter, .. } if parameter == "new_curve")
930        );
931    }
932
933    // ── Plan 03: functional PLS forecasting ───────────────────────────────────
934
935    /// Full-rank AR-driven curve series for PLS tests: three AR(1) components on
936    /// three basis functions plus small broadband noise, so the lag-1 predictor is
937    /// well-conditioned (not confined to a low-dimensional subspace).
938    fn pls_curve_series(n: usize, m: usize, phi: f64) -> (FdMatrix, Vec<f64>) {
939        let argvals = uniform_grid(m);
940        let basis: Vec<Vec<f64>> = (1..=3)
941            .map(|c| {
942                argvals
943                    .iter()
944                    .map(|u| (c as f64 * std::f64::consts::PI * u).sin())
945                    .collect::<Vec<f64>>()
946            })
947            .collect();
948        let phis = [phi, phi * 0.7, phi * 0.5];
949        let mut comps = vec![vec![0.0f64; n]; 3];
950        for (c, comp) in comps.iter_mut().enumerate() {
951            let e = lcg_white(n, 0xC0FFEE00 + c as u64);
952            comp[0] = e[0];
953            for t in 1..n {
954                comp[t] = phis[c] * comp[t - 1] + e[t];
955            }
956        }
957        let noise = lcg_white(n * m, 0xBEEF_1234);
958        let mut data = FdMatrix::zeros(n, m);
959        for t in 0..n {
960            for j in 0..m {
961                let mut v = 0.05 * noise[t * m + j];
962                for c in 0..3 {
963                    v += comps[c][t] * basis[c][j];
964                }
965                data[(t, j)] = v;
966            }
967        }
968        (data, argvals)
969    }
970
971    #[test]
972    fn fplsr_produces_finite_forecast() {
973        let (data, argvals) = pls_curve_series(60, 25, 0.7);
974        let res = fplsr(&data, 2, &argvals).unwrap();
975        assert_eq!(res.forecast.shape(), (1, 25));
976        assert_eq!(res.fitted.shape(), (59, 25));
977        for j in 0..25 {
978            assert!(res.forecast[(0, j)].is_finite());
979        }
980    }
981
982    #[test]
983    fn fplsr_no_worse_than_naive() {
984        // Fit on data[0..n-1], forecast the n-th curve, compare to naive last-curve.
985        let (data, argvals) = pls_curve_series(80, 25, 0.75);
986        let (n, m) = data.shape();
987        let mut train = FdMatrix::zeros(n - 1, m);
988        for i in 0..n - 1 {
989            for j in 0..m {
990                train[(i, j)] = data[(i, j)];
991            }
992        }
993        let mut truth = FdMatrix::zeros(1, m);
994        let mut naive = FdMatrix::zeros(1, m);
995        for j in 0..m {
996            truth[(0, j)] = data[(n - 1, j)];
997            naive[(0, j)] = data[(n - 2, j)];
998        }
999        let res = fplsr(&train, 3, &argvals).unwrap();
1000        let model_mse = functional_mse(&truth, &res.forecast, &argvals).unwrap();
1001        let naive_mse = functional_mse(&truth, &naive, &argvals).unwrap();
1002        assert!(model_mse.is_finite());
1003        assert!(
1004            model_mse <= naive_mse,
1005            "model_mse = {model_mse}, naive_mse = {naive_mse}"
1006        );
1007    }
1008
1009    #[test]
1010    fn fplsr_deterministic() {
1011        let (data, argvals) = pls_curve_series(50, 20, 0.6);
1012        let a = fplsr(&data, 2, &argvals).unwrap();
1013        let b = fplsr(&data, 2, &argvals).unwrap();
1014        assert_eq!(a, b);
1015    }
1016
1017    #[test]
1018    fn fplsr_rejects_bad_input() {
1019        let (data, argvals) = ar1_curve_series(40, 20, 0.5);
1020        // empty
1021        let empty = FdMatrix::zeros(0, 0);
1022        assert!(matches!(
1023            fplsr(&empty, 1, &[]).unwrap_err(),
1024            FdarError::InvalidDimension { .. }
1025        ));
1026        // ncomp == 0
1027        assert!(matches!(
1028            fplsr(&data, 0, &argvals).unwrap_err(),
1029            FdarError::InvalidParameter { parameter, .. } if parameter == "ncomp"
1030        ));
1031        // n < 3
1032        let (short, short_argvals) = ar1_curve_series(2, 20, 0.5);
1033        assert!(matches!(
1034            fplsr(&short, 1, &short_argvals).unwrap_err(),
1035            FdarError::InvalidParameter { parameter, .. } if parameter == "data"
1036        ));
1037        // argvals mismatch
1038        assert!(matches!(
1039            fplsr(&data, 2, &uniform_grid(19)).unwrap_err(),
1040            FdarError::InvalidDimension { .. }
1041        ));
1042    }
1043}