Skip to main content

fdars_core/
elastic_fpca.rs

1//! Vertical, horizontal, and joint FPCA for elastic functional data.
2//!
3//! These FPCA variants decompose amplitude vs phase variability after elastic
4//! alignment. They correspond to `vert.fpca`, `horiz.fpca`, and `jointFPCA`
5//! from the R fdasrvf package.
6//!
7//! Key capabilities:
8//! - [`vert_fpca`] — Amplitude FPCA in augmented SRSF space
9//! - [`horiz_fpca`] — Phase FPCA via shooting vectors on the Hilbert sphere
10//! - [`joint_fpca`] — Combined amplitude + phase FPCA
11
12use crate::alignment::{srsf_inverse, srsf_transform, KarcherMeanResult};
13
14use crate::matrix::FdMatrix;
15use crate::warping::{exp_map_sphere, inv_exp_map_sphere, l2_norm_l2, psi_to_gam};
16use nalgebra::SVD;
17
18// ─── Types ──────────────────────────────────────────────────────────────────
19
20/// Result of vertical (amplitude) FPCA.
21#[derive(Debug, Clone)]
22pub struct VertFpcaResult {
23    /// PC scores (n × ncomp).
24    pub scores: FdMatrix,
25    /// Eigenfunctions in augmented SRSF space (ncomp × (m+1)).
26    pub eigenfunctions_q: FdMatrix,
27    /// Eigenfunctions in function space (ncomp × m).
28    pub eigenfunctions_f: FdMatrix,
29    /// Eigenvalues (variance explained).
30    pub eigenvalues: Vec<f64>,
31    /// Cumulative proportion of variance explained.
32    pub cumulative_variance: Vec<f64>,
33    /// Augmented mean SRSF (length m+1).
34    pub mean_q: Vec<f64>,
35}
36
37/// Result of horizontal (phase) FPCA.
38#[derive(Debug, Clone)]
39pub struct HorizFpcaResult {
40    /// PC scores (n × ncomp).
41    pub scores: FdMatrix,
42    /// Eigenfunctions in ψ space (ncomp × m).
43    pub eigenfunctions_psi: FdMatrix,
44    /// Eigenfunctions as warping functions (ncomp × m).
45    pub eigenfunctions_gam: FdMatrix,
46    /// Eigenvalues.
47    pub eigenvalues: Vec<f64>,
48    /// Cumulative proportion of variance explained.
49    pub cumulative_variance: Vec<f64>,
50    /// Mean ψ on the sphere (length m).
51    pub mean_psi: Vec<f64>,
52    /// Shooting vectors (n × m).
53    pub shooting_vectors: FdMatrix,
54}
55
56/// Result of joint (amplitude + phase) FPCA.
57#[derive(Debug, Clone)]
58pub struct JointFpcaResult {
59    /// PC scores (n × ncomp).
60    pub scores: FdMatrix,
61    /// Eigenvalues.
62    pub eigenvalues: Vec<f64>,
63    /// Cumulative proportion of variance explained.
64    pub cumulative_variance: Vec<f64>,
65    /// Phase-vs-amplitude balance weight.
66    pub balance_c: f64,
67    /// Vertical (amplitude) component of eigenvectors (ncomp × (m+1)).
68    pub vert_component: FdMatrix,
69    /// Horizontal (phase) component of eigenvectors (ncomp × m).
70    pub horiz_component: FdMatrix,
71}
72
73// ─── Vertical FPCA ──────────────────────────────────────────────────────────
74
75/// Perform vertical (amplitude) FPCA on elastically aligned curves.
76///
77/// 1. Compute SRSFs of aligned curves
78/// 2. Augment with `sign(f_i(t0)) * sqrt(|f_i(t0)|)` as extra dimension
79/// 3. Center, compute covariance, SVD
80/// 4. Project onto eigenvectors and convert back to function space
81///
82/// # Arguments
83/// * `karcher` — Pre-computed Karcher mean result (with aligned data and gammas)
84/// * `argvals` — Evaluation points (length m)
85/// * `ncomp` — Number of principal components to extract
86pub fn vert_fpca(
87    karcher: &KarcherMeanResult,
88    argvals: &[f64],
89    ncomp: usize,
90) -> Option<VertFpcaResult> {
91    let (n, m) = karcher.aligned_data.shape();
92    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
93        return None;
94    }
95    let ncomp = ncomp.min(n - 1).min(m);
96    let m_aug = m + 1;
97
98    let qn = match &karcher.aligned_srsfs {
99        Some(srsfs) => srsfs.clone(),
100        None => srsf_transform(&karcher.aligned_data, argvals),
101    };
102
103    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
104
105    // Covariance matrix K (m_aug × m_aug) and SVD
106    let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
107    let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
108
109    let svd = SVD::new(k_mat, true, true);
110    let u_cov = svd.u.as_ref()?;
111
112    let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
113    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
114
115    // Eigenfunctions = columns of U from svd(K)
116    let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
117    for k in 0..ncomp {
118        for j in 0..m_aug {
119            eigenfunctions_q[(k, j)] = u_cov[(j, k)];
120        }
121    }
122
123    // Scores: project centered data onto eigenvectors
124    let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
125
126    // Convert eigenfunctions to function domain via srsf_inverse
127    let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
128    for k in 0..ncomp {
129        let q_k: Vec<f64> = (0..m)
130            .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
131            .collect();
132        let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
133        let f0 = aug_val.signum() * aug_val * aug_val;
134        let f_k = srsf_inverse(&q_k, argvals, f0);
135        for j in 0..m {
136            eigenfunctions_f[(k, j)] = f_k[j];
137        }
138    }
139
140    Some(VertFpcaResult {
141        scores,
142        eigenfunctions_q,
143        eigenfunctions_f,
144        eigenvalues,
145        cumulative_variance,
146        mean_q,
147    })
148}
149
150// ─── Horizontal FPCA ────────────────────────────────────────────────────────
151
152/// Perform horizontal (phase) FPCA on warping functions.
153///
154/// 1. Convert warps to ψ space (Hilbert sphere)
155/// 2. Compute Karcher mean on sphere via iterative exp/log maps
156/// 3. Compute shooting vectors (log map at mean)
157/// 4. PCA on shooting vectors
158///
159/// # Arguments
160/// * `karcher` — Pre-computed Karcher mean result
161/// * `argvals` — Evaluation points (length m)
162/// * `ncomp` — Number of principal components
163pub fn horiz_fpca(
164    karcher: &KarcherMeanResult,
165    argvals: &[f64],
166    ncomp: usize,
167) -> Option<HorizFpcaResult> {
168    let (n, m) = karcher.gammas.shape();
169    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
170        return None;
171    }
172    let ncomp = ncomp.min(n - 1).min(m);
173
174    let t0 = argvals[0];
175    let domain = argvals[m - 1] - t0;
176    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
177
178    let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
179    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
180    let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
181
182    // PCA on shooting vectors (tangent space → standard PCA)
183    let (centered, _mean_v) = center_matrix(&shooting, n, m);
184
185    let svd = SVD::new(centered.to_dmatrix(), true, true);
186    let v_t = svd.v_t.as_ref()?;
187    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n)?;
188    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
189
190    // Eigenfunctions in ψ space
191    let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
192    for k in 0..ncomp {
193        for j in 0..m {
194            eigenfunctions_psi[(k, j)] = v_t[(k, j)];
195        }
196    }
197
198    // Convert eigenfunctions to warping functions
199    let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
200    for k in 0..ncomp {
201        let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
202        let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
203        let gam_k = psi_to_gam(&psi_k, &time);
204        for j in 0..m {
205            eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
206        }
207    }
208
209    Some(HorizFpcaResult {
210        scores,
211        eigenfunctions_psi,
212        eigenfunctions_gam,
213        eigenvalues,
214        cumulative_variance,
215        mean_psi: mu_psi,
216        shooting_vectors: shooting,
217    })
218}
219
220// ─── Joint FPCA ─────────────────────────────────────────────────────────────
221
222/// Perform joint (amplitude + phase) FPCA.
223///
224/// Concatenates augmented SRSFs and scaled shooting vectors, then does PCA
225/// on the combined representation.
226///
227/// # Arguments
228/// * `karcher` — Pre-computed Karcher mean result
229/// * `argvals` — Evaluation points (length m)
230/// * `ncomp` — Number of principal components
231/// * `balance_c` — Weight for phase component (if None, optimized via golden section)
232pub fn joint_fpca(
233    karcher: &KarcherMeanResult,
234    argvals: &[f64],
235    ncomp: usize,
236    balance_c: Option<f64>,
237) -> Option<JointFpcaResult> {
238    let (n, m) = karcher.aligned_data.shape();
239    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
240        return None;
241    }
242
243    let _vert = vert_fpca(karcher, argvals, ncomp)?;
244    let horiz = horiz_fpca(karcher, argvals, ncomp)?;
245
246    let m_aug = m + 1;
247    let ncomp = ncomp.min(n - 1);
248
249    let qn = match &karcher.aligned_srsfs {
250        Some(srsfs) => srsfs.clone(),
251        None => srsf_transform(&karcher.aligned_data, argvals),
252    };
253    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
254    let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
255
256    let shooting = &horiz.shooting_vectors;
257    let c = match balance_c {
258        Some(c) => c,
259        None => optimize_balance_c(karcher, argvals, &q_centered, shooting, ncomp),
260    };
261
262    // Concatenate: g_i = [qn_aug_centered_i; C * v_i]
263    let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
264
265    let svd = SVD::new(combined.to_dmatrix(), true, true);
266    let v_t = svd.v_t.as_ref()?;
267    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n)?;
268    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
269
270    // Split eigenvectors into amplitude and phase parts
271    let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
272
273    Some(JointFpcaResult {
274        scores,
275        eigenvalues,
276        cumulative_variance,
277        balance_c: c,
278        vert_component,
279        horiz_component,
280    })
281}
282
283// ─── Shared Helpers ────────────────────────────────────────────────────────
284
285/// Compute cumulative proportion of variance explained from eigenvalues.
286fn cumulative_variance_from_eigenvalues(eigenvalues: &[f64]) -> Vec<f64> {
287    let total_var: f64 = eigenvalues.iter().sum();
288    let mut cum = Vec::with_capacity(eigenvalues.len());
289    let mut running = 0.0;
290    for ev in eigenvalues {
291        running += ev;
292        cum.push(if total_var > 0.0 {
293            running / total_var
294        } else {
295            0.0
296        });
297    }
298    cum
299}
300
301/// Convert warping functions to normalized ψ vectors on the Hilbert sphere.
302pub(crate) fn warps_to_normalized_psi(gammas: &FdMatrix, argvals: &[f64]) -> Vec<Vec<f64>> {
303    let (n, m) = gammas.shape();
304    let t0 = argvals[0];
305    let domain = argvals[m - 1] - t0;
306    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
307    let binsize = 1.0 / (m - 1) as f64;
308
309    (0..n)
310        .map(|i| {
311            let gam_01: Vec<f64> = (0..m).map(|j| (gammas[(i, j)] - t0) / domain).collect();
312            let mut grad = vec![0.0; m];
313            grad[0] = (gam_01[1] - gam_01[0]) / binsize;
314            for j in 1..m - 1 {
315                grad[j] = (gam_01[j + 1] - gam_01[j - 1]) / (2.0 * binsize);
316            }
317            grad[m - 1] = (gam_01[m - 1] - gam_01[m - 2]) / binsize;
318            let mut psi: Vec<f64> = grad.iter().map(|&g| g.max(0.0).sqrt()).collect();
319            let norm = l2_norm_l2(&psi, &time);
320            if norm > 1e-10 {
321                for v in &mut psi {
322                    *v /= norm;
323                }
324            }
325            psi
326        })
327        .collect()
328}
329
330/// Compute Karcher mean on the Hilbert sphere via iterative exp/log maps.
331pub(crate) fn sphere_karcher_mean(psis: &[Vec<f64>], time: &[f64], max_iter: usize) -> Vec<f64> {
332    let n = psis.len();
333    let m = psis[0].len();
334
335    // Initial mean: normalized arithmetic mean
336    let mut mu_psi = vec![0.0; m];
337    for psi in psis {
338        for j in 0..m {
339            mu_psi[j] += psi[j];
340        }
341    }
342    for j in 0..m {
343        mu_psi[j] /= n as f64;
344    }
345    normalize_to_sphere(&mut mu_psi, time);
346
347    // Iterative refinement
348    for _ in 0..max_iter {
349        let mean_v = mean_tangent_vector(psis, &mu_psi, time);
350        let step_norm = l2_norm_l2(&mean_v, time);
351        if step_norm < 1e-8 {
352            break;
353        }
354        mu_psi = exp_map_sphere(&mu_psi, &mean_v, time);
355        normalize_to_sphere(&mut mu_psi, time);
356    }
357
358    mu_psi
359}
360
361/// Compute shooting vectors v_i = log_μ(ψ_i) from ψ vectors and Karcher mean.
362pub(crate) fn shooting_vectors_from_psis(
363    psis: &[Vec<f64>],
364    mu_psi: &[f64],
365    time: &[f64],
366) -> FdMatrix {
367    let n = psis.len();
368    let m = psis[0].len();
369    let mut shooting = FdMatrix::zeros(n, m);
370    for i in 0..n {
371        let v = inv_exp_map_sphere(mu_psi, &psis[i], time);
372        for j in 0..m {
373            shooting[(i, j)] = v[j];
374        }
375    }
376    shooting
377}
378
379/// Build augmented SRSF matrix: original SRSFs + sign(f(id))*sqrt(|f(id)|) column.
380pub(crate) fn build_augmented_srsfs(
381    qn: &FdMatrix,
382    aligned_data: &FdMatrix,
383    n: usize,
384    m: usize,
385) -> FdMatrix {
386    let id = m / 2;
387    let m_aug = m + 1;
388    let mut q_aug = FdMatrix::zeros(n, m_aug);
389    for i in 0..n {
390        for j in 0..m {
391            q_aug[(i, j)] = qn[(i, j)];
392        }
393        let fid = aligned_data[(i, id)];
394        q_aug[(i, m)] = fid.signum() * fid.abs().sqrt();
395    }
396    q_aug
397}
398
399/// Center a matrix and return the mean vector.
400pub(crate) fn center_matrix(mat: &FdMatrix, n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
401    let mut mean = vec![0.0; m];
402    for j in 0..m {
403        for i in 0..n {
404            mean[j] += mat[(i, j)];
405        }
406        mean[j] /= n as f64;
407    }
408    let mut centered = FdMatrix::zeros(n, m);
409    for i in 0..n {
410        for j in 0..m {
411            centered[(i, j)] = mat[(i, j)] - mean[j];
412        }
413    }
414    (centered, mean)
415}
416
417/// Extract eigenvalues and scores from SVD of centered data.
418fn svd_scores_and_eigenvalues(
419    svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
420    ncomp: usize,
421    n: usize,
422) -> Option<(FdMatrix, Vec<f64>)> {
423    let u = svd.u.as_ref()?;
424    let eigenvalues: Vec<f64> = svd
425        .singular_values
426        .iter()
427        .take(ncomp)
428        .map(|&s| s * s / (n - 1) as f64)
429        .collect();
430    let mut scores = FdMatrix::zeros(n, ncomp);
431    for k in 0..ncomp {
432        let sv = svd.singular_values[k];
433        for i in 0..n {
434            scores[(i, k)] = u[(i, k)] * sv;
435        }
436    }
437    Some((scores, eigenvalues))
438}
439
440/// Split joint eigenvectors into vertical (amplitude) and horizontal (phase) components.
441fn split_joint_eigenvectors(
442    v_t: &nalgebra::DMatrix<f64>,
443    ncomp: usize,
444    m_aug: usize,
445    m: usize,
446) -> (FdMatrix, FdMatrix) {
447    let mut vert_component = FdMatrix::zeros(ncomp, m_aug);
448    let mut horiz_component = FdMatrix::zeros(ncomp, m);
449    for k in 0..ncomp {
450        for j in 0..m_aug {
451            vert_component[(k, j)] = v_t[(k, j)];
452        }
453        for j in 0..m {
454            horiz_component[(k, j)] = v_t[(k, m_aug + j)];
455        }
456    }
457    (vert_component, horiz_component)
458}
459
460/// Build symmetric covariance matrix K (d × d) from data and mean.
461fn build_symmetric_covariance(
462    data: &FdMatrix,
463    mean: &[f64],
464    n: usize,
465    d: usize,
466) -> nalgebra::DMatrix<f64> {
467    let nf = (n - 1) as f64;
468    let mut k_mat = nalgebra::DMatrix::zeros(d, d);
469    for i in 0..n {
470        for p in 0..d {
471            let dp = data[(i, p)] - mean[p];
472            for q in p..d {
473                k_mat[(p, q)] += dp * (data[(i, q)] - mean[q]);
474            }
475        }
476    }
477    for p in 0..d {
478        k_mat[(p, p)] /= nf;
479        for q in (p + 1)..d {
480            k_mat[(p, q)] /= nf;
481            k_mat[(q, p)] = k_mat[(p, q)];
482        }
483    }
484    k_mat
485}
486
487/// Project centered data onto covariance eigenvectors to get scores.
488fn project_onto_eigenvectors(
489    data: &FdMatrix,
490    mean: &[f64],
491    u_cov: &nalgebra::DMatrix<f64>,
492    n: usize,
493    d: usize,
494    ncomp: usize,
495) -> FdMatrix {
496    let mut scores = FdMatrix::zeros(n, ncomp);
497    for k in 0..ncomp {
498        for i in 0..n {
499            let mut s = 0.0;
500            for j in 0..d {
501                s += (data[(i, j)] - mean[j]) * u_cov[(j, k)];
502            }
503            scores[(i, k)] = s;
504        }
505    }
506    scores
507}
508
509/// Normalize a vector to unit L2 norm on sphere. Returns whether normalization happened.
510fn normalize_to_sphere(mu: &mut [f64], time: &[f64]) {
511    let norm = l2_norm_l2(mu, time);
512    if norm > 1e-10 {
513        for v in mu.iter_mut() {
514            *v /= norm;
515        }
516    }
517}
518
519/// Compute mean tangent vector on sphere from ψ vectors at current mean.
520fn mean_tangent_vector(psis: &[Vec<f64>], mu_psi: &[f64], time: &[f64]) -> Vec<f64> {
521    let n = psis.len();
522    let m = mu_psi.len();
523    let mut mean_v = vec![0.0; m];
524    for psi in psis {
525        let v = inv_exp_map_sphere(mu_psi, psi, time);
526        for j in 0..m {
527            mean_v[j] += v[j];
528        }
529    }
530    for j in 0..m {
531        mean_v[j] /= n as f64;
532    }
533    mean_v
534}
535
536/// Build combined representation: [q_centered | c * shooting] for joint FPCA.
537fn build_combined_representation(
538    q_centered: &FdMatrix,
539    shooting: &FdMatrix,
540    c: f64,
541    n: usize,
542    m_aug: usize,
543    m: usize,
544) -> FdMatrix {
545    let combined_dim = m_aug + m;
546    let mut combined = FdMatrix::zeros(n, combined_dim);
547    for i in 0..n {
548        for j in 0..m_aug {
549            combined[(i, j)] = q_centered[(i, j)];
550        }
551        for j in 0..m {
552            combined[(i, m_aug + j)] = c * shooting[(i, j)];
553        }
554    }
555    combined
556}
557
558/// Optimize the balance parameter C via golden section search.
559///
560/// Minimizes reconstruction error of the joint representation.
561fn optimize_balance_c(
562    _karcher: &KarcherMeanResult,
563    _argvals: &[f64],
564    q_centered: &FdMatrix,
565    shooting: &FdMatrix,
566    ncomp: usize,
567) -> f64 {
568    let (n, m) = shooting.shape();
569    let m_aug = q_centered.ncols();
570    let combined_dim = m_aug + m;
571
572    let golden_ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
573    let mut a = 0.0_f64;
574    let mut b = 10.0_f64;
575
576    let eval_c = |c: f64| -> f64 {
577        let mut combined = FdMatrix::zeros(n, combined_dim);
578        for i in 0..n {
579            for j in 0..m_aug {
580                combined[(i, j)] = q_centered[(i, j)];
581            }
582            for j in 0..m {
583                combined[(i, m_aug + j)] = c * shooting[(i, j)];
584            }
585        }
586
587        let svd = SVD::new(combined.to_dmatrix(), true, true);
588        if let (Some(_u), Some(_v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
589            let nc = ncomp.min(svd.singular_values.len());
590            // Reconstruction error = total variance - explained variance
591            let total_var: f64 = svd.singular_values.iter().map(|&s| s * s).sum();
592            let explained: f64 = svd.singular_values.iter().take(nc).map(|&s| s * s).sum();
593            total_var - explained
594        } else {
595            f64::INFINITY
596        }
597    };
598
599    for _ in 0..20 {
600        let c1 = b - golden_ratio * (b - a);
601        let c2 = a + golden_ratio * (b - a);
602        if eval_c(c1) < eval_c(c2) {
603            b = c2;
604        } else {
605            a = c1;
606        }
607    }
608
609    (a + b) / 2.0
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::alignment::karcher_mean;
616    use std::f64::consts::PI;
617
618    fn generate_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
619        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
620        let mut data = FdMatrix::zeros(n, m);
621        for i in 0..n {
622            let shift = 0.1 * (i as f64 - n as f64 / 2.0);
623            let scale = 1.0 + 0.2 * (i as f64 / n as f64);
624            for j in 0..m {
625                data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
626            }
627        }
628        (data, t)
629    }
630
631    #[test]
632    fn test_vert_fpca_basic() {
633        let (data, t) = generate_test_data(15, 51);
634        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
635        let result = vert_fpca(&km, &t, 3);
636        assert!(result.is_some(), "vert_fpca should succeed");
637
638        let res = result.unwrap();
639        assert_eq!(res.scores.shape(), (15, 3));
640        assert_eq!(res.eigenvalues.len(), 3);
641        assert_eq!(res.eigenfunctions_q.shape(), (3, 52)); // m+1
642        assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
643
644        // Eigenvalues should be non-negative and decreasing
645        for ev in &res.eigenvalues {
646            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
647        }
648        for i in 1..res.eigenvalues.len() {
649            assert!(
650                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
651                "Eigenvalues should be decreasing"
652            );
653        }
654
655        // Cumulative variance should be increasing and <= 1
656        for i in 1..res.cumulative_variance.len() {
657            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
658        }
659        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
660    }
661
662    #[test]
663    fn test_horiz_fpca_basic() {
664        let (data, t) = generate_test_data(15, 51);
665        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
666        let result = horiz_fpca(&km, &t, 3);
667        assert!(result.is_some(), "horiz_fpca should succeed");
668
669        let res = result.unwrap();
670        assert_eq!(res.scores.shape(), (15, 3));
671        assert_eq!(res.eigenvalues.len(), 3);
672        assert_eq!(res.eigenfunctions_psi.shape(), (3, 51));
673        assert_eq!(res.shooting_vectors.shape(), (15, 51));
674    }
675
676    #[test]
677    fn test_joint_fpca_basic() {
678        let (data, t) = generate_test_data(15, 51);
679        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
680        let result = joint_fpca(&km, &t, 3, Some(1.0));
681        assert!(result.is_some(), "joint_fpca should succeed");
682
683        let res = result.unwrap();
684        assert_eq!(res.scores.shape(), (15, 3));
685        assert_eq!(res.eigenvalues.len(), 3);
686        assert!(res.balance_c >= 0.0);
687    }
688
689    #[test]
690    fn test_joint_fpca_optimize_c() {
691        let (data, t) = generate_test_data(15, 51);
692        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
693        let result = joint_fpca(&km, &t, 3, None);
694        assert!(
695            result.is_some(),
696            "joint_fpca with C optimization should succeed"
697        );
698    }
699
700    #[test]
701    fn test_vert_fpca_invalid_input() {
702        let data = FdMatrix::zeros(1, 10); // n < 2
703        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
704        let km = KarcherMeanResult {
705            mean: vec![0.0; 10],
706            mean_srsf: vec![0.0; 10],
707            gammas: FdMatrix::zeros(1, 10),
708            aligned_data: data,
709            n_iter: 0,
710            converged: true,
711            aligned_srsfs: None,
712        };
713        assert!(vert_fpca(&km, &t, 3).is_none());
714    }
715}