1use 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#[derive(Debug, Clone, PartialEq)]
22pub struct VertFpcaResult {
23 pub scores: FdMatrix,
25 pub eigenfunctions_q: FdMatrix,
27 pub eigenfunctions_f: FdMatrix,
29 pub eigenvalues: Vec<f64>,
31 pub cumulative_variance: Vec<f64>,
33 pub mean_q: Vec<f64>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct HorizFpcaResult {
40 pub scores: FdMatrix,
42 pub eigenfunctions_psi: FdMatrix,
44 pub eigenfunctions_gam: FdMatrix,
46 pub eigenvalues: Vec<f64>,
48 pub cumulative_variance: Vec<f64>,
50 pub mean_psi: Vec<f64>,
52 pub shooting_vectors: FdMatrix,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct JointFpcaResult {
59 pub scores: FdMatrix,
61 pub eigenvalues: Vec<f64>,
63 pub cumulative_variance: Vec<f64>,
65 pub balance_c: f64,
67 pub vert_component: FdMatrix,
69 pub horiz_component: FdMatrix,
71}
72
73pub 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 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 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 let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
141
142 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
166pub 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 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 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 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
257pub 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 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 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
341fn 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
359pub(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
388pub(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 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 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
419pub(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
437pub(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
457pub(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
475fn 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
498fn 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
518fn 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
545fn 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
567fn 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
577fn 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
594fn 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
616fn 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 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)); assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
701
702 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 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); 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 for ev in &res.eigenvalues {
782 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
783 }
784 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 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 for ev in &res.eigenvalues {
806 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
807 }
808 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 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 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}