Skip to main content

fdars_core/
regression.rs

1//! Regression functions for functional data.
2//!
3//! This module provides functional PCA, PLS, and ridge regression.
4
5use crate::error::FdarError;
6use crate::helpers::simpsons_weights;
7use crate::matrix::FdMatrix;
8#[cfg(feature = "linalg")]
9use anofox_regression::solvers::RidgeRegressor;
10#[cfg(feature = "linalg")]
11use anofox_regression::{FittedRegressor, Regressor};
12use nalgebra::SVD;
13
14/// Result of functional PCA.
15#[derive(Debug, Clone, PartialEq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[non_exhaustive]
18pub struct FpcaResult {
19    /// Singular values
20    pub singular_values: Vec<f64>,
21    /// Rotation matrix (loadings), m x ncomp
22    pub rotation: FdMatrix,
23    /// Scores matrix, n x ncomp
24    pub scores: FdMatrix,
25    /// Mean function
26    pub mean: Vec<f64>,
27    /// Centered data, n x m
28    pub centered: FdMatrix,
29    /// Integration weights used for the functional inner product
30    pub weights: Vec<f64>,
31}
32
33impl FpcaResult {
34    /// Project new functional data onto the FPC score space.
35    ///
36    /// Centers the input data by subtracting the mean function estimated
37    /// during FPCA, then multiplies by the rotation (loadings) matrix to
38    /// obtain FPC scores for the new observations.
39    ///
40    /// # Arguments
41    /// * `data` - Matrix (n_new x m) of new observations
42    ///
43    /// # Errors
44    ///
45    /// Returns [`FdarError::InvalidDimension`] if the number of columns in
46    /// `data` does not match the length of the mean vector (i.e. the number
47    /// of evaluation points used during FPCA).
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use fdars_core::matrix::FdMatrix;
53    /// use fdars_core::regression::fdata_to_pc_1d;
54    ///
55    /// let data = FdMatrix::from_column_major(
56    ///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
57    ///     5, 10,
58    /// ).unwrap();
59    /// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
60    /// let fpca = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
61    ///
62    /// // Project the original data (scores should match)
63    /// let scores = fpca.project(&data).unwrap();
64    /// assert_eq!(scores.shape(), (5, 3));
65    ///
66    /// // Project new data
67    /// let new_data = FdMatrix::from_column_major(
68    ///     (0..20).map(|i| (i as f64 * 0.2).cos()).collect(),
69    ///     2, 10,
70    /// ).unwrap();
71    /// let new_scores = fpca.project(&new_data).unwrap();
72    /// assert_eq!(new_scores.shape(), (2, 3));
73    /// ```
74    pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
75        let (n, m) = data.shape();
76        let ncomp = self.rotation.ncols();
77        if m != self.mean.len() {
78            return Err(FdarError::InvalidDimension {
79                parameter: "data",
80                expected: format!("{} columns", self.mean.len()),
81                actual: format!("{m} columns"),
82            });
83        }
84
85        let mut scores = FdMatrix::zeros(n, ncomp);
86        for i in 0..n {
87            for k in 0..ncomp {
88                let mut sum = 0.0;
89                for j in 0..m {
90                    sum += (data[(i, j)] - self.mean[j]) * self.rotation[(j, k)] * self.weights[j];
91                }
92                scores[(i, k)] = sum;
93            }
94        }
95        Ok(scores)
96    }
97
98    /// Reconstruct functional data from FPC scores.
99    ///
100    /// Computes the approximation of functional data using the first
101    /// `ncomp` principal components:
102    /// `data[i, j] = mean[j] + sum_k scores[i, k] * rotation[j, k]`
103    ///
104    /// # Arguments
105    /// * `scores` - Score matrix (n x p) where p >= `ncomp`
106    /// * `ncomp` - Number of components to use for reconstruction
107    ///
108    /// # Errors
109    ///
110    /// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero or exceeds
111    /// the number of columns in `scores` or the number of available components
112    /// in the rotation matrix.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use fdars_core::matrix::FdMatrix;
118    /// use fdars_core::regression::fdata_to_pc_1d;
119    ///
120    /// let data = FdMatrix::from_column_major(
121    ///     (0..100).map(|i| (i as f64 * 0.1).sin()).collect(),
122    ///     10, 10,
123    /// ).unwrap();
124    /// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
125    /// let fpca = fdata_to_pc_1d(&data, 5, &argvals).unwrap();
126    ///
127    /// // Reconstruct using all 5 components
128    /// let recon = fpca.reconstruct(&fpca.scores, 5).unwrap();
129    /// assert_eq!(recon.shape(), (10, 10));
130    ///
131    /// // Reconstruct using fewer components
132    /// let recon2 = fpca.reconstruct(&fpca.scores, 2).unwrap();
133    /// assert_eq!(recon2.shape(), (10, 10));
134    /// ```
135    pub fn reconstruct(&self, scores: &FdMatrix, ncomp: usize) -> Result<FdMatrix, FdarError> {
136        let (n, p) = scores.shape();
137        let m = self.mean.len();
138        let max_comp = self.rotation.ncols().min(p);
139        if ncomp == 0 {
140            return Err(FdarError::InvalidParameter {
141                parameter: "ncomp",
142                message: "ncomp must be >= 1".to_string(),
143            });
144        }
145        if ncomp > max_comp {
146            return Err(FdarError::InvalidParameter {
147                parameter: "ncomp",
148                message: format!("ncomp={ncomp} exceeds available components ({max_comp})"),
149            });
150        }
151
152        let mut recon = FdMatrix::zeros(n, m);
153        for i in 0..n {
154            for j in 0..m {
155                let mut val = self.mean[j];
156                for k in 0..ncomp {
157                    val += scores[(i, k)] * self.rotation[(j, k)];
158                }
159                recon[(i, j)] = val;
160            }
161        }
162        Ok(recon)
163    }
164}
165
166/// Center columns of a matrix and return (centered_matrix, column_means).
167fn center_columns(data: &FdMatrix) -> (FdMatrix, Vec<f64>) {
168    let (n, m) = data.shape();
169    let mut centered = FdMatrix::zeros(n, m);
170    let mut means = vec![0.0; m];
171    for j in 0..m {
172        let col = data.column(j);
173        let mean = col.iter().sum::<f64>() / n as f64;
174        means[j] = mean;
175        let out_col = centered.column_mut(j);
176        for i in 0..n {
177            out_col[i] = col[i] - mean;
178        }
179    }
180    (centered, means)
181}
182
183/// Extract rotation (V) and scores (U*S) from SVD results.
184fn extract_pc_components(
185    svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
186    n: usize,
187    m: usize,
188    ncomp: usize,
189) -> Option<(Vec<f64>, FdMatrix, FdMatrix)> {
190    let singular_values: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
191
192    let v_t = svd.v_t.as_ref()?;
193    let mut rotation = FdMatrix::zeros(m, ncomp);
194    for k in 0..ncomp {
195        for j in 0..m {
196            rotation[(j, k)] = v_t[(k, j)];
197        }
198    }
199
200    let u = svd.u.as_ref()?;
201    let mut scores = FdMatrix::zeros(n, ncomp);
202    for k in 0..ncomp {
203        let sv_k = singular_values[k];
204        for i in 0..n {
205            scores[(i, k)] = u[(i, k)] * sv_k;
206        }
207    }
208
209    Some((singular_values, rotation, scores))
210}
211
212/// Perform functional PCA via SVD on centered data with integration weights.
213///
214/// Uses Simpson's-rule weights derived from `argvals` so that the resulting
215/// scores represent functional inner products and are invariant to grid
216/// density.
217///
218/// # Arguments
219/// * `data` - Matrix (n x m): n observations, m evaluation points
220/// * `ncomp` - Number of components to extract
221/// * `argvals` - Evaluation grid points (length m)
222///
223/// # Errors
224///
225/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or zero
226/// columns, or if `argvals.len() != m`.
227/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
228/// Returns [`FdarError::ComputationFailed`] if the SVD decomposition fails to
229/// produce U or V_t matrices.
230///
231/// # Examples
232///
233/// ```
234/// use fdars_core::matrix::FdMatrix;
235/// use fdars_core::regression::fdata_to_pc_1d;
236///
237/// // 5 curves, each evaluated at 10 points
238/// let data = FdMatrix::from_column_major(
239///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
240///     5, 10,
241/// ).unwrap();
242/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
243/// let result = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
244/// assert_eq!(result.scores.shape(), (5, 3));
245/// assert_eq!(result.rotation.shape(), (10, 3));
246/// assert_eq!(result.mean.len(), 10);
247/// ```
248#[must_use = "expensive computation whose result should not be discarded"]
249pub fn fdata_to_pc_1d(
250    data: &FdMatrix,
251    ncomp: usize,
252    argvals: &[f64],
253) -> Result<FpcaResult, FdarError> {
254    let (n, m) = data.shape();
255    if n == 0 {
256        return Err(FdarError::InvalidDimension {
257            parameter: "data",
258            expected: "n > 0 rows".to_string(),
259            actual: format!("n = {n}"),
260        });
261    }
262    if m == 0 {
263        return Err(FdarError::InvalidDimension {
264            parameter: "data",
265            expected: "m > 0 columns".to_string(),
266            actual: format!("m = {m}"),
267        });
268    }
269    if argvals.len() != m {
270        return Err(FdarError::InvalidDimension {
271            parameter: "argvals",
272            expected: format!("{m} elements"),
273            actual: format!("{} elements", argvals.len()),
274        });
275    }
276    if ncomp < 1 {
277        return Err(FdarError::InvalidParameter {
278            parameter: "ncomp",
279            message: format!("ncomp must be >= 1, got {ncomp}"),
280        });
281    }
282
283    let ncomp = ncomp.min(n).min(m);
284    let (centered, means) = center_columns(data);
285
286    // Compute integration weights for functional inner product
287    let weights = simpsons_weights(argvals);
288    let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
289
290    // Scale centered data by sqrt(weights) for weighted SVD
291    let mut weighted = centered.clone();
292    for i in 0..n {
293        for j in 0..m {
294            weighted[(i, j)] *= sqrt_weights[j];
295        }
296    }
297
298    let svd = SVD::new(weighted.to_dmatrix(), true, true);
299    let (singular_values, mut rotation, scores) =
300        extract_pc_components(&svd, n, m, ncomp).ok_or_else(|| FdarError::ComputationFailed {
301            operation: "SVD",
302            detail: "failed to extract U or V_t from SVD decomposition; try reducing ncomp or check for zero-variance columns in the data".to_string(),
303        })?;
304
305    // Unscale loadings: divide by sqrt(weights) to get actual eigenfunctions
306    for k in 0..ncomp {
307        for j in 0..m {
308            if sqrt_weights[j] > 1e-15 {
309                rotation[(j, k)] /= sqrt_weights[j];
310            }
311        }
312    }
313
314    Ok(FpcaResult {
315        singular_values,
316        rotation,
317        scores,
318        mean: means,
319        centered,
320        weights,
321    })
322}
323
324/// Result of PLS regression.
325#[derive(Debug, Clone, PartialEq)]
326#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
327#[non_exhaustive]
328pub struct PlsResult {
329    /// Weight vectors, m x ncomp
330    pub weights: FdMatrix,
331    /// Score vectors, n x ncomp
332    pub scores: FdMatrix,
333    /// Loading vectors, m x ncomp
334    pub loadings: FdMatrix,
335    /// Column means of the training data, length m
336    pub x_means: Vec<f64>,
337    /// Integration weights for the functional inner product
338    pub integration_weights: Vec<f64>,
339}
340
341impl PlsResult {
342    /// Project new functional data onto the PLS score space.
343    ///
344    /// Centers the input data by subtracting the column means estimated
345    /// during PLS fitting, then iteratively projects and deflates through
346    /// each PLS component using the stored weight and loading vectors.
347    ///
348    /// # Arguments
349    /// * `data` - Matrix (n_new x m) of new observations
350    ///
351    /// # Errors
352    ///
353    /// Returns [`FdarError::InvalidDimension`] if the number of columns in
354    /// `data` does not match the number of predictor variables used during
355    /// PLS fitting.
356    ///
357    /// # Examples
358    ///
359    /// ```
360    /// use fdars_core::matrix::FdMatrix;
361    /// use fdars_core::regression::fdata_to_pls_1d;
362    ///
363    /// let x = FdMatrix::from_column_major(
364    ///     (0..100).map(|i| (i as f64 * 0.1).sin()).collect(),
365    ///     10, 10,
366    /// ).unwrap();
367    /// let y: Vec<f64> = (0..10).map(|i| i as f64 * 0.5).collect();
368    /// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
369    /// let pls = fdata_to_pls_1d(&x, &y, 3, &argvals).unwrap();
370    ///
371    /// // Project the original data
372    /// let scores = pls.project(&x).unwrap();
373    /// assert_eq!(scores.shape(), (10, 3));
374    ///
375    /// // Project new data
376    /// let new_x = FdMatrix::from_column_major(
377    ///     (0..20).map(|i| (i as f64 * 0.2).cos()).collect(),
378    ///     2, 10,
379    /// ).unwrap();
380    /// let new_scores = pls.project(&new_x).unwrap();
381    /// assert_eq!(new_scores.shape(), (2, 3));
382    /// ```
383    pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
384        let (n, m) = data.shape();
385        let ncomp = self.weights.ncols();
386        if m != self.x_means.len() {
387            return Err(FdarError::InvalidDimension {
388                parameter: "data",
389                expected: format!("{} columns", self.x_means.len()),
390                actual: format!("{m} columns"),
391            });
392        }
393
394        // Center data
395        let mut x_cen = FdMatrix::zeros(n, m);
396        for j in 0..m {
397            for i in 0..n {
398                x_cen[(i, j)] = data[(i, j)] - self.x_means[j];
399            }
400        }
401
402        // Iteratively project and deflate through each component
403        let mut scores = FdMatrix::zeros(n, ncomp);
404        for k in 0..ncomp {
405            // Compute scores: t = X_cen * W * w_k (weighted inner product)
406            for i in 0..n {
407                let mut sum = 0.0;
408                for j in 0..m {
409                    sum += x_cen[(i, j)] * self.weights[(j, k)] * self.integration_weights[j];
410                }
411                scores[(i, k)] = sum;
412            }
413
414            // Deflate: X_cen -= t * p_k'
415            for j in 0..m {
416                let p_jk = self.loadings[(j, k)];
417                for i in 0..n {
418                    x_cen[(i, j)] -= scores[(i, k)] * p_jk;
419                }
420            }
421        }
422
423        Ok(scores)
424    }
425}
426
427/// Compute PLS weight vector: w = X'y / ||X'y|| (with integration weights)
428fn pls_compute_weights(x_cen: &FdMatrix, y_cen: &[f64], int_w: &[f64]) -> Vec<f64> {
429    let (n, m) = x_cen.shape();
430    let mut w: Vec<f64> = (0..m)
431        .map(|j| {
432            let mut sum = 0.0;
433            for i in 0..n {
434                sum += x_cen[(i, j)] * y_cen[i];
435            }
436            sum * int_w[j]
437        })
438        .collect();
439
440    let w_norm: f64 = w.iter().map(|&wi| wi * wi).sum::<f64>().sqrt();
441    if w_norm > 1e-10 {
442        for wi in &mut w {
443            *wi /= w_norm;
444        }
445    }
446    w
447}
448
449/// Compute PLS scores: t = X * W * w (weighted inner product)
450fn pls_compute_scores(x_cen: &FdMatrix, w: &[f64], int_w: &[f64]) -> Vec<f64> {
451    let (n, m) = x_cen.shape();
452    (0..n)
453        .map(|i| {
454            let mut sum = 0.0;
455            for j in 0..m {
456                sum += x_cen[(i, j)] * w[j] * int_w[j];
457            }
458            sum
459        })
460        .collect()
461}
462
463/// Compute PLS loadings: p = X't / (t't) (with integration weights)
464fn pls_compute_loadings(x_cen: &FdMatrix, t: &[f64], t_norm_sq: f64, int_w: &[f64]) -> Vec<f64> {
465    let (n, m) = x_cen.shape();
466    (0..m)
467        .map(|j| {
468            let mut sum = 0.0;
469            for i in 0..n {
470                sum += x_cen[(i, j)] * t[i];
471            }
472            sum * int_w[j] / t_norm_sq.max(1e-10)
473        })
474        .collect()
475}
476
477/// Deflate X by removing the rank-1 component t * p'
478fn pls_deflate_x(x_cen: &mut FdMatrix, t: &[f64], p: &[f64]) {
479    let (n, m) = x_cen.shape();
480    for j in 0..m {
481        for i in 0..n {
482            x_cen[(i, j)] -= t[i] * p[j];
483        }
484    }
485}
486
487/// Execute one NIPALS step: compute weights/scores/loadings and deflate X and y.
488fn pls_nipals_step(
489    k: usize,
490    x_cen: &mut FdMatrix,
491    y_cen: &mut [f64],
492    weights: &mut FdMatrix,
493    scores: &mut FdMatrix,
494    loadings: &mut FdMatrix,
495    int_w: &[f64],
496) {
497    let n = x_cen.nrows();
498    let m = x_cen.ncols();
499
500    let w = pls_compute_weights(x_cen, y_cen, int_w);
501    let t = pls_compute_scores(x_cen, &w, int_w);
502    let t_norm_sq: f64 = t.iter().map(|&ti| ti * ti).sum();
503    let p = pls_compute_loadings(x_cen, &t, t_norm_sq, int_w);
504
505    for j in 0..m {
506        weights[(j, k)] = w[j];
507        loadings[(j, k)] = p[j];
508    }
509    for i in 0..n {
510        scores[(i, k)] = t[i];
511    }
512
513    pls_deflate_x(x_cen, &t, &p);
514    let t_y: f64 = t.iter().zip(y_cen.iter()).map(|(&ti, &yi)| ti * yi).sum();
515    let q = t_y / t_norm_sq.max(1e-10);
516    for i in 0..n {
517        y_cen[i] -= t[i] * q;
518    }
519}
520
521/// Perform PLS via NIPALS algorithm with integration weights.
522///
523/// # Arguments
524/// * `data` - Matrix (n x m): n observations, m evaluation points
525/// * `y` - Response vector (length n)
526/// * `ncomp` - Number of components to extract
527/// * `argvals` - Evaluation grid points (length m)
528///
529/// # Errors
530///
531/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or zero
532/// columns, if `y.len()` does not equal the number of rows in `data`,
533/// or if `argvals.len() != m`.
534/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
535#[must_use = "expensive computation whose result should not be discarded"]
536pub fn fdata_to_pls_1d(
537    data: &FdMatrix,
538    y: &[f64],
539    ncomp: usize,
540    argvals: &[f64],
541) -> Result<PlsResult, FdarError> {
542    let (n, m) = data.shape();
543    if n == 0 {
544        return Err(FdarError::InvalidDimension {
545            parameter: "data",
546            expected: "n > 0 rows".to_string(),
547            actual: format!("n = {n}"),
548        });
549    }
550    if m == 0 {
551        return Err(FdarError::InvalidDimension {
552            parameter: "data",
553            expected: "m > 0 columns".to_string(),
554            actual: format!("m = {m}"),
555        });
556    }
557    if y.len() != n {
558        return Err(FdarError::InvalidDimension {
559            parameter: "y",
560            expected: format!("length {n}"),
561            actual: format!("length {}", y.len()),
562        });
563    }
564    if argvals.len() != m {
565        return Err(FdarError::InvalidDimension {
566            parameter: "argvals",
567            expected: format!("{m} elements"),
568            actual: format!("{} elements", argvals.len()),
569        });
570    }
571    if ncomp < 1 {
572        return Err(FdarError::InvalidParameter {
573            parameter: "ncomp",
574            message: format!("ncomp must be >= 1, got {ncomp}"),
575        });
576    }
577
578    let ncomp = ncomp.min(n).min(m);
579
580    // Compute integration weights
581    let int_w = simpsons_weights(argvals);
582
583    // Center X and y
584    let x_means: Vec<f64> = (0..m)
585        .map(|j| {
586            let col = data.column(j);
587            let sum: f64 = col.iter().sum();
588            sum / n as f64
589        })
590        .collect();
591
592    let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
593
594    let mut x_cen = FdMatrix::zeros(n, m);
595    for j in 0..m {
596        for i in 0..n {
597            x_cen[(i, j)] = data[(i, j)] - x_means[j];
598        }
599    }
600
601    let mut y_cen: Vec<f64> = y.iter().map(|&yi| yi - y_mean).collect();
602
603    let mut weights = FdMatrix::zeros(m, ncomp);
604    let mut scores = FdMatrix::zeros(n, ncomp);
605    let mut loadings = FdMatrix::zeros(m, ncomp);
606
607    // NIPALS algorithm
608    for k in 0..ncomp {
609        pls_nipals_step(
610            k,
611            &mut x_cen,
612            &mut y_cen,
613            &mut weights,
614            &mut scores,
615            &mut loadings,
616            &int_w,
617        );
618    }
619
620    Ok(PlsResult {
621        weights,
622        scores,
623        loadings,
624        x_means,
625        integration_weights: int_w,
626    })
627}
628
629/// Result of ridge regression fit.
630#[derive(Debug, Clone, PartialEq)]
631#[non_exhaustive]
632#[cfg(feature = "linalg")]
633pub struct RidgeResult {
634    /// Coefficients
635    pub coefficients: Vec<f64>,
636    /// Intercept
637    pub intercept: f64,
638    /// Fitted values
639    pub fitted_values: Vec<f64>,
640    /// Residuals
641    pub residuals: Vec<f64>,
642    /// R-squared
643    pub r_squared: f64,
644    /// Lambda used
645    pub lambda: f64,
646    /// Error message if any
647    pub error: Option<String>,
648}
649
650/// Fit ridge regression.
651///
652/// # Arguments
653/// * `x` - Predictor matrix (n x m)
654/// * `y` - Response vector
655/// * `lambda` - Regularization parameter
656/// * `with_intercept` - Whether to include intercept
657#[cfg(feature = "linalg")]
658#[must_use = "expensive computation whose result should not be discarded"]
659pub fn ridge_regression_fit(
660    x: &FdMatrix,
661    y: &[f64],
662    lambda: f64,
663    with_intercept: bool,
664) -> RidgeResult {
665    let (n, m) = x.shape();
666    if n == 0 || m == 0 || y.len() != n {
667        return RidgeResult {
668            coefficients: Vec::new(),
669            intercept: 0.0,
670            fitted_values: Vec::new(),
671            residuals: Vec::new(),
672            r_squared: 0.0,
673            lambda,
674            error: Some("Invalid input dimensions".to_string()),
675        };
676    }
677
678    // Convert to faer Mat format
679    let x_faer = faer::Mat::from_fn(n, m, |i, j| x[(i, j)]);
680    let y_faer = faer::Col::from_fn(n, |i| y[i]);
681
682    // Build and fit the ridge regressor
683    let regressor = RidgeRegressor::builder()
684        .with_intercept(with_intercept)
685        .lambda(lambda)
686        .build();
687
688    let fitted = match regressor.fit(&x_faer, &y_faer) {
689        Ok(f) => f,
690        Err(e) => {
691            return RidgeResult {
692                coefficients: Vec::new(),
693                intercept: 0.0,
694                fitted_values: Vec::new(),
695                residuals: Vec::new(),
696                r_squared: 0.0,
697                lambda,
698                error: Some(format!("Fit failed: {e:?}")),
699            }
700        }
701    };
702
703    // Extract coefficients
704    let coefs = fitted.coefficients();
705    let coefficients: Vec<f64> = (0..coefs.nrows()).map(|i| coefs[i]).collect();
706
707    // Get intercept
708    let intercept = fitted.intercept().unwrap_or(0.0);
709
710    // Compute fitted values
711    let mut fitted_values = vec![0.0; n];
712    for i in 0..n {
713        let mut pred = intercept;
714        for j in 0..m {
715            pred += x[(i, j)] * coefficients[j];
716        }
717        fitted_values[i] = pred;
718    }
719
720    // Compute residuals
721    let residuals: Vec<f64> = y
722        .iter()
723        .zip(fitted_values.iter())
724        .map(|(&yi, &yhat)| yi - yhat)
725        .collect();
726
727    // Compute R-squared
728    let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
729    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
730    let ss_res: f64 = residuals.iter().map(|&r| r.powi(2)).sum();
731    let r_squared = if ss_tot > 0.0 {
732        1.0 - ss_res / ss_tot
733    } else {
734        0.0
735    };
736
737    RidgeResult {
738        coefficients,
739        intercept,
740        fitted_values,
741        residuals,
742        r_squared,
743        lambda,
744        error: None,
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use std::f64::consts::PI;
752
753    /// Generate functional data with known structure for testing
754    fn generate_test_fdata(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
755        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
756
757        // Create n curves: sine waves with varying phase
758        let mut data = FdMatrix::zeros(n, m);
759        for i in 0..n {
760            let phase = (i as f64 / n as f64) * PI;
761            for j in 0..m {
762                data[(i, j)] = (2.0 * PI * t[j] + phase).sin();
763            }
764        }
765
766        (data, t)
767    }
768
769    // ============== FPCA tests ==============
770
771    #[test]
772    fn test_fdata_to_pc_1d_basic() {
773        let n = 20;
774        let m = 50;
775        let ncomp = 3;
776        let (data, t) = generate_test_fdata(n, m);
777
778        let result = fdata_to_pc_1d(&data, ncomp, &t);
779        assert!(result.is_ok());
780
781        let fpca = result.unwrap();
782        assert_eq!(fpca.singular_values.len(), ncomp);
783        assert_eq!(fpca.rotation.shape(), (m, ncomp));
784        assert_eq!(fpca.scores.shape(), (n, ncomp));
785        assert_eq!(fpca.mean.len(), m);
786        assert_eq!(fpca.centered.shape(), (n, m));
787    }
788
789    #[test]
790    fn test_fdata_to_pc_1d_singular_values_decreasing() {
791        let n = 20;
792        let m = 50;
793        let ncomp = 5;
794        let (data, t) = generate_test_fdata(n, m);
795
796        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
797
798        // Singular values should be in decreasing order
799        for i in 1..fpca.singular_values.len() {
800            assert!(
801                fpca.singular_values[i] <= fpca.singular_values[i - 1] + 1e-10,
802                "Singular values should be decreasing"
803            );
804        }
805    }
806
807    #[test]
808    fn test_fdata_to_pc_1d_centered_has_zero_mean() {
809        let n = 20;
810        let m = 50;
811        let (data, t) = generate_test_fdata(n, m);
812
813        let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
814
815        // Column means of centered data should be zero
816        for j in 0..m {
817            let col_mean: f64 = (0..n).map(|i| fpca.centered[(i, j)]).sum::<f64>() / n as f64;
818            assert!(
819                col_mean.abs() < 1e-10,
820                "Centered data should have zero column mean"
821            );
822        }
823    }
824
825    #[test]
826    fn test_fdata_to_pc_1d_ncomp_limits() {
827        let n = 10;
828        let m = 50;
829        let (data, t) = generate_test_fdata(n, m);
830
831        // Request more components than n - should cap at n
832        let fpca = fdata_to_pc_1d(&data, 20, &t).unwrap();
833        assert!(fpca.singular_values.len() <= n);
834    }
835
836    #[test]
837    fn test_fdata_to_pc_1d_invalid_input() {
838        // Empty data
839        let empty = FdMatrix::zeros(0, 50);
840        let t50: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
841        let result = fdata_to_pc_1d(&empty, 3, &t50);
842        assert!(result.is_err());
843
844        // Zero components
845        let (data, t) = generate_test_fdata(10, 50);
846        let result = fdata_to_pc_1d(&data, 0, &t);
847        assert!(result.is_err());
848    }
849
850    #[test]
851    fn test_fdata_to_pc_1d_reconstruction() {
852        let n = 10;
853        let m = 30;
854        let (data, t) = generate_test_fdata(n, m);
855
856        // Use all components for perfect reconstruction
857        let ncomp = n.min(m);
858        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
859
860        // Reconstruct: X_centered = scores * rotation^T
861        for i in 0..n {
862            for j in 0..m {
863                let mut reconstructed = 0.0;
864                for k in 0..ncomp {
865                    let score = fpca.scores[(i, k)];
866                    let loading = fpca.rotation[(j, k)];
867                    reconstructed += score * loading;
868                }
869                let original_centered = fpca.centered[(i, j)];
870                assert!(
871                    (reconstructed - original_centered).abs() < 0.1,
872                    "Reconstruction error at ({}, {}): {} vs {}",
873                    i,
874                    j,
875                    reconstructed,
876                    original_centered
877                );
878            }
879        }
880    }
881
882    // ============== PLS tests ==============
883
884    #[test]
885    fn test_fdata_to_pls_1d_basic() {
886        let n = 20;
887        let m = 30;
888        let ncomp = 3;
889        let (x, t) = generate_test_fdata(n, m);
890
891        // Create y with some relationship to x
892        let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
893
894        let result = fdata_to_pls_1d(&x, &y, ncomp, &t);
895        assert!(result.is_ok());
896
897        let pls = result.unwrap();
898        assert_eq!(pls.weights.shape(), (m, ncomp));
899        assert_eq!(pls.scores.shape(), (n, ncomp));
900        assert_eq!(pls.loadings.shape(), (m, ncomp));
901    }
902
903    #[test]
904    fn test_fdata_to_pls_1d_weights_normalized() {
905        let n = 20;
906        let m = 30;
907        let ncomp = 2;
908        let (x, t) = generate_test_fdata(n, m);
909        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
910
911        let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
912
913        // Weight vectors should be approximately unit norm
914        for k in 0..ncomp {
915            let norm: f64 = (0..m)
916                .map(|j| pls.weights[(j, k)].powi(2))
917                .sum::<f64>()
918                .sqrt();
919            assert!(
920                (norm - 1.0).abs() < 0.1,
921                "Weight vector {} should be unit norm, got {}",
922                k,
923                norm
924            );
925        }
926    }
927
928    #[test]
929    fn test_fdata_to_pls_1d_invalid_input() {
930        let (x, t) = generate_test_fdata(10, 30);
931
932        // Wrong y length
933        let result = fdata_to_pls_1d(&x, &[0.0; 5], 2, &t);
934        assert!(result.is_err());
935
936        // Zero components
937        let y = vec![0.0; 10];
938        let result = fdata_to_pls_1d(&x, &y, 0, &t);
939        assert!(result.is_err());
940    }
941
942    // ============== Ridge regression tests ==============
943
944    #[cfg(feature = "linalg")]
945    #[test]
946    fn test_ridge_regression_fit_basic() {
947        let n = 50;
948        let m = 5;
949
950        // Create X with known structure
951        let mut x = FdMatrix::zeros(n, m);
952        for i in 0..n {
953            for j in 0..m {
954                x[(i, j)] = (i as f64 + j as f64) / (n + m) as f64;
955            }
956        }
957
958        // Create y = sum of x columns + noise
959        let y: Vec<f64> = (0..n)
960            .map(|i| {
961                let mut sum = 0.0;
962                for j in 0..m {
963                    sum += x[(i, j)];
964                }
965                sum + 0.01 * (i as f64 % 10.0)
966            })
967            .collect();
968
969        let result = ridge_regression_fit(&x, &y, 0.1, true);
970
971        assert!(result.error.is_none(), "Ridge should fit without error");
972        assert_eq!(result.coefficients.len(), m);
973        assert_eq!(result.fitted_values.len(), n);
974        assert_eq!(result.residuals.len(), n);
975    }
976
977    #[cfg(feature = "linalg")]
978    #[test]
979    fn test_ridge_regression_fit_r_squared() {
980        let n = 50;
981        let m = 3;
982
983        let x = FdMatrix::from_column_major(
984            (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
985            n,
986            m,
987        )
988        .unwrap();
989        let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
990
991        let result = ridge_regression_fit(&x, &y, 0.01, true);
992
993        assert!(
994            result.r_squared > 0.5,
995            "R-squared should be high, got {}",
996            result.r_squared
997        );
998        assert!(result.r_squared <= 1.0 + 1e-10, "R-squared should be <= 1");
999    }
1000
1001    #[cfg(feature = "linalg")]
1002    #[test]
1003    fn test_ridge_regression_fit_regularization() {
1004        let n = 30;
1005        let m = 10;
1006
1007        let x = FdMatrix::from_column_major(
1008            (0..n * m)
1009                .map(|i| ((i * 17) % 100) as f64 / 100.0)
1010                .collect(),
1011            n,
1012            m,
1013        )
1014        .unwrap();
1015        let y: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
1016
1017        let low_lambda = ridge_regression_fit(&x, &y, 0.001, true);
1018        let high_lambda = ridge_regression_fit(&x, &y, 100.0, true);
1019
1020        let norm_low: f64 = low_lambda
1021            .coefficients
1022            .iter()
1023            .map(|c| c.powi(2))
1024            .sum::<f64>()
1025            .sqrt();
1026        let norm_high: f64 = high_lambda
1027            .coefficients
1028            .iter()
1029            .map(|c| c.powi(2))
1030            .sum::<f64>()
1031            .sqrt();
1032
1033        assert!(
1034            norm_high <= norm_low + 1e-6,
1035            "Higher lambda should shrink coefficients: {} vs {}",
1036            norm_high,
1037            norm_low
1038        );
1039    }
1040
1041    #[cfg(feature = "linalg")]
1042    #[test]
1043    fn test_ridge_regression_fit_residuals() {
1044        let n = 20;
1045        let m = 3;
1046
1047        let x = FdMatrix::from_column_major(
1048            (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1049            n,
1050            m,
1051        )
1052        .unwrap();
1053        let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1054
1055        let result = ridge_regression_fit(&x, &y, 0.1, true);
1056
1057        for i in 0..n {
1058            let expected_resid = y[i] - result.fitted_values[i];
1059            assert!(
1060                (result.residuals[i] - expected_resid).abs() < 1e-10,
1061                "Residual mismatch at {}",
1062                i
1063            );
1064        }
1065    }
1066
1067    #[cfg(feature = "linalg")]
1068    #[test]
1069    fn test_ridge_regression_fit_no_intercept() {
1070        let n = 30;
1071        let m = 5;
1072
1073        let x = FdMatrix::from_column_major(
1074            (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1075            n,
1076            m,
1077        )
1078        .unwrap();
1079        let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1080
1081        let result = ridge_regression_fit(&x, &y, 0.1, false);
1082
1083        assert!(result.error.is_none());
1084        assert!(
1085            result.intercept.abs() < 1e-10,
1086            "Intercept should be 0, got {}",
1087            result.intercept
1088        );
1089    }
1090
1091    #[cfg(feature = "linalg")]
1092    #[test]
1093    fn test_ridge_regression_fit_invalid_input() {
1094        let empty = FdMatrix::zeros(0, 5);
1095        let result = ridge_regression_fit(&empty, &[], 0.1, true);
1096        assert!(result.error.is_some());
1097
1098        let x = FdMatrix::zeros(10, 10);
1099        let y = vec![0.0; 5];
1100        let result = ridge_regression_fit(&x, &y, 0.1, true);
1101        assert!(result.error.is_some());
1102    }
1103
1104    #[test]
1105    fn test_all_zero_fpca() {
1106        // All-zero data: centering leaves zeros, SVD should return trivial result
1107        let n = 5;
1108        let m = 20;
1109        let data = FdMatrix::zeros(n, m);
1110        let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1111        let result = fdata_to_pc_1d(&data, 2, &t);
1112        // Should not panic; may return Ok with zero singular values
1113        if let Ok(res) = result {
1114            assert_eq!(res.scores.nrows(), n);
1115            for &sv in &res.singular_values {
1116                assert!(
1117                    sv.abs() < 1e-10,
1118                    "All-zero data should have zero singular values"
1119                );
1120            }
1121        }
1122    }
1123
1124    #[test]
1125    fn test_n1_pca() {
1126        // Single observation: centering leaves all zeros, SVD may return trivial result
1127        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1128        let t = vec![0.0, 0.5, 1.0];
1129        let result = fdata_to_pc_1d(&data, 1, &t);
1130        // With n=1, centering leaves all zeros, so SVD may fail or return trivial result
1131        // Just ensure no panic
1132        let _ = result;
1133    }
1134
1135    #[test]
1136    fn test_constant_y_pls() {
1137        let n = 10;
1138        let m = 20;
1139        let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1140        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1141        let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1142        let y = vec![5.0; n]; // Constant response
1143        let result = fdata_to_pls_1d(&data, &y, 2, &t);
1144        // Constant y → centering makes y all zeros, PLS may fail
1145        // Just ensure no panic
1146        let _ = result;
1147    }
1148
1149    // ============== FpcaResult::project tests ==============
1150
1151    #[test]
1152    fn test_fpca_project_shape() {
1153        let n = 20;
1154        let m = 30;
1155        let ncomp = 3;
1156        let (data, t) = generate_test_fdata(n, m);
1157        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1158
1159        let new_data = FdMatrix::zeros(5, m);
1160        let scores = fpca.project(&new_data).unwrap();
1161        assert_eq!(scores.shape(), (5, ncomp));
1162    }
1163
1164    #[test]
1165    fn test_fpca_project_reproduces_training_scores() {
1166        let n = 20;
1167        let m = 30;
1168        let ncomp = 3;
1169        let (data, t) = generate_test_fdata(n, m);
1170        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1171
1172        // Projecting the training data should reproduce the original scores
1173        let scores = fpca.project(&data).unwrap();
1174        for i in 0..n {
1175            for k in 0..ncomp {
1176                assert!(
1177                    (scores[(i, k)] - fpca.scores[(i, k)]).abs() < 1e-8,
1178                    "Score mismatch at ({}, {}): {} vs {}",
1179                    i,
1180                    k,
1181                    scores[(i, k)],
1182                    fpca.scores[(i, k)]
1183                );
1184            }
1185        }
1186    }
1187
1188    #[test]
1189    fn test_fpca_project_dimension_mismatch() {
1190        let (data, t) = generate_test_fdata(20, 30);
1191        let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1192
1193        let wrong_m = FdMatrix::zeros(5, 20); // wrong number of columns
1194        assert!(fpca.project(&wrong_m).is_err());
1195    }
1196
1197    // ============== FpcaResult::reconstruct tests ==============
1198
1199    #[test]
1200    fn test_fpca_reconstruct_shape() {
1201        let n = 10;
1202        let m = 30;
1203        let ncomp = 5;
1204        let (data, t) = generate_test_fdata(n, m);
1205        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1206
1207        let recon = fpca.reconstruct(&fpca.scores, 3).unwrap();
1208        assert_eq!(recon.shape(), (n, m));
1209    }
1210
1211    #[test]
1212    fn test_fpca_reconstruct_full_matches_original() {
1213        let n = 10;
1214        let m = 30;
1215        let ncomp = n.min(m);
1216        let (data, t) = generate_test_fdata(n, m);
1217        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1218
1219        // Full reconstruction should recover original data
1220        let recon = fpca.reconstruct(&fpca.scores, ncomp).unwrap();
1221        for i in 0..n {
1222            for j in 0..m {
1223                assert!(
1224                    (recon[(i, j)] - data[(i, j)]).abs() < 0.1,
1225                    "Reconstruction error at ({}, {}): {} vs {}",
1226                    i,
1227                    j,
1228                    recon[(i, j)],
1229                    data[(i, j)]
1230                );
1231            }
1232        }
1233    }
1234
1235    #[test]
1236    fn test_fpca_reconstruct_fewer_components() {
1237        let n = 20;
1238        let m = 30;
1239        let ncomp = 5;
1240        let (data, t) = generate_test_fdata(n, m);
1241        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1242
1243        let recon2 = fpca.reconstruct(&fpca.scores, 2).unwrap();
1244        let recon5 = fpca.reconstruct(&fpca.scores, 5).unwrap();
1245        assert_eq!(recon2.shape(), (n, m));
1246        assert_eq!(recon5.shape(), (n, m));
1247    }
1248
1249    #[test]
1250    fn test_fpca_reconstruct_invalid_ncomp() {
1251        let (data, t) = generate_test_fdata(10, 30);
1252        let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1253
1254        // Zero components
1255        assert!(fpca.reconstruct(&fpca.scores, 0).is_err());
1256        // More components than available
1257        assert!(fpca.reconstruct(&fpca.scores, 10).is_err());
1258    }
1259
1260    // ============== PlsResult::project tests ==============
1261
1262    #[test]
1263    fn test_pls_project_shape() {
1264        let n = 20;
1265        let m = 30;
1266        let ncomp = 3;
1267        let (x, t) = generate_test_fdata(n, m);
1268        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1269        let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1270
1271        let new_x = FdMatrix::zeros(5, m);
1272        let scores = pls.project(&new_x).unwrap();
1273        assert_eq!(scores.shape(), (5, ncomp));
1274    }
1275
1276    #[test]
1277    fn test_pls_project_reproduces_training_scores() {
1278        let n = 20;
1279        let m = 30;
1280        let ncomp = 3;
1281        let (x, t) = generate_test_fdata(n, m);
1282        let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
1283        let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1284
1285        // Projecting the training data should reproduce the original scores
1286        let scores = pls.project(&x).unwrap();
1287        for i in 0..n {
1288            for k in 0..ncomp {
1289                assert!(
1290                    (scores[(i, k)] - pls.scores[(i, k)]).abs() < 1e-8,
1291                    "Score mismatch at ({}, {}): {} vs {}",
1292                    i,
1293                    k,
1294                    scores[(i, k)],
1295                    pls.scores[(i, k)]
1296                );
1297            }
1298        }
1299    }
1300
1301    #[test]
1302    fn test_pls_project_dimension_mismatch() {
1303        let (x, t) = generate_test_fdata(20, 30);
1304        let y: Vec<f64> = (0..20).map(|i| i as f64).collect();
1305        let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1306
1307        let wrong_m = FdMatrix::zeros(5, 20); // wrong number of columns
1308        assert!(pls.project(&wrong_m).is_err());
1309    }
1310
1311    #[test]
1312    fn test_pls_x_means_stored() {
1313        let n = 20;
1314        let m = 30;
1315        let (x, t) = generate_test_fdata(n, m);
1316        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1317        let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1318
1319        // x_means should be stored and have correct length
1320        assert_eq!(pls.x_means.len(), m);
1321    }
1322
1323    // ============== Regression tests for issue #22 ==============
1324
1325    /// Regression test: projection of original data recovers original scores.
1326    #[test]
1327    fn fpca_project_recovers_original_scores() {
1328        let n = 15;
1329        let m = 40;
1330        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1331        let vals: Vec<f64> = (0..n)
1332            .flat_map(|i| {
1333                argvals
1334                    .iter()
1335                    .map(move |&t| (2.0 * PI * t).sin() + 0.3 * i as f64 * t)
1336            })
1337            .collect();
1338        let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1339        let fpca = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1340
1341        // project the training data — should match original scores
1342        let projected = fpca.project(&data).unwrap();
1343        for i in 0..n {
1344            for k in 0..3 {
1345                let diff = (fpca.scores[(i, k)] - projected[(i, k)]).abs();
1346                assert!(
1347                    diff < 1e-8,
1348                    "project score [{i},{k}] mismatch: orig={:.6}, proj={:.6}",
1349                    fpca.scores[(i, k)],
1350                    projected[(i, k)]
1351                );
1352            }
1353        }
1354    }
1355
1356    /// Regression test: weights are stored and have correct properties.
1357    #[test]
1358    fn fpca_weights_are_stored() {
1359        let m = 50;
1360        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1361        let data =
1362            FdMatrix::from_column_major((0..150).map(|i| (i as f64 * 0.1).sin()).collect(), 3, m)
1363                .unwrap();
1364        let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
1365
1366        // Weights should exist and be positive
1367        assert_eq!(fpca.weights.len(), m);
1368        assert!(fpca.weights.iter().all(|&w| w > 0.0));
1369
1370        // Weights should sum to approximately the domain length (1.0 for [0,1])
1371        let sum: f64 = fpca.weights.iter().sum();
1372        assert!(
1373            (sum - 1.0).abs() < 0.01,
1374            "weight sum should ≈ 1.0, got {sum}"
1375        );
1376    }
1377
1378    /// Regression test: variance explained is consistent across grid densities.
1379    #[test]
1380    fn fpca_variance_explained_grid_invariant() {
1381        use rand::rngs::StdRng;
1382        use rand::{Rng, SeedableRng};
1383
1384        let n = 20;
1385        let mut rng = StdRng::seed_from_u64(99);
1386        let coeffs: Vec<(f64, f64)> = (0..n)
1387            .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1388            .collect();
1389
1390        let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1391            let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1392            let mut vals = vec![0.0; n * m];
1393            for (i, &(a, b)) in coeffs.iter().enumerate() {
1394                for (j, &tj) in t.iter().enumerate() {
1395                    vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1396                }
1397            }
1398            (FdMatrix::from_column_major(vals, n, m).unwrap(), t)
1399        };
1400
1401        let (d1, t1) = make_data(41);
1402        let (d2, t2) = make_data(201);
1403        let f1 = fdata_to_pc_1d(&d1, 2, &t1).unwrap();
1404        let f2 = fdata_to_pc_1d(&d2, 2, &t2).unwrap();
1405
1406        let total1: f64 = f1.singular_values.iter().map(|s| s * s).sum();
1407        let total2: f64 = f2.singular_values.iter().map(|s| s * s).sum();
1408        let pve1 = f1.singular_values[0].powi(2) / total1;
1409        let pve2 = f2.singular_values[0].powi(2) / total2;
1410
1411        assert!(
1412            (pve1 - pve2).abs() < 0.05,
1413            "variance explained differs: coarse={pve1:.4}, fine={pve2:.4}"
1414        );
1415    }
1416
1417    #[test]
1418    fn fpca_scores_invariant_to_grid_density() {
1419        use rand::rngs::StdRng;
1420        use rand::{Rng, SeedableRng};
1421
1422        let n = 20;
1423
1424        // Generate random coefficients once
1425        let mut rng = StdRng::seed_from_u64(42);
1426        let coeffs: Vec<(f64, f64)> = (0..n)
1427            .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1428            .collect();
1429
1430        // Helper: generate data on a given grid from the same analytic expression
1431        let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1432            let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1433            let mut vals = vec![0.0; n * m];
1434            for (i, &(a, b)) in coeffs.iter().enumerate() {
1435                for (j, &tj) in t.iter().enumerate() {
1436                    vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1437                }
1438            }
1439            let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1440            (data, t)
1441        };
1442
1443        let (data1, t1) = make_data(51);
1444        let (data2, t2) = make_data(201);
1445
1446        let fpca1 = fdata_to_pc_1d(&data1, 2, &t1).unwrap();
1447        let fpca2 = fdata_to_pc_1d(&data2, 2, &t2).unwrap();
1448
1449        // Scores should be approximately the same (allow sign flip per component)
1450        for k in 0..2 {
1451            // Determine sign: use the sign of the dot product between score vectors
1452            let dot: f64 = (0..n)
1453                .map(|i| fpca1.scores[(i, k)] * fpca2.scores[(i, k)])
1454                .sum();
1455            let sign = if dot >= 0.0 { 1.0 } else { -1.0 };
1456
1457            for i in 0..n {
1458                let s1 = fpca1.scores[(i, k)];
1459                let s2 = sign * fpca2.scores[(i, k)];
1460                let rel_diff = if s1.abs() > 1e-6 {
1461                    (s1 - s2).abs() / s1.abs()
1462                } else {
1463                    (s1 - s2).abs()
1464                };
1465                assert!(
1466                    rel_diff < 0.10,
1467                    "score [{i},{k}] differs: coarse={s1:.4}, fine={s2:.4}, rel_diff={rel_diff:.4}"
1468                );
1469            }
1470        }
1471    }
1472}