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, PartialEq)]
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, PartialEq)]
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, PartialEq)]
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) -> Result<VertFpcaResult, crate::FdarError> {
91    let (n, m) = karcher.aligned_data.shape();
92    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
93        return Err(crate::FdarError::InvalidDimension {
94            parameter: "aligned_data/argvals",
95            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
96            actual: format!(
97                "n={}, m={}, ncomp={}, argvals.len()={}",
98                n,
99                m,
100                ncomp,
101                argvals.len()
102            ),
103        });
104    }
105    let ncomp = ncomp.min(n - 1).min(m);
106    let m_aug = m + 1;
107
108    let qn = match &karcher.aligned_srsfs {
109        Some(srsfs) => srsfs.clone(),
110        None => srsf_transform(&karcher.aligned_data, argvals),
111    };
112
113    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
114
115    // Covariance matrix K (m_aug × m_aug) and SVD
116    let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
117    let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
118
119    let svd = SVD::new(k_mat, true, true);
120    let u_cov = svd
121        .u
122        .as_ref()
123        .ok_or_else(|| crate::FdarError::ComputationFailed {
124            operation: "SVD",
125            detail: "SVD failed to compute U matrix".to_string(),
126        })?;
127
128    let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
129    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
130
131    // Eigenfunctions = columns of U from svd(K)
132    let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
133    for k in 0..ncomp {
134        for j in 0..m_aug {
135            eigenfunctions_q[(k, j)] = u_cov[(j, k)];
136        }
137    }
138
139    // Scores: project centered data onto eigenvectors
140    let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
141
142    // Convert eigenfunctions to function domain via srsf_inverse
143    let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
144    for k in 0..ncomp {
145        let q_k: Vec<f64> = (0..m)
146            .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
147            .collect();
148        let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
149        let f0 = aug_val.signum() * aug_val * aug_val;
150        let f_k = srsf_inverse(&q_k, argvals, f0);
151        for j in 0..m {
152            eigenfunctions_f[(k, j)] = f_k[j];
153        }
154    }
155
156    Ok(VertFpcaResult {
157        scores,
158        eigenfunctions_q,
159        eigenfunctions_f,
160        eigenvalues,
161        cumulative_variance,
162        mean_q,
163    })
164}
165
166// ─── Horizontal FPCA ────────────────────────────────────────────────────────
167
168/// Perform horizontal (phase) FPCA on warping functions.
169///
170/// 1. Convert warps to ψ space (Hilbert sphere)
171/// 2. Compute Karcher mean on sphere via iterative exp/log maps
172/// 3. Compute shooting vectors (log map at mean)
173/// 4. PCA on shooting vectors
174///
175/// # Arguments
176/// * `karcher` — Pre-computed Karcher mean result
177/// * `argvals` — Evaluation points (length m)
178/// * `ncomp` — Number of principal components
179pub fn horiz_fpca(
180    karcher: &KarcherMeanResult,
181    argvals: &[f64],
182    ncomp: usize,
183) -> Result<HorizFpcaResult, crate::FdarError> {
184    let (n, m) = karcher.gammas.shape();
185    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
186        return Err(crate::FdarError::InvalidDimension {
187            parameter: "gammas/argvals",
188            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
189            actual: format!(
190                "n={}, m={}, ncomp={}, argvals.len()={}",
191                n,
192                m,
193                ncomp,
194                argvals.len()
195            ),
196        });
197    }
198    let ncomp = ncomp.min(n - 1).min(m);
199
200    let t0 = argvals[0];
201    let domain = argvals[m - 1] - t0;
202    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
203
204    let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
205    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
206    let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
207
208    // PCA on shooting vectors (tangent space → standard PCA)
209    let (centered, _mean_v) = center_matrix(&shooting, n, m);
210
211    let svd = SVD::new(centered.to_dmatrix(), true, true);
212    let v_t = svd
213        .v_t
214        .as_ref()
215        .ok_or_else(|| crate::FdarError::ComputationFailed {
216            operation: "SVD",
217            detail: "SVD failed to compute V^T matrix".to_string(),
218        })?;
219    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
220        crate::FdarError::ComputationFailed {
221            operation: "SVD",
222            detail: "SVD failed to compute scores".to_string(),
223        }
224    })?;
225    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
226
227    // Eigenfunctions in ψ space
228    let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
229    for k in 0..ncomp {
230        for j in 0..m {
231            eigenfunctions_psi[(k, j)] = v_t[(k, j)];
232        }
233    }
234
235    // Convert eigenfunctions to warping functions
236    let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
237    for k in 0..ncomp {
238        let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
239        let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
240        let gam_k = psi_to_gam(&psi_k, &time);
241        for j in 0..m {
242            eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
243        }
244    }
245
246    Ok(HorizFpcaResult {
247        scores,
248        eigenfunctions_psi,
249        eigenfunctions_gam,
250        eigenvalues,
251        cumulative_variance,
252        mean_psi: mu_psi,
253        shooting_vectors: shooting,
254    })
255}
256
257// ─── Joint FPCA ─────────────────────────────────────────────────────────────
258
259/// Perform joint (amplitude + phase) FPCA.
260///
261/// Concatenates augmented SRSFs and scaled shooting vectors, then does PCA
262/// on the combined representation.
263///
264/// # Arguments
265/// * `karcher` — Pre-computed Karcher mean result
266/// * `argvals` — Evaluation points (length m)
267/// * `ncomp` — Number of principal components
268/// * `balance_c` — Weight for phase component (if None, optimized via golden section)
269pub fn joint_fpca(
270    karcher: &KarcherMeanResult,
271    argvals: &[f64],
272    ncomp: usize,
273    balance_c: Option<f64>,
274) -> Result<JointFpcaResult, crate::FdarError> {
275    let (n, m) = karcher.aligned_data.shape();
276    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
277        return Err(crate::FdarError::InvalidDimension {
278            parameter: "aligned_data/argvals",
279            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
280            actual: format!(
281                "n={}, m={}, ncomp={}, argvals.len()={}",
282                n,
283                m,
284                ncomp,
285                argvals.len()
286            ),
287        });
288    }
289
290    let _vert = vert_fpca(karcher, argvals, ncomp)?;
291    let horiz = horiz_fpca(karcher, argvals, ncomp)?;
292
293    let m_aug = m + 1;
294    let ncomp = ncomp.min(n - 1);
295
296    let qn = match &karcher.aligned_srsfs {
297        Some(srsfs) => srsfs.clone(),
298        None => srsf_transform(&karcher.aligned_data, argvals),
299    };
300    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
301    let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
302
303    let shooting = &horiz.shooting_vectors;
304    let c = match balance_c {
305        Some(c) => c,
306        None => optimize_balance_c(karcher, argvals, &q_centered, shooting, ncomp),
307    };
308
309    // Concatenate: g_i = [qn_aug_centered_i; C * v_i]
310    let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
311
312    let svd = SVD::new(combined.to_dmatrix(), true, true);
313    let v_t = svd
314        .v_t
315        .as_ref()
316        .ok_or_else(|| crate::FdarError::ComputationFailed {
317            operation: "SVD",
318            detail: "SVD failed to compute V^T matrix".to_string(),
319        })?;
320    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
321        crate::FdarError::ComputationFailed {
322            operation: "SVD",
323            detail: "SVD failed to compute scores".to_string(),
324        }
325    })?;
326    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
327
328    // Split eigenvectors into amplitude and phase parts
329    let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
330
331    Ok(JointFpcaResult {
332        scores,
333        eigenvalues,
334        cumulative_variance,
335        balance_c: c,
336        vert_component,
337        horiz_component,
338    })
339}
340
341// ─── Shared Helpers ────────────────────────────────────────────────────────
342
343/// Compute cumulative proportion of variance explained from eigenvalues.
344fn cumulative_variance_from_eigenvalues(eigenvalues: &[f64]) -> Vec<f64> {
345    let total_var: f64 = eigenvalues.iter().sum();
346    let mut cum = Vec::with_capacity(eigenvalues.len());
347    let mut running = 0.0;
348    for ev in eigenvalues {
349        running += ev;
350        cum.push(if total_var > 0.0 {
351            running / total_var
352        } else {
353            0.0
354        });
355    }
356    cum
357}
358
359/// Convert warping functions to normalized ψ vectors on the Hilbert sphere.
360pub(crate) fn warps_to_normalized_psi(gammas: &FdMatrix, argvals: &[f64]) -> Vec<Vec<f64>> {
361    let (n, m) = gammas.shape();
362    let t0 = argvals[0];
363    let domain = argvals[m - 1] - t0;
364    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
365    let binsize = 1.0 / (m - 1) as f64;
366
367    (0..n)
368        .map(|i| {
369            let gam_01: Vec<f64> = (0..m).map(|j| (gammas[(i, j)] - t0) / domain).collect();
370            let mut grad = vec![0.0; m];
371            grad[0] = (gam_01[1] - gam_01[0]) / binsize;
372            for j in 1..m - 1 {
373                grad[j] = (gam_01[j + 1] - gam_01[j - 1]) / (2.0 * binsize);
374            }
375            grad[m - 1] = (gam_01[m - 1] - gam_01[m - 2]) / binsize;
376            let mut psi: Vec<f64> = grad.iter().map(|&g| g.max(0.0).sqrt()).collect();
377            let norm = l2_norm_l2(&psi, &time);
378            if norm > 1e-10 {
379                for v in &mut psi {
380                    *v /= norm;
381                }
382            }
383            psi
384        })
385        .collect()
386}
387
388/// Compute Karcher mean on the Hilbert sphere via iterative exp/log maps.
389pub(crate) fn sphere_karcher_mean(psis: &[Vec<f64>], time: &[f64], max_iter: usize) -> Vec<f64> {
390    let n = psis.len();
391    let m = psis[0].len();
392
393    // Initial mean: normalized arithmetic mean
394    let mut mu_psi = vec![0.0; m];
395    for psi in psis {
396        for j in 0..m {
397            mu_psi[j] += psi[j];
398        }
399    }
400    for j in 0..m {
401        mu_psi[j] /= n as f64;
402    }
403    normalize_to_sphere(&mut mu_psi, time);
404
405    // Iterative refinement
406    for _ in 0..max_iter {
407        let mean_v = mean_tangent_vector(psis, &mu_psi, time);
408        let step_norm = l2_norm_l2(&mean_v, time);
409        if step_norm < 1e-8 {
410            break;
411        }
412        mu_psi = exp_map_sphere(&mu_psi, &mean_v, time);
413        normalize_to_sphere(&mut mu_psi, time);
414    }
415
416    mu_psi
417}
418
419/// Compute shooting vectors v_i = log_μ(ψ_i) from ψ vectors and Karcher mean.
420pub(crate) fn shooting_vectors_from_psis(
421    psis: &[Vec<f64>],
422    mu_psi: &[f64],
423    time: &[f64],
424) -> FdMatrix {
425    let n = psis.len();
426    let m = psis[0].len();
427    let mut shooting = FdMatrix::zeros(n, m);
428    for i in 0..n {
429        let v = inv_exp_map_sphere(mu_psi, &psis[i], time);
430        for j in 0..m {
431            shooting[(i, j)] = v[j];
432        }
433    }
434    shooting
435}
436
437/// Build augmented SRSF matrix: original SRSFs + sign(f(id))*sqrt(|f(id)|) column.
438pub(crate) fn build_augmented_srsfs(
439    qn: &FdMatrix,
440    aligned_data: &FdMatrix,
441    n: usize,
442    m: usize,
443) -> FdMatrix {
444    let id = m / 2;
445    let m_aug = m + 1;
446    let mut q_aug = FdMatrix::zeros(n, m_aug);
447    for i in 0..n {
448        for j in 0..m {
449            q_aug[(i, j)] = qn[(i, j)];
450        }
451        let fid = aligned_data[(i, id)];
452        q_aug[(i, m)] = fid.signum() * fid.abs().sqrt();
453    }
454    q_aug
455}
456
457/// Center a matrix and return the mean vector.
458pub(crate) fn center_matrix(mat: &FdMatrix, n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
459    let mut mean = vec![0.0; m];
460    for j in 0..m {
461        for i in 0..n {
462            mean[j] += mat[(i, j)];
463        }
464        mean[j] /= n as f64;
465    }
466    let mut centered = FdMatrix::zeros(n, m);
467    for i in 0..n {
468        for j in 0..m {
469            centered[(i, j)] = mat[(i, j)] - mean[j];
470        }
471    }
472    (centered, mean)
473}
474
475/// Extract eigenvalues and scores from SVD of centered data.
476fn svd_scores_and_eigenvalues(
477    svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
478    ncomp: usize,
479    n: usize,
480) -> Option<(FdMatrix, Vec<f64>)> {
481    let u = svd.u.as_ref()?;
482    let eigenvalues: Vec<f64> = svd
483        .singular_values
484        .iter()
485        .take(ncomp)
486        .map(|&s| s * s / (n - 1) as f64)
487        .collect();
488    let mut scores = FdMatrix::zeros(n, ncomp);
489    for k in 0..ncomp {
490        let sv = svd.singular_values[k];
491        for i in 0..n {
492            scores[(i, k)] = u[(i, k)] * sv;
493        }
494    }
495    Some((scores, eigenvalues))
496}
497
498/// Split joint eigenvectors into vertical (amplitude) and horizontal (phase) components.
499fn split_joint_eigenvectors(
500    v_t: &nalgebra::DMatrix<f64>,
501    ncomp: usize,
502    m_aug: usize,
503    m: usize,
504) -> (FdMatrix, FdMatrix) {
505    let mut vert_component = FdMatrix::zeros(ncomp, m_aug);
506    let mut horiz_component = FdMatrix::zeros(ncomp, m);
507    for k in 0..ncomp {
508        for j in 0..m_aug {
509            vert_component[(k, j)] = v_t[(k, j)];
510        }
511        for j in 0..m {
512            horiz_component[(k, j)] = v_t[(k, m_aug + j)];
513        }
514    }
515    (vert_component, horiz_component)
516}
517
518/// Build symmetric covariance matrix K (d × d) from data and mean.
519fn build_symmetric_covariance(
520    data: &FdMatrix,
521    mean: &[f64],
522    n: usize,
523    d: usize,
524) -> nalgebra::DMatrix<f64> {
525    let nf = (n - 1) as f64;
526    let mut k_mat = nalgebra::DMatrix::zeros(d, d);
527    for i in 0..n {
528        for p in 0..d {
529            let dp = data[(i, p)] - mean[p];
530            for q in p..d {
531                k_mat[(p, q)] += dp * (data[(i, q)] - mean[q]);
532            }
533        }
534    }
535    for p in 0..d {
536        k_mat[(p, p)] /= nf;
537        for q in (p + 1)..d {
538            k_mat[(p, q)] /= nf;
539            k_mat[(q, p)] = k_mat[(p, q)];
540        }
541    }
542    k_mat
543}
544
545/// Project centered data onto covariance eigenvectors to get scores.
546fn project_onto_eigenvectors(
547    data: &FdMatrix,
548    mean: &[f64],
549    u_cov: &nalgebra::DMatrix<f64>,
550    n: usize,
551    d: usize,
552    ncomp: usize,
553) -> FdMatrix {
554    let mut scores = FdMatrix::zeros(n, ncomp);
555    for k in 0..ncomp {
556        for i in 0..n {
557            let mut s = 0.0;
558            for j in 0..d {
559                s += (data[(i, j)] - mean[j]) * u_cov[(j, k)];
560            }
561            scores[(i, k)] = s;
562        }
563    }
564    scores
565}
566
567/// Normalize a vector to unit L2 norm on sphere. Returns whether normalization happened.
568fn normalize_to_sphere(mu: &mut [f64], time: &[f64]) {
569    let norm = l2_norm_l2(mu, time);
570    if norm > 1e-10 {
571        for v in mu.iter_mut() {
572            *v /= norm;
573        }
574    }
575}
576
577/// Compute mean tangent vector on sphere from ψ vectors at current mean.
578fn mean_tangent_vector(psis: &[Vec<f64>], mu_psi: &[f64], time: &[f64]) -> Vec<f64> {
579    let n = psis.len();
580    let m = mu_psi.len();
581    let mut mean_v = vec![0.0; m];
582    for psi in psis {
583        let v = inv_exp_map_sphere(mu_psi, psi, time);
584        for j in 0..m {
585            mean_v[j] += v[j];
586        }
587    }
588    for j in 0..m {
589        mean_v[j] /= n as f64;
590    }
591    mean_v
592}
593
594/// Build combined representation: [q_centered | c * shooting] for joint FPCA.
595fn build_combined_representation(
596    q_centered: &FdMatrix,
597    shooting: &FdMatrix,
598    c: f64,
599    n: usize,
600    m_aug: usize,
601    m: usize,
602) -> FdMatrix {
603    let combined_dim = m_aug + m;
604    let mut combined = FdMatrix::zeros(n, combined_dim);
605    for i in 0..n {
606        for j in 0..m_aug {
607            combined[(i, j)] = q_centered[(i, j)];
608        }
609        for j in 0..m {
610            combined[(i, m_aug + j)] = c * shooting[(i, j)];
611        }
612    }
613    combined
614}
615
616/// Optimize the balance parameter C via golden section search.
617///
618/// Minimizes reconstruction error of the joint representation.
619fn optimize_balance_c(
620    _karcher: &KarcherMeanResult,
621    _argvals: &[f64],
622    q_centered: &FdMatrix,
623    shooting: &FdMatrix,
624    ncomp: usize,
625) -> f64 {
626    let (n, m) = shooting.shape();
627    let m_aug = q_centered.ncols();
628    let combined_dim = m_aug + m;
629
630    let golden_ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
631    let mut a = 0.0_f64;
632    let mut b = 10.0_f64;
633
634    let eval_c = |c: f64| -> f64 {
635        let mut combined = FdMatrix::zeros(n, combined_dim);
636        for i in 0..n {
637            for j in 0..m_aug {
638                combined[(i, j)] = q_centered[(i, j)];
639            }
640            for j in 0..m {
641                combined[(i, m_aug + j)] = c * shooting[(i, j)];
642            }
643        }
644
645        let svd = SVD::new(combined.to_dmatrix(), true, true);
646        if let (Some(_u), Some(_v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
647            let nc = ncomp.min(svd.singular_values.len());
648            // Reconstruction error = total variance - explained variance
649            let total_var: f64 = svd.singular_values.iter().map(|&s| s * s).sum();
650            let explained: f64 = svd.singular_values.iter().take(nc).map(|&s| s * s).sum();
651            total_var - explained
652        } else {
653            f64::INFINITY
654        }
655    };
656
657    for _ in 0..20 {
658        let c1 = b - golden_ratio * (b - a);
659        let c2 = a + golden_ratio * (b - a);
660        if eval_c(c1) < eval_c(c2) {
661            b = c2;
662        } else {
663            a = c1;
664        }
665    }
666
667    (a + b) / 2.0
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use crate::alignment::karcher_mean;
674    use std::f64::consts::PI;
675
676    fn generate_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
677        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
678        let mut data = FdMatrix::zeros(n, m);
679        for i in 0..n {
680            let shift = 0.1 * (i as f64 - n as f64 / 2.0);
681            let scale = 1.0 + 0.2 * (i as f64 / n as f64);
682            for j in 0..m {
683                data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
684            }
685        }
686        (data, t)
687    }
688
689    #[test]
690    fn test_vert_fpca_basic() {
691        let (data, t) = generate_test_data(15, 51);
692        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
693        let result = vert_fpca(&km, &t, 3);
694        assert!(result.is_ok(), "vert_fpca should succeed");
695
696        let res = result.unwrap();
697        assert_eq!(res.scores.shape(), (15, 3));
698        assert_eq!(res.eigenvalues.len(), 3);
699        assert_eq!(res.eigenfunctions_q.shape(), (3, 52)); // m+1
700        assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
701
702        // Eigenvalues should be non-negative and decreasing
703        for ev in &res.eigenvalues {
704            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
705        }
706        for i in 1..res.eigenvalues.len() {
707            assert!(
708                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
709                "Eigenvalues should be decreasing"
710            );
711        }
712
713        // Cumulative variance should be increasing and <= 1
714        for i in 1..res.cumulative_variance.len() {
715            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
716        }
717        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
718    }
719
720    #[test]
721    fn test_horiz_fpca_basic() {
722        let (data, t) = generate_test_data(15, 51);
723        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
724        let result = horiz_fpca(&km, &t, 3);
725        assert!(result.is_ok(), "horiz_fpca should succeed");
726
727        let res = result.unwrap();
728        assert_eq!(res.scores.shape(), (15, 3));
729        assert_eq!(res.eigenvalues.len(), 3);
730        assert_eq!(res.eigenfunctions_psi.shape(), (3, 51));
731        assert_eq!(res.shooting_vectors.shape(), (15, 51));
732    }
733
734    #[test]
735    fn test_joint_fpca_basic() {
736        let (data, t) = generate_test_data(15, 51);
737        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
738        let result = joint_fpca(&km, &t, 3, Some(1.0));
739        assert!(result.is_ok(), "joint_fpca should succeed");
740
741        let res = result.unwrap();
742        assert_eq!(res.scores.shape(), (15, 3));
743        assert_eq!(res.eigenvalues.len(), 3);
744        assert!(res.balance_c >= 0.0);
745    }
746
747    #[test]
748    fn test_joint_fpca_optimize_c() {
749        let (data, t) = generate_test_data(15, 51);
750        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
751        let result = joint_fpca(&km, &t, 3, None);
752        assert!(
753            result.is_ok(),
754            "joint_fpca with C optimization should succeed"
755        );
756    }
757
758    #[test]
759    fn test_vert_fpca_invalid_input() {
760        let data = FdMatrix::zeros(1, 10); // n < 2
761        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
762        let km = KarcherMeanResult {
763            mean: vec![0.0; 10],
764            mean_srsf: vec![0.0; 10],
765            gammas: FdMatrix::zeros(1, 10),
766            aligned_data: data,
767            n_iter: 0,
768            converged: true,
769            aligned_srsfs: None,
770        };
771        assert!(vert_fpca(&km, &t, 3).is_err());
772    }
773
774    #[test]
775    fn test_horiz_fpca_eigenvalue_properties() {
776        let (data, t) = generate_test_data(15, 51);
777        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
778        let res = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
779
780        // Eigenvalues non-negative
781        for ev in &res.eigenvalues {
782            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
783        }
784        // Eigenvalues decreasing
785        for i in 1..res.eigenvalues.len() {
786            assert!(
787                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
788                "Eigenvalues should be decreasing"
789            );
790        }
791        // Cumulative variance increasing and <= 1
792        for i in 1..res.cumulative_variance.len() {
793            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
794        }
795        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
796    }
797
798    #[test]
799    fn test_joint_fpca_eigenvalue_properties() {
800        let (data, t) = generate_test_data(15, 51);
801        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
802        let res = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
803
804        // Eigenvalues non-negative
805        for ev in &res.eigenvalues {
806            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
807        }
808        // Eigenvalues decreasing
809        for i in 1..res.eigenvalues.len() {
810            assert!(
811                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
812                "Eigenvalues should be decreasing"
813            );
814        }
815        // Cumulative variance increasing and <= 1
816        for i in 1..res.cumulative_variance.len() {
817            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
818        }
819        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
820    }
821
822    #[test]
823    fn test_vert_fpca_ncomp_sensitivity() -> Result<(), crate::error::FdarError> {
824        let (data, t) = generate_test_data(15, 51);
825        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
826
827        for &ncomp in &[1, 2, 5, 10] {
828            let res = vert_fpca(&km, &t, ncomp)?;
829            assert_eq!(res.scores.shape(), (15, ncomp));
830            assert_eq!(res.eigenvalues.len(), ncomp);
831            assert_eq!(res.eigenfunctions_q.shape(), (ncomp, 52));
832            assert_eq!(res.eigenfunctions_f.shape(), (ncomp, 51));
833            assert_eq!(res.cumulative_variance.len(), ncomp);
834        }
835        Ok(())
836    }
837
838    #[test]
839    fn test_vert_fpca_score_orthogonality() {
840        let (data, t) = generate_test_data(15, 51);
841        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
842        let res = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
843
844        let n = 15;
845        // Check approximate orthogonality of score vectors
846        for k1 in 0..3 {
847            for k2 in (k1 + 1)..3 {
848                let dot: f64 = (0..n)
849                    .map(|i| res.scores[(i, k1)] * res.scores[(i, k2)])
850                    .sum();
851                let norm1: f64 = (0..n)
852                    .map(|i| res.scores[(i, k1)].powi(2))
853                    .sum::<f64>()
854                    .sqrt();
855                let norm2: f64 = (0..n)
856                    .map(|i| res.scores[(i, k2)].powi(2))
857                    .sum::<f64>()
858                    .sqrt();
859                if norm1 > 1e-10 && norm2 > 1e-10 {
860                    let cos_angle = (dot / (norm1 * norm2)).abs();
861                    assert!(
862                        cos_angle < 0.15,
863                        "Score components {} and {} should be approximately orthogonal, cos={}",
864                        k1,
865                        k2,
866                        cos_angle
867                    );
868                }
869            }
870        }
871    }
872}