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)]
22#[non_exhaustive]
23pub struct VertFpcaResult {
24    /// PC scores (n × ncomp).
25    pub scores: FdMatrix,
26    /// Eigenfunctions in augmented SRSF space (ncomp × (m+1)).
27    pub eigenfunctions_q: FdMatrix,
28    /// Eigenfunctions in function space (ncomp × m).
29    pub eigenfunctions_f: FdMatrix,
30    /// Eigenvalues (variance explained).
31    pub eigenvalues: Vec<f64>,
32    /// Cumulative proportion of variance explained.
33    pub cumulative_variance: Vec<f64>,
34    /// Augmented mean SRSF (length m+1).
35    pub mean_q: Vec<f64>,
36}
37
38/// Result of horizontal (phase) FPCA.
39#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub struct HorizFpcaResult {
42    /// PC scores (n × ncomp).
43    pub scores: FdMatrix,
44    /// Eigenfunctions in ψ space (ncomp × m).
45    pub eigenfunctions_psi: FdMatrix,
46    /// Eigenfunctions as warping functions (ncomp × m).
47    pub eigenfunctions_gam: FdMatrix,
48    /// Eigenvalues.
49    pub eigenvalues: Vec<f64>,
50    /// Cumulative proportion of variance explained.
51    pub cumulative_variance: Vec<f64>,
52    /// Mean ψ on the sphere (length m).
53    pub mean_psi: Vec<f64>,
54    /// Shooting vectors (n × m).
55    pub shooting_vectors: FdMatrix,
56}
57
58/// Result of joint (amplitude + phase) FPCA.
59#[derive(Debug, Clone, PartialEq)]
60#[non_exhaustive]
61pub struct JointFpcaResult {
62    /// PC scores (n × ncomp).
63    pub scores: FdMatrix,
64    /// Eigenvalues.
65    pub eigenvalues: Vec<f64>,
66    /// Cumulative proportion of variance explained.
67    pub cumulative_variance: Vec<f64>,
68    /// Phase-vs-amplitude balance weight.
69    pub balance_c: f64,
70    /// Vertical (amplitude) component of eigenvectors (ncomp × (m+1)).
71    pub vert_component: FdMatrix,
72    /// Horizontal (phase) component of eigenvectors (ncomp × m).
73    pub horiz_component: FdMatrix,
74}
75
76// ─── Vertical FPCA ──────────────────────────────────────────────────────────
77
78/// Perform vertical (amplitude) FPCA on elastically aligned curves.
79///
80/// 1. Compute SRSFs of aligned curves
81/// 2. Augment with `sign(f_i(t0)) * sqrt(|f_i(t0)|)` as extra dimension
82/// 3. Center, compute covariance, SVD
83/// 4. Project onto eigenvectors and convert back to function space
84///
85/// # Arguments
86/// * `karcher` — Pre-computed Karcher mean result (with aligned data and gammas)
87/// * `argvals` — Evaluation points (length m)
88/// * `ncomp` — Number of principal components to extract
89pub fn vert_fpca(
90    karcher: &KarcherMeanResult,
91    argvals: &[f64],
92    ncomp: usize,
93) -> Result<VertFpcaResult, crate::FdarError> {
94    let (n, m) = karcher.aligned_data.shape();
95    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
96        return Err(crate::FdarError::InvalidDimension {
97            parameter: "aligned_data/argvals",
98            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
99            actual: format!(
100                "n={}, m={}, ncomp={}, argvals.len()={}",
101                n,
102                m,
103                ncomp,
104                argvals.len()
105            ),
106        });
107    }
108    let ncomp = ncomp.min(n - 1).min(m);
109    let m_aug = m + 1;
110
111    let qn = match &karcher.aligned_srsfs {
112        Some(srsfs) => srsfs.clone(),
113        None => srsf_transform(&karcher.aligned_data, argvals),
114    };
115
116    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
117
118    // Covariance matrix K (m_aug × m_aug) and SVD
119    let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
120    let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
121
122    let svd = SVD::new(k_mat, true, true);
123    let u_cov = svd
124        .u
125        .as_ref()
126        .ok_or_else(|| crate::FdarError::ComputationFailed {
127            operation: "SVD",
128            detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
129        })?;
130
131    let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
132    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
133
134    // Eigenfunctions = columns of U from svd(K)
135    let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
136    for k in 0..ncomp {
137        for j in 0..m_aug {
138            eigenfunctions_q[(k, j)] = u_cov[(j, k)];
139        }
140    }
141
142    // Scores: project centered data onto eigenvectors
143    let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
144
145    // Convert eigenfunctions to function domain via srsf_inverse
146    let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
147    for k in 0..ncomp {
148        let q_k: Vec<f64> = (0..m)
149            .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
150            .collect();
151        let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
152        let f0 = aug_val.signum() * aug_val * aug_val;
153        let f_k = srsf_inverse(&q_k, argvals, f0);
154        for j in 0..m {
155            eigenfunctions_f[(k, j)] = f_k[j];
156        }
157    }
158
159    Ok(VertFpcaResult {
160        scores,
161        eigenfunctions_q,
162        eigenfunctions_f,
163        eigenvalues,
164        cumulative_variance,
165        mean_q,
166    })
167}
168
169// ─── Horizontal FPCA ────────────────────────────────────────────────────────
170
171/// Perform horizontal (phase) FPCA on warping functions.
172///
173/// 1. Convert warps to ψ space (Hilbert sphere)
174/// 2. Compute Karcher mean on sphere via iterative exp/log maps
175/// 3. Compute shooting vectors (log map at mean)
176/// 4. PCA on shooting vectors
177///
178/// # Arguments
179/// * `karcher` — Pre-computed Karcher mean result
180/// * `argvals` — Evaluation points (length m)
181/// * `ncomp` — Number of principal components
182pub fn horiz_fpca(
183    karcher: &KarcherMeanResult,
184    argvals: &[f64],
185    ncomp: usize,
186) -> Result<HorizFpcaResult, crate::FdarError> {
187    let (n, m) = karcher.gammas.shape();
188    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
189        return Err(crate::FdarError::InvalidDimension {
190            parameter: "gammas/argvals",
191            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
192            actual: format!(
193                "n={}, m={}, ncomp={}, argvals.len()={}",
194                n,
195                m,
196                ncomp,
197                argvals.len()
198            ),
199        });
200    }
201    let ncomp = ncomp.min(n - 1).min(m);
202
203    let t0 = argvals[0];
204    let domain = argvals[m - 1] - t0;
205    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
206
207    let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
208    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
209    let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
210
211    // PCA on shooting vectors (tangent space → standard PCA)
212    let (centered, _mean_v) = center_matrix(&shooting, n, m);
213
214    let svd = SVD::new(centered.to_dmatrix(), true, true);
215    let v_t = svd
216        .v_t
217        .as_ref()
218        .ok_or_else(|| crate::FdarError::ComputationFailed {
219            operation: "SVD",
220            detail:
221                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
222                    .to_string(),
223        })?;
224    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
225        crate::FdarError::ComputationFailed {
226            operation: "SVD",
227            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
228        }
229    })?;
230    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
231
232    // Eigenfunctions in ψ space
233    let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
234    for k in 0..ncomp {
235        for j in 0..m {
236            eigenfunctions_psi[(k, j)] = v_t[(k, j)];
237        }
238    }
239
240    // Convert eigenfunctions to warping functions
241    let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
242    for k in 0..ncomp {
243        let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
244        let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
245        let gam_k = psi_to_gam(&psi_k, &time);
246        for j in 0..m {
247            eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
248        }
249    }
250
251    Ok(HorizFpcaResult {
252        scores,
253        eigenfunctions_psi,
254        eigenfunctions_gam,
255        eigenvalues,
256        cumulative_variance,
257        mean_psi: mu_psi,
258        shooting_vectors: shooting,
259    })
260}
261
262// ─── Joint FPCA ─────────────────────────────────────────────────────────────
263
264/// Perform joint (amplitude + phase) FPCA.
265///
266/// Concatenates augmented SRSFs and scaled shooting vectors, then does PCA
267/// on the combined representation.
268///
269/// # Arguments
270/// * `karcher` — Pre-computed Karcher mean result
271/// * `argvals` — Evaluation points (length m)
272/// * `ncomp` — Number of principal components
273/// * `balance_c` — Weight for phase component (if None, optimized via golden section)
274pub fn joint_fpca(
275    karcher: &KarcherMeanResult,
276    argvals: &[f64],
277    ncomp: usize,
278    balance_c: Option<f64>,
279) -> Result<JointFpcaResult, crate::FdarError> {
280    let (n, m) = karcher.aligned_data.shape();
281    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
282        return Err(crate::FdarError::InvalidDimension {
283            parameter: "aligned_data/argvals",
284            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
285            actual: format!(
286                "n={}, m={}, ncomp={}, argvals.len()={}",
287                n,
288                m,
289                ncomp,
290                argvals.len()
291            ),
292        });
293    }
294
295    let _vert = vert_fpca(karcher, argvals, ncomp)?;
296    let horiz = horiz_fpca(karcher, argvals, ncomp)?;
297
298    let m_aug = m + 1;
299    let ncomp = ncomp.min(n - 1);
300
301    let qn = match &karcher.aligned_srsfs {
302        Some(srsfs) => srsfs.clone(),
303        None => srsf_transform(&karcher.aligned_data, argvals),
304    };
305    let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
306    let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
307
308    let shooting = &horiz.shooting_vectors;
309    let c = match balance_c {
310        Some(c) => c,
311        None => optimize_balance_c(karcher, argvals, &q_centered, shooting, ncomp),
312    };
313
314    // Concatenate: g_i = [qn_aug_centered_i; C * v_i]
315    let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
316
317    let svd = SVD::new(combined.to_dmatrix(), true, true);
318    let v_t = svd
319        .v_t
320        .as_ref()
321        .ok_or_else(|| crate::FdarError::ComputationFailed {
322            operation: "SVD",
323            detail:
324                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
325                    .to_string(),
326        })?;
327    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
328        crate::FdarError::ComputationFailed {
329            operation: "SVD",
330            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
331        }
332    })?;
333    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
334
335    // Split eigenvectors into amplitude and phase parts
336    let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
337
338    Ok(JointFpcaResult {
339        scores,
340        eigenvalues,
341        cumulative_variance,
342        balance_c: c,
343        vert_component,
344        horiz_component,
345    })
346}
347
348// ─── From-alignment wrappers ───────────────────────────────────────────────
349//
350// These accept raw aligned-data fields instead of a full `KarcherMeanResult`,
351// making it possible to run elastic FPCA from an `AlignmentLayer` or any other
352// source that provides the same arrays.
353
354/// Vertical (amplitude) FPCA from pre-aligned curves and (optional) SRSFs.
355///
356/// This is equivalent to [`vert_fpca`] but does not require a
357/// [`KarcherMeanResult`].  Only the fields actually used by the algorithm are
358/// accepted as arguments.
359///
360/// # Arguments
361/// * `aligned_data` — Aligned curves (n × m).
362/// * `aligned_srsfs` — Pre-computed SRSFs of aligned curves (n × m).
363///   When `None`, SRSFs are recomputed from `aligned_data`.
364/// * `argvals` — Evaluation grid (length m).
365/// * `ncomp` — Number of principal components to extract.
366pub fn vert_fpca_from_alignment(
367    aligned_data: &FdMatrix,
368    aligned_srsfs: Option<&FdMatrix>,
369    argvals: &[f64],
370    ncomp: usize,
371) -> Result<VertFpcaResult, crate::FdarError> {
372    let (n, m) = aligned_data.shape();
373    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
374        return Err(crate::FdarError::InvalidDimension {
375            parameter: "aligned_data/argvals",
376            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
377            actual: format!(
378                "n={}, m={}, ncomp={}, argvals.len()={}",
379                n,
380                m,
381                ncomp,
382                argvals.len()
383            ),
384        });
385    }
386    let ncomp = ncomp.min(n - 1).min(m);
387    let m_aug = m + 1;
388
389    let qn = match aligned_srsfs {
390        Some(srsfs) => srsfs.clone(),
391        None => srsf_transform(aligned_data, argvals),
392    };
393
394    let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
395
396    let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
397    let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
398
399    let svd = SVD::new(k_mat, true, true);
400    let u_cov = svd
401        .u
402        .as_ref()
403        .ok_or_else(|| crate::FdarError::ComputationFailed {
404            operation: "SVD",
405            detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
406        })?;
407
408    let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
409    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
410
411    let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
412    for k in 0..ncomp {
413        for j in 0..m_aug {
414            eigenfunctions_q[(k, j)] = u_cov[(j, k)];
415        }
416    }
417
418    let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
419
420    let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
421    for k in 0..ncomp {
422        let q_k: Vec<f64> = (0..m)
423            .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
424            .collect();
425        let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
426        let f0 = aug_val.signum() * aug_val * aug_val;
427        let f_k = srsf_inverse(&q_k, argvals, f0);
428        for j in 0..m {
429            eigenfunctions_f[(k, j)] = f_k[j];
430        }
431    }
432
433    Ok(VertFpcaResult {
434        scores,
435        eigenfunctions_q,
436        eigenfunctions_f,
437        eigenvalues,
438        cumulative_variance,
439        mean_q,
440    })
441}
442
443/// Horizontal (phase) FPCA from warping functions.
444///
445/// This is equivalent to [`horiz_fpca`] but does not require a
446/// [`KarcherMeanResult`].  Only the warping functions are needed.
447///
448/// # Arguments
449/// * `gammas` — Warping functions (n × m).
450/// * `argvals` — Evaluation grid (length m).
451/// * `ncomp` — Number of principal components to extract.
452pub fn horiz_fpca_from_alignment(
453    gammas: &FdMatrix,
454    argvals: &[f64],
455    ncomp: usize,
456) -> Result<HorizFpcaResult, crate::FdarError> {
457    let (n, m) = gammas.shape();
458    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
459        return Err(crate::FdarError::InvalidDimension {
460            parameter: "gammas/argvals",
461            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
462            actual: format!(
463                "n={}, m={}, ncomp={}, argvals.len()={}",
464                n,
465                m,
466                ncomp,
467                argvals.len()
468            ),
469        });
470    }
471    let ncomp = ncomp.min(n - 1).min(m);
472
473    let t0 = argvals[0];
474    let domain = argvals[m - 1] - t0;
475    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
476
477    let psis = warps_to_normalized_psi(gammas, argvals);
478    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
479    let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
480
481    let (centered, _mean_v) = center_matrix(&shooting, n, m);
482
483    let svd = SVD::new(centered.to_dmatrix(), true, true);
484    let v_t = svd
485        .v_t
486        .as_ref()
487        .ok_or_else(|| crate::FdarError::ComputationFailed {
488            operation: "SVD",
489            detail:
490                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
491                    .to_string(),
492        })?;
493    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
494        crate::FdarError::ComputationFailed {
495            operation: "SVD",
496            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
497        }
498    })?;
499    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
500
501    let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
502    for k in 0..ncomp {
503        for j in 0..m {
504            eigenfunctions_psi[(k, j)] = v_t[(k, j)];
505        }
506    }
507
508    let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
509    for k in 0..ncomp {
510        let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
511        let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
512        let gam_k = psi_to_gam(&psi_k, &time);
513        for j in 0..m {
514            eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
515        }
516    }
517
518    Ok(HorizFpcaResult {
519        scores,
520        eigenfunctions_psi,
521        eigenfunctions_gam,
522        eigenvalues,
523        cumulative_variance,
524        mean_psi: mu_psi,
525        shooting_vectors: shooting,
526    })
527}
528
529/// Joint (amplitude + phase) FPCA from pre-aligned curves and warps.
530///
531/// This is equivalent to [`joint_fpca`] but does not require a
532/// [`KarcherMeanResult`].
533///
534/// # Arguments
535/// * `aligned_data` — Aligned curves (n × m).
536/// * `aligned_srsfs` — Pre-computed SRSFs of aligned curves (n × m), or `None`.
537/// * `gammas` — Warping functions (n × m).
538/// * `argvals` — Evaluation grid (length m).
539/// * `ncomp` — Number of principal components to extract.
540/// * `balance_c` — Weight for phase component (`None` ⇒ optimized automatically).
541pub fn joint_fpca_from_alignment(
542    aligned_data: &FdMatrix,
543    aligned_srsfs: Option<&FdMatrix>,
544    gammas: &FdMatrix,
545    argvals: &[f64],
546    ncomp: usize,
547    balance_c: Option<f64>,
548) -> Result<JointFpcaResult, crate::FdarError> {
549    let (n, m) = aligned_data.shape();
550    if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
551        return Err(crate::FdarError::InvalidDimension {
552            parameter: "aligned_data/argvals",
553            expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
554            actual: format!(
555                "n={}, m={}, ncomp={}, argvals.len()={}",
556                n,
557                m,
558                ncomp,
559                argvals.len()
560            ),
561        });
562    }
563
564    let horiz = horiz_fpca_from_alignment(gammas, argvals, ncomp)?;
565
566    let m_aug = m + 1;
567    let ncomp = ncomp.min(n - 1);
568
569    let qn = match aligned_srsfs {
570        Some(srsfs) => srsfs.clone(),
571        None => srsf_transform(aligned_data, argvals),
572    };
573    let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
574    let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
575
576    let shooting = &horiz.shooting_vectors;
577    let c = match balance_c {
578        Some(c) => c,
579        None => optimize_balance_c_raw(&q_centered, shooting, ncomp, m_aug, m),
580    };
581
582    let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
583
584    let svd = SVD::new(combined.to_dmatrix(), true, true);
585    let v_t = svd
586        .v_t
587        .as_ref()
588        .ok_or_else(|| crate::FdarError::ComputationFailed {
589            operation: "SVD",
590            detail:
591                "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
592                    .to_string(),
593        })?;
594    let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
595        crate::FdarError::ComputationFailed {
596            operation: "SVD",
597            detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
598        }
599    })?;
600    let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
601
602    let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
603
604    Ok(JointFpcaResult {
605        scores,
606        eigenvalues,
607        cumulative_variance,
608        balance_c: c,
609        vert_component,
610        horiz_component,
611    })
612}
613
614// ─── Shared Helpers ────────────────────────────────────────────────────────
615
616/// Compute cumulative proportion of variance explained from eigenvalues.
617fn cumulative_variance_from_eigenvalues(eigenvalues: &[f64]) -> Vec<f64> {
618    let total_var: f64 = eigenvalues.iter().sum();
619    let mut cum = Vec::with_capacity(eigenvalues.len());
620    let mut running = 0.0;
621    for ev in eigenvalues {
622        running += ev;
623        cum.push(if total_var > 0.0 {
624            running / total_var
625        } else {
626            0.0
627        });
628    }
629    cum
630}
631
632/// Convert warping functions to normalized ψ vectors on the Hilbert sphere.
633pub(crate) fn warps_to_normalized_psi(gammas: &FdMatrix, argvals: &[f64]) -> Vec<Vec<f64>> {
634    let (n, m) = gammas.shape();
635    let t0 = argvals[0];
636    let domain = argvals[m - 1] - t0;
637    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
638    let binsize = 1.0 / (m - 1) as f64;
639
640    (0..n)
641        .map(|i| {
642            let gam_01: Vec<f64> = (0..m).map(|j| (gammas[(i, j)] - t0) / domain).collect();
643            let mut grad = vec![0.0; m];
644            grad[0] = (gam_01[1] - gam_01[0]) / binsize;
645            for j in 1..m - 1 {
646                grad[j] = (gam_01[j + 1] - gam_01[j - 1]) / (2.0 * binsize);
647            }
648            grad[m - 1] = (gam_01[m - 1] - gam_01[m - 2]) / binsize;
649            let mut psi: Vec<f64> = grad.iter().map(|&g| g.max(0.0).sqrt()).collect();
650            let norm = l2_norm_l2(&psi, &time);
651            if norm > 1e-10 {
652                for v in &mut psi {
653                    *v /= norm;
654                }
655            }
656            psi
657        })
658        .collect()
659}
660
661/// Compute Karcher mean on the Hilbert sphere via iterative exp/log maps.
662pub(crate) fn sphere_karcher_mean(psis: &[Vec<f64>], time: &[f64], max_iter: usize) -> Vec<f64> {
663    let n = psis.len();
664    let m = psis[0].len();
665
666    // Initial mean: normalized arithmetic mean
667    let mut mu_psi = vec![0.0; m];
668    for psi in psis {
669        for j in 0..m {
670            mu_psi[j] += psi[j];
671        }
672    }
673    for j in 0..m {
674        mu_psi[j] /= n as f64;
675    }
676    normalize_to_sphere(&mut mu_psi, time);
677
678    // Iterative refinement
679    for _ in 0..max_iter {
680        let mean_v = mean_tangent_vector(psis, &mu_psi, time);
681        let step_norm = l2_norm_l2(&mean_v, time);
682        if step_norm < 1e-8 {
683            break;
684        }
685        mu_psi = exp_map_sphere(&mu_psi, &mean_v, time);
686        normalize_to_sphere(&mut mu_psi, time);
687    }
688
689    mu_psi
690}
691
692/// Compute shooting vectors v_i = log_μ(ψ_i) from ψ vectors and Karcher mean.
693pub(crate) fn shooting_vectors_from_psis(
694    psis: &[Vec<f64>],
695    mu_psi: &[f64],
696    time: &[f64],
697) -> FdMatrix {
698    let n = psis.len();
699    let m = psis[0].len();
700    let mut shooting = FdMatrix::zeros(n, m);
701    for i in 0..n {
702        let v = inv_exp_map_sphere(mu_psi, &psis[i], time);
703        for j in 0..m {
704            shooting[(i, j)] = v[j];
705        }
706    }
707    shooting
708}
709
710/// Build augmented SRSF matrix: original SRSFs + sign(f(id))*sqrt(|f(id)|) column.
711pub(crate) fn build_augmented_srsfs(
712    qn: &FdMatrix,
713    aligned_data: &FdMatrix,
714    n: usize,
715    m: usize,
716) -> FdMatrix {
717    let id = m / 2;
718    let m_aug = m + 1;
719    let mut q_aug = FdMatrix::zeros(n, m_aug);
720    for i in 0..n {
721        for j in 0..m {
722            q_aug[(i, j)] = qn[(i, j)];
723        }
724        let fid = aligned_data[(i, id)];
725        q_aug[(i, m)] = fid.signum() * fid.abs().sqrt();
726    }
727    q_aug
728}
729
730/// Center a matrix and return the mean vector.
731pub(crate) fn center_matrix(mat: &FdMatrix, n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
732    let mut mean = vec![0.0; m];
733    for j in 0..m {
734        for i in 0..n {
735            mean[j] += mat[(i, j)];
736        }
737        mean[j] /= n as f64;
738    }
739    let mut centered = FdMatrix::zeros(n, m);
740    for i in 0..n {
741        for j in 0..m {
742            centered[(i, j)] = mat[(i, j)] - mean[j];
743        }
744    }
745    (centered, mean)
746}
747
748/// Extract eigenvalues and scores from SVD of centered data.
749fn svd_scores_and_eigenvalues(
750    svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
751    ncomp: usize,
752    n: usize,
753) -> Option<(FdMatrix, Vec<f64>)> {
754    let u = svd.u.as_ref()?;
755    let eigenvalues: Vec<f64> = svd
756        .singular_values
757        .iter()
758        .take(ncomp)
759        .map(|&s| s * s / (n - 1) as f64)
760        .collect();
761    let mut scores = FdMatrix::zeros(n, ncomp);
762    for k in 0..ncomp {
763        let sv = svd.singular_values[k];
764        for i in 0..n {
765            scores[(i, k)] = u[(i, k)] * sv;
766        }
767    }
768    Some((scores, eigenvalues))
769}
770
771/// Split joint eigenvectors into vertical (amplitude) and horizontal (phase) components.
772fn split_joint_eigenvectors(
773    v_t: &nalgebra::DMatrix<f64>,
774    ncomp: usize,
775    m_aug: usize,
776    m: usize,
777) -> (FdMatrix, FdMatrix) {
778    let mut vert_component = FdMatrix::zeros(ncomp, m_aug);
779    let mut horiz_component = FdMatrix::zeros(ncomp, m);
780    for k in 0..ncomp {
781        for j in 0..m_aug {
782            vert_component[(k, j)] = v_t[(k, j)];
783        }
784        for j in 0..m {
785            horiz_component[(k, j)] = v_t[(k, m_aug + j)];
786        }
787    }
788    (vert_component, horiz_component)
789}
790
791/// Build symmetric covariance matrix K (d × d) from data and mean.
792fn build_symmetric_covariance(
793    data: &FdMatrix,
794    mean: &[f64],
795    n: usize,
796    d: usize,
797) -> nalgebra::DMatrix<f64> {
798    let nf = (n - 1) as f64;
799    let mut k_mat = nalgebra::DMatrix::zeros(d, d);
800    for i in 0..n {
801        for p in 0..d {
802            let dp = data[(i, p)] - mean[p];
803            for q in p..d {
804                k_mat[(p, q)] += dp * (data[(i, q)] - mean[q]);
805            }
806        }
807    }
808    for p in 0..d {
809        k_mat[(p, p)] /= nf;
810        for q in (p + 1)..d {
811            k_mat[(p, q)] /= nf;
812            k_mat[(q, p)] = k_mat[(p, q)];
813        }
814    }
815    k_mat
816}
817
818/// Project centered data onto covariance eigenvectors to get scores.
819fn project_onto_eigenvectors(
820    data: &FdMatrix,
821    mean: &[f64],
822    u_cov: &nalgebra::DMatrix<f64>,
823    n: usize,
824    d: usize,
825    ncomp: usize,
826) -> FdMatrix {
827    let mut scores = FdMatrix::zeros(n, ncomp);
828    for k in 0..ncomp {
829        for i in 0..n {
830            let mut s = 0.0;
831            for j in 0..d {
832                s += (data[(i, j)] - mean[j]) * u_cov[(j, k)];
833            }
834            scores[(i, k)] = s;
835        }
836    }
837    scores
838}
839
840/// Normalize a vector to unit L2 norm on sphere. Returns whether normalization happened.
841fn normalize_to_sphere(mu: &mut [f64], time: &[f64]) {
842    let norm = l2_norm_l2(mu, time);
843    if norm > 1e-10 {
844        for v in mu.iter_mut() {
845            *v /= norm;
846        }
847    }
848}
849
850/// Compute mean tangent vector on sphere from ψ vectors at current mean.
851fn mean_tangent_vector(psis: &[Vec<f64>], mu_psi: &[f64], time: &[f64]) -> Vec<f64> {
852    let n = psis.len();
853    let m = mu_psi.len();
854    let mut mean_v = vec![0.0; m];
855    for psi in psis {
856        let v = inv_exp_map_sphere(mu_psi, psi, time);
857        for j in 0..m {
858            mean_v[j] += v[j];
859        }
860    }
861    for j in 0..m {
862        mean_v[j] /= n as f64;
863    }
864    mean_v
865}
866
867/// Build combined representation: [q_centered | c * shooting] for joint FPCA.
868fn build_combined_representation(
869    q_centered: &FdMatrix,
870    shooting: &FdMatrix,
871    c: f64,
872    n: usize,
873    m_aug: usize,
874    m: usize,
875) -> FdMatrix {
876    let combined_dim = m_aug + m;
877    let mut combined = FdMatrix::zeros(n, combined_dim);
878    for i in 0..n {
879        for j in 0..m_aug {
880            combined[(i, j)] = q_centered[(i, j)];
881        }
882        for j in 0..m {
883            combined[(i, m_aug + j)] = c * shooting[(i, j)];
884        }
885    }
886    combined
887}
888
889/// Optimize the balance parameter C via golden section search.
890///
891/// Minimizes reconstruction error of the joint representation.
892fn optimize_balance_c(
893    _karcher: &KarcherMeanResult,
894    _argvals: &[f64],
895    q_centered: &FdMatrix,
896    shooting: &FdMatrix,
897    ncomp: usize,
898) -> f64 {
899    let m_aug = q_centered.ncols();
900    let m = shooting.ncols();
901    optimize_balance_c_raw(q_centered, shooting, ncomp, m_aug, m)
902}
903
904/// Core balance-C optimizer that does not depend on [`KarcherMeanResult`].
905fn optimize_balance_c_raw(
906    q_centered: &FdMatrix,
907    shooting: &FdMatrix,
908    ncomp: usize,
909    m_aug: usize,
910    m: usize,
911) -> f64 {
912    let n = shooting.nrows();
913    let combined_dim = m_aug + m;
914
915    let golden_ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
916    let mut a = 0.0_f64;
917    let mut b = 10.0_f64;
918
919    let eval_c = |c: f64| -> f64 {
920        let mut combined = FdMatrix::zeros(n, combined_dim);
921        for i in 0..n {
922            for j in 0..m_aug {
923                combined[(i, j)] = q_centered[(i, j)];
924            }
925            for j in 0..m {
926                combined[(i, m_aug + j)] = c * shooting[(i, j)];
927            }
928        }
929
930        let svd = SVD::new(combined.to_dmatrix(), true, true);
931        if let (Some(_u), Some(_v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
932            let nc = ncomp.min(svd.singular_values.len());
933            // Reconstruction error = total variance - explained variance
934            let total_var: f64 = svd.singular_values.iter().map(|&s| s * s).sum();
935            let explained: f64 = svd.singular_values.iter().take(nc).map(|&s| s * s).sum();
936            total_var - explained
937        } else {
938            f64::INFINITY
939        }
940    };
941
942    for _ in 0..20 {
943        let c1 = b - golden_ratio * (b - a);
944        let c2 = a + golden_ratio * (b - a);
945        if eval_c(c1) < eval_c(c2) {
946            b = c2;
947        } else {
948            a = c1;
949        }
950    }
951
952    (a + b) / 2.0
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use crate::alignment::karcher_mean;
959    use std::f64::consts::PI;
960
961    fn generate_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
962        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
963        let mut data = FdMatrix::zeros(n, m);
964        for i in 0..n {
965            let shift = 0.1 * (i as f64 - n as f64 / 2.0);
966            let scale = 1.0 + 0.2 * (i as f64 / n as f64);
967            for j in 0..m {
968                data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
969            }
970        }
971        (data, t)
972    }
973
974    #[test]
975    fn test_vert_fpca_basic() {
976        let (data, t) = generate_test_data(15, 51);
977        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
978        let result = vert_fpca(&km, &t, 3);
979        assert!(result.is_ok(), "vert_fpca should succeed");
980
981        let res = result.unwrap();
982        assert_eq!(res.scores.shape(), (15, 3));
983        assert_eq!(res.eigenvalues.len(), 3);
984        assert_eq!(res.eigenfunctions_q.shape(), (3, 52)); // m+1
985        assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
986
987        // Eigenvalues should be non-negative and decreasing
988        for ev in &res.eigenvalues {
989            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
990        }
991        for i in 1..res.eigenvalues.len() {
992            assert!(
993                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
994                "Eigenvalues should be decreasing"
995            );
996        }
997
998        // Cumulative variance should be increasing and <= 1
999        for i in 1..res.cumulative_variance.len() {
1000            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1001        }
1002        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1003    }
1004
1005    #[test]
1006    fn test_horiz_fpca_basic() {
1007        let (data, t) = generate_test_data(15, 51);
1008        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1009        let result = horiz_fpca(&km, &t, 3);
1010        assert!(result.is_ok(), "horiz_fpca should succeed");
1011
1012        let res = result.unwrap();
1013        assert_eq!(res.scores.shape(), (15, 3));
1014        assert_eq!(res.eigenvalues.len(), 3);
1015        assert_eq!(res.eigenfunctions_psi.shape(), (3, 51));
1016        assert_eq!(res.shooting_vectors.shape(), (15, 51));
1017    }
1018
1019    #[test]
1020    fn test_joint_fpca_basic() {
1021        let (data, t) = generate_test_data(15, 51);
1022        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1023        let result = joint_fpca(&km, &t, 3, Some(1.0));
1024        assert!(result.is_ok(), "joint_fpca should succeed");
1025
1026        let res = result.unwrap();
1027        assert_eq!(res.scores.shape(), (15, 3));
1028        assert_eq!(res.eigenvalues.len(), 3);
1029        assert!(res.balance_c >= 0.0);
1030    }
1031
1032    #[test]
1033    fn test_joint_fpca_optimize_c() {
1034        let (data, t) = generate_test_data(15, 51);
1035        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1036        let result = joint_fpca(&km, &t, 3, None);
1037        assert!(
1038            result.is_ok(),
1039            "joint_fpca with C optimization should succeed"
1040        );
1041    }
1042
1043    #[test]
1044    fn test_vert_fpca_invalid_input() {
1045        let data = FdMatrix::zeros(1, 10); // n < 2
1046        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1047        let km = KarcherMeanResult {
1048            mean: vec![0.0; 10],
1049            mean_srsf: vec![0.0; 10],
1050            gammas: FdMatrix::zeros(1, 10),
1051            aligned_data: data,
1052            n_iter: 0,
1053            converged: true,
1054            aligned_srsfs: None,
1055        };
1056        assert!(vert_fpca(&km, &t, 3).is_err());
1057    }
1058
1059    #[test]
1060    fn test_horiz_fpca_eigenvalue_properties() {
1061        let (data, t) = generate_test_data(15, 51);
1062        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1063        let res = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1064
1065        // Eigenvalues non-negative
1066        for ev in &res.eigenvalues {
1067            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1068        }
1069        // Eigenvalues decreasing
1070        for i in 1..res.eigenvalues.len() {
1071            assert!(
1072                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1073                "Eigenvalues should be decreasing"
1074            );
1075        }
1076        // Cumulative variance increasing and <= 1
1077        for i in 1..res.cumulative_variance.len() {
1078            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1079        }
1080        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1081    }
1082
1083    #[test]
1084    fn test_joint_fpca_eigenvalue_properties() {
1085        let (data, t) = generate_test_data(15, 51);
1086        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1087        let res = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1088
1089        // Eigenvalues non-negative
1090        for ev in &res.eigenvalues {
1091            assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1092        }
1093        // Eigenvalues decreasing
1094        for i in 1..res.eigenvalues.len() {
1095            assert!(
1096                res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1097                "Eigenvalues should be decreasing"
1098            );
1099        }
1100        // Cumulative variance increasing and <= 1
1101        for i in 1..res.cumulative_variance.len() {
1102            assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1103        }
1104        assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1105    }
1106
1107    #[test]
1108    fn test_vert_fpca_ncomp_sensitivity() -> Result<(), crate::error::FdarError> {
1109        let (data, t) = generate_test_data(15, 51);
1110        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1111
1112        for &ncomp in &[1, 2, 5, 10] {
1113            let res = vert_fpca(&km, &t, ncomp)?;
1114            assert_eq!(res.scores.shape(), (15, ncomp));
1115            assert_eq!(res.eigenvalues.len(), ncomp);
1116            assert_eq!(res.eigenfunctions_q.shape(), (ncomp, 52));
1117            assert_eq!(res.eigenfunctions_f.shape(), (ncomp, 51));
1118            assert_eq!(res.cumulative_variance.len(), ncomp);
1119        }
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn test_vert_fpca_score_orthogonality() {
1125        let (data, t) = generate_test_data(15, 51);
1126        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1127        let res = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1128
1129        let n = 15;
1130        // Check approximate orthogonality of score vectors
1131        for k1 in 0..3 {
1132            for k2 in (k1 + 1)..3 {
1133                let dot: f64 = (0..n)
1134                    .map(|i| res.scores[(i, k1)] * res.scores[(i, k2)])
1135                    .sum();
1136                let norm1: f64 = (0..n)
1137                    .map(|i| res.scores[(i, k1)].powi(2))
1138                    .sum::<f64>()
1139                    .sqrt();
1140                let norm2: f64 = (0..n)
1141                    .map(|i| res.scores[(i, k2)].powi(2))
1142                    .sum::<f64>()
1143                    .sqrt();
1144                if norm1 > 1e-10 && norm2 > 1e-10 {
1145                    let cos_angle = (dot / (norm1 * norm2)).abs();
1146                    assert!(
1147                        cos_angle < 0.15,
1148                        "Score components {} and {} should be approximately orthogonal, cos={}",
1149                        k1,
1150                        k2,
1151                        cos_angle
1152                    );
1153                }
1154            }
1155        }
1156    }
1157
1158    // ── from_alignment wrapper tests ──
1159
1160    #[test]
1161    fn test_vert_fpca_from_alignment_matches_original() {
1162        let (data, t) = generate_test_data(15, 51);
1163        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1164
1165        let original = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1166        let from_aln = vert_fpca_from_alignment(&km.aligned_data, km.aligned_srsfs.as_ref(), &t, 3)
1167            .expect("vert_fpca_from_alignment should succeed");
1168
1169        assert_eq!(original.scores.shape(), from_aln.scores.shape());
1170        assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1171        for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1172            assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1173        }
1174    }
1175
1176    #[test]
1177    fn test_vert_fpca_from_alignment_without_srsfs() {
1178        let (data, t) = generate_test_data(15, 51);
1179        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1180
1181        let res = vert_fpca_from_alignment(&km.aligned_data, None, &t, 3)
1182            .expect("vert_fpca_from_alignment without srsfs should succeed");
1183        assert_eq!(res.scores.shape(), (15, 3));
1184    }
1185
1186    #[test]
1187    fn test_horiz_fpca_from_alignment_matches_original() {
1188        let (data, t) = generate_test_data(15, 51);
1189        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1190
1191        let original = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1192        let from_aln = horiz_fpca_from_alignment(&km.gammas, &t, 3)
1193            .expect("horiz_fpca_from_alignment should succeed");
1194
1195        assert_eq!(original.scores.shape(), from_aln.scores.shape());
1196        assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1197        for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1198            assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1199        }
1200    }
1201
1202    #[test]
1203    fn test_joint_fpca_from_alignment_matches_original() {
1204        let (data, t) = generate_test_data(15, 51);
1205        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1206
1207        let original = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1208        let from_aln = joint_fpca_from_alignment(
1209            &km.aligned_data,
1210            km.aligned_srsfs.as_ref(),
1211            &km.gammas,
1212            &t,
1213            3,
1214            Some(1.0),
1215        )
1216        .expect("joint_fpca_from_alignment should succeed");
1217
1218        assert_eq!(original.scores.shape(), from_aln.scores.shape());
1219        assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1220        for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1221            assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1222        }
1223    }
1224
1225    #[test]
1226    fn test_joint_fpca_from_alignment_optimize_c() {
1227        let (data, t) = generate_test_data(15, 51);
1228        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1229
1230        let res = joint_fpca_from_alignment(
1231            &km.aligned_data,
1232            km.aligned_srsfs.as_ref(),
1233            &km.gammas,
1234            &t,
1235            3,
1236            None,
1237        )
1238        .expect("joint_fpca_from_alignment with C optimization should succeed");
1239        assert_eq!(res.scores.shape(), (15, 3));
1240        assert!(res.balance_c >= 0.0);
1241    }
1242
1243    #[test]
1244    fn test_from_alignment_invalid_input() {
1245        let data = FdMatrix::zeros(1, 10);
1246        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1247
1248        assert!(vert_fpca_from_alignment(&data, None, &t, 3).is_err());
1249        assert!(horiz_fpca_from_alignment(&data, &t, 3).is_err());
1250        assert!(joint_fpca_from_alignment(&data, None, &data, &t, 3, Some(1.0)).is_err());
1251    }
1252}