1use 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
52fn 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
77fn 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
97fn 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#[derive(Debug, Clone, PartialEq)]
147struct ArModel {
148 phi: Vec<f64>,
150 sigma2: f64,
152 mean: f64,
154 order: usize,
156 history: Vec<f64>,
158}
159
160impl ArModel {
161 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 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 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 fn forecast(&self, h: usize) -> Vec<f64> {
229 if self.order == 0 {
230 return vec![self.mean; h];
231 }
232 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#[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
314fn 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#[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#[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 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 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#[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 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 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 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 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#[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 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 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 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); out.push(u - 0.5);
625 }
626 out
627 }
628
629 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 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 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 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 let mse = functional_mse(&data, &fit.fitted, &argvals).unwrap();
716 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 let ar0 = ar_model_from_fit(&fit, 0);
778 assert!((ar0.phi[0] - 0.8).abs() < 0.12, "phi[0] = {}", ar0.phi[0]);
779 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 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 #[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 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 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 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 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 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 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 let empty = FdMatrix::zeros(0, 0);
1022 assert!(matches!(
1023 fplsr(&empty, 1, &[]).unwrap_err(),
1024 FdarError::InvalidDimension { .. }
1025 ));
1026 assert!(matches!(
1028 fplsr(&data, 0, &argvals).unwrap_err(),
1029 FdarError::InvalidParameter { parameter, .. } if parameter == "ncomp"
1030 ));
1031 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 assert!(matches!(
1039 fplsr(&data, 2, &uniform_grid(19)).unwrap_err(),
1040 FdarError::InvalidDimension { .. }
1041 ));
1042 }
1043}