1use crate::alignment::{srsf_inverse, srsf_transform, KarcherMeanResult};
13use crate::iter_maybe_parallel;
14use crate::matrix::FdMatrix;
15use crate::warping::{exp_map_sphere, inv_exp_map_sphere, l2_norm_l2, psi_to_gam};
16use nalgebra::SVD;
17#[cfg(feature = "parallel")]
18use rayon::iter::ParallelIterator;
19
20const SCORES_PARALLEL_THRESHOLD: usize = 50;
29
30#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub struct VertFpcaResult {
36 pub scores: FdMatrix,
38 pub eigenfunctions_q: FdMatrix,
40 pub eigenfunctions_f: FdMatrix,
42 pub eigenvalues: Vec<f64>,
44 pub cumulative_variance: Vec<f64>,
46 pub mean_q: Vec<f64>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52#[non_exhaustive]
53pub struct HorizFpcaResult {
54 pub scores: FdMatrix,
56 pub eigenfunctions_psi: FdMatrix,
58 pub eigenfunctions_gam: FdMatrix,
60 pub eigenvalues: Vec<f64>,
62 pub cumulative_variance: Vec<f64>,
64 pub mean_psi: Vec<f64>,
66 pub shooting_vectors: FdMatrix,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72#[non_exhaustive]
73pub struct JointFpcaResult {
74 pub scores: FdMatrix,
76 pub eigenvalues: Vec<f64>,
78 pub cumulative_variance: Vec<f64>,
80 pub balance_c: f64,
82 pub vert_component: FdMatrix,
84 pub horiz_component: FdMatrix,
86}
87
88pub fn vert_fpca(
102 karcher: &KarcherMeanResult,
103 argvals: &[f64],
104 ncomp: usize,
105) -> Result<VertFpcaResult, crate::FdarError> {
106 let (n, m) = karcher.aligned_data.shape();
107 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
108 return Err(crate::FdarError::InvalidDimension {
109 parameter: "aligned_data/argvals",
110 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
111 actual: format!(
112 "n={}, m={}, ncomp={}, argvals.len()={}",
113 n,
114 m,
115 ncomp,
116 argvals.len()
117 ),
118 });
119 }
120 let ncomp = ncomp.min(n - 1).min(m);
121 let m_aug = m + 1;
122
123 let qn = match &karcher.aligned_srsfs {
124 Some(srsfs) => srsfs.clone(),
125 None => srsf_transform(&karcher.aligned_data, argvals),
126 };
127
128 let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
129
130 let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
132 let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
133
134 let svd = SVD::new(k_mat, true, true);
135 let u_cov = svd
136 .u
137 .as_ref()
138 .ok_or_else(|| crate::FdarError::ComputationFailed {
139 operation: "SVD",
140 detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
141 })?;
142
143 let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
144 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
145
146 let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
148 for k in 0..ncomp {
149 for j in 0..m_aug {
150 eigenfunctions_q[(k, j)] = u_cov[(j, k)];
151 }
152 }
153
154 let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
156
157 let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
159 for k in 0..ncomp {
160 let q_k: Vec<f64> = (0..m)
161 .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
162 .collect();
163 let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
164 let f0 = aug_val.signum() * aug_val * aug_val;
165 let f_k = srsf_inverse(&q_k, argvals, f0);
166 for j in 0..m {
167 eigenfunctions_f[(k, j)] = f_k[j];
168 }
169 }
170
171 Ok(VertFpcaResult {
172 scores,
173 eigenfunctions_q,
174 eigenfunctions_f,
175 eigenvalues,
176 cumulative_variance,
177 mean_q,
178 })
179}
180
181pub fn horiz_fpca(
195 karcher: &KarcherMeanResult,
196 argvals: &[f64],
197 ncomp: usize,
198) -> Result<HorizFpcaResult, crate::FdarError> {
199 let (n, m) = karcher.gammas.shape();
200 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
201 return Err(crate::FdarError::InvalidDimension {
202 parameter: "gammas/argvals",
203 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
204 actual: format!(
205 "n={}, m={}, ncomp={}, argvals.len()={}",
206 n,
207 m,
208 ncomp,
209 argvals.len()
210 ),
211 });
212 }
213 let ncomp = ncomp.min(n - 1).min(m);
214
215 let t0 = argvals[0];
216 let domain = argvals[m - 1] - t0;
217 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
218
219 let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
220 let mu_psi = sphere_karcher_mean(&psis, &time, 50);
221 let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
222
223 let (centered, _mean_v) = center_matrix(&shooting, n, m);
225
226 let svd = SVD::new(centered.to_dmatrix(), true, true);
227 let v_t = svd
228 .v_t
229 .as_ref()
230 .ok_or_else(|| crate::FdarError::ComputationFailed {
231 operation: "SVD",
232 detail:
233 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
234 .to_string(),
235 })?;
236 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
237 crate::FdarError::ComputationFailed {
238 operation: "SVD",
239 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
240 }
241 })?;
242 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
243
244 let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
246 for k in 0..ncomp {
247 for j in 0..m {
248 eigenfunctions_psi[(k, j)] = v_t[(k, j)];
249 }
250 }
251
252 let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
254 for k in 0..ncomp {
255 let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
256 let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
257 let gam_k = psi_to_gam(&psi_k, &time);
258 for j in 0..m {
259 eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
260 }
261 }
262
263 Ok(HorizFpcaResult {
264 scores,
265 eigenfunctions_psi,
266 eigenfunctions_gam,
267 eigenvalues,
268 cumulative_variance,
269 mean_psi: mu_psi,
270 shooting_vectors: shooting,
271 })
272}
273
274pub fn joint_fpca(
287 karcher: &KarcherMeanResult,
288 argvals: &[f64],
289 ncomp: usize,
290 balance_c: Option<f64>,
291) -> Result<JointFpcaResult, crate::FdarError> {
292 let (n, m) = karcher.aligned_data.shape();
293 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
294 return Err(crate::FdarError::InvalidDimension {
295 parameter: "aligned_data/argvals",
296 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
297 actual: format!(
298 "n={}, m={}, ncomp={}, argvals.len()={}",
299 n,
300 m,
301 ncomp,
302 argvals.len()
303 ),
304 });
305 }
306
307 let _vert = vert_fpca(karcher, argvals, ncomp)?;
308 let horiz = horiz_fpca(karcher, argvals, ncomp)?;
309
310 let m_aug = m + 1;
311 let ncomp = ncomp.min(n - 1);
312
313 let qn = match &karcher.aligned_srsfs {
314 Some(srsfs) => srsfs.clone(),
315 None => srsf_transform(&karcher.aligned_data, argvals),
316 };
317 let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
318 let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
319
320 let shooting = &horiz.shooting_vectors;
321 let c = match balance_c {
322 Some(c) => c,
323 None => optimize_balance_c(karcher, argvals, &q_centered, shooting, ncomp),
324 };
325
326 let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
328
329 let svd = SVD::new(combined.to_dmatrix(), true, true);
330 let v_t = svd
331 .v_t
332 .as_ref()
333 .ok_or_else(|| crate::FdarError::ComputationFailed {
334 operation: "SVD",
335 detail:
336 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
337 .to_string(),
338 })?;
339 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
340 crate::FdarError::ComputationFailed {
341 operation: "SVD",
342 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
343 }
344 })?;
345 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
346
347 let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
349
350 Ok(JointFpcaResult {
351 scores,
352 eigenvalues,
353 cumulative_variance,
354 balance_c: c,
355 vert_component,
356 horiz_component,
357 })
358}
359
360pub fn vert_fpca_from_alignment(
379 aligned_data: &FdMatrix,
380 aligned_srsfs: Option<&FdMatrix>,
381 argvals: &[f64],
382 ncomp: usize,
383) -> Result<VertFpcaResult, crate::FdarError> {
384 let (n, m) = aligned_data.shape();
385 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
386 return Err(crate::FdarError::InvalidDimension {
387 parameter: "aligned_data/argvals",
388 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
389 actual: format!(
390 "n={}, m={}, ncomp={}, argvals.len()={}",
391 n,
392 m,
393 ncomp,
394 argvals.len()
395 ),
396 });
397 }
398 let ncomp = ncomp.min(n - 1).min(m);
399 let m_aug = m + 1;
400
401 let qn = match aligned_srsfs {
402 Some(srsfs) => srsfs.clone(),
403 None => srsf_transform(aligned_data, argvals),
404 };
405
406 let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
407
408 let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
409 let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
410
411 let svd = SVD::new(k_mat, true, true);
412 let u_cov = svd
413 .u
414 .as_ref()
415 .ok_or_else(|| crate::FdarError::ComputationFailed {
416 operation: "SVD",
417 detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
418 })?;
419
420 let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
421 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
422
423 let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
424 for k in 0..ncomp {
425 for j in 0..m_aug {
426 eigenfunctions_q[(k, j)] = u_cov[(j, k)];
427 }
428 }
429
430 let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
431
432 let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
433 for k in 0..ncomp {
434 let q_k: Vec<f64> = (0..m)
435 .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
436 .collect();
437 let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
438 let f0 = aug_val.signum() * aug_val * aug_val;
439 let f_k = srsf_inverse(&q_k, argvals, f0);
440 for j in 0..m {
441 eigenfunctions_f[(k, j)] = f_k[j];
442 }
443 }
444
445 Ok(VertFpcaResult {
446 scores,
447 eigenfunctions_q,
448 eigenfunctions_f,
449 eigenvalues,
450 cumulative_variance,
451 mean_q,
452 })
453}
454
455pub fn horiz_fpca_from_alignment(
465 gammas: &FdMatrix,
466 argvals: &[f64],
467 ncomp: usize,
468) -> Result<HorizFpcaResult, crate::FdarError> {
469 let (n, m) = gammas.shape();
470 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
471 return Err(crate::FdarError::InvalidDimension {
472 parameter: "gammas/argvals",
473 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
474 actual: format!(
475 "n={}, m={}, ncomp={}, argvals.len()={}",
476 n,
477 m,
478 ncomp,
479 argvals.len()
480 ),
481 });
482 }
483 let ncomp = ncomp.min(n - 1).min(m);
484
485 let t0 = argvals[0];
486 let domain = argvals[m - 1] - t0;
487 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
488
489 let psis = warps_to_normalized_psi(gammas, argvals);
490 let mu_psi = sphere_karcher_mean(&psis, &time, 50);
491 let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
492
493 let (centered, _mean_v) = center_matrix(&shooting, n, m);
494
495 let svd = SVD::new(centered.to_dmatrix(), true, true);
496 let v_t = svd
497 .v_t
498 .as_ref()
499 .ok_or_else(|| crate::FdarError::ComputationFailed {
500 operation: "SVD",
501 detail:
502 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
503 .to_string(),
504 })?;
505 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
506 crate::FdarError::ComputationFailed {
507 operation: "SVD",
508 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
509 }
510 })?;
511 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
512
513 let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
514 for k in 0..ncomp {
515 for j in 0..m {
516 eigenfunctions_psi[(k, j)] = v_t[(k, j)];
517 }
518 }
519
520 let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
521 for k in 0..ncomp {
522 let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
523 let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
524 let gam_k = psi_to_gam(&psi_k, &time);
525 for j in 0..m {
526 eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
527 }
528 }
529
530 Ok(HorizFpcaResult {
531 scores,
532 eigenfunctions_psi,
533 eigenfunctions_gam,
534 eigenvalues,
535 cumulative_variance,
536 mean_psi: mu_psi,
537 shooting_vectors: shooting,
538 })
539}
540
541pub fn joint_fpca_from_alignment(
554 aligned_data: &FdMatrix,
555 aligned_srsfs: Option<&FdMatrix>,
556 gammas: &FdMatrix,
557 argvals: &[f64],
558 ncomp: usize,
559 balance_c: Option<f64>,
560) -> Result<JointFpcaResult, crate::FdarError> {
561 let (n, m) = aligned_data.shape();
562 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
563 return Err(crate::FdarError::InvalidDimension {
564 parameter: "aligned_data/argvals",
565 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
566 actual: format!(
567 "n={}, m={}, ncomp={}, argvals.len()={}",
568 n,
569 m,
570 ncomp,
571 argvals.len()
572 ),
573 });
574 }
575
576 let horiz = horiz_fpca_from_alignment(gammas, argvals, ncomp)?;
577
578 let m_aug = m + 1;
579 let ncomp = ncomp.min(n - 1);
580
581 let qn = match aligned_srsfs {
582 Some(srsfs) => srsfs.clone(),
583 None => srsf_transform(aligned_data, argvals),
584 };
585 let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
586 let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
587
588 let shooting = &horiz.shooting_vectors;
589 let c = match balance_c {
590 Some(c) => c,
591 None => optimize_balance_c_raw(&q_centered, shooting, ncomp, m_aug, m),
592 };
593
594 let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
595
596 let svd = SVD::new(combined.to_dmatrix(), true, true);
597 let v_t = svd
598 .v_t
599 .as_ref()
600 .ok_or_else(|| crate::FdarError::ComputationFailed {
601 operation: "SVD",
602 detail:
603 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
604 .to_string(),
605 })?;
606 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
607 crate::FdarError::ComputationFailed {
608 operation: "SVD",
609 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
610 }
611 })?;
612 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
613
614 let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
615
616 Ok(JointFpcaResult {
617 scores,
618 eigenvalues,
619 cumulative_variance,
620 balance_c: c,
621 vert_component,
622 horiz_component,
623 })
624}
625
626fn cumulative_variance_from_eigenvalues(eigenvalues: &[f64]) -> Vec<f64> {
630 let total_var: f64 = eigenvalues.iter().sum();
631 let mut cum = Vec::with_capacity(eigenvalues.len());
632 let mut running = 0.0;
633 for ev in eigenvalues {
634 running += ev;
635 cum.push(if total_var > 0.0 {
636 running / total_var
637 } else {
638 0.0
639 });
640 }
641 cum
642}
643
644pub(crate) fn warps_to_normalized_psi(gammas: &FdMatrix, argvals: &[f64]) -> Vec<Vec<f64>> {
646 let (n, m) = gammas.shape();
647 let t0 = argvals[0];
648 let domain = argvals[m - 1] - t0;
649 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
650 let binsize = 1.0 / (m - 1) as f64;
651
652 (0..n)
653 .map(|i| {
654 let gam_01: Vec<f64> = (0..m).map(|j| (gammas[(i, j)] - t0) / domain).collect();
655 let mut grad = vec![0.0; m];
656 grad[0] = (gam_01[1] - gam_01[0]) / binsize;
657 for j in 1..m - 1 {
658 grad[j] = (gam_01[j + 1] - gam_01[j - 1]) / (2.0 * binsize);
659 }
660 grad[m - 1] = (gam_01[m - 1] - gam_01[m - 2]) / binsize;
661 let mut psi: Vec<f64> = grad.iter().map(|&g| g.max(0.0).sqrt()).collect();
662 let norm = l2_norm_l2(&psi, &time);
663 if norm > 1e-10 {
664 for v in &mut psi {
665 *v /= norm;
666 }
667 }
668 psi
669 })
670 .collect()
671}
672
673pub(crate) fn sphere_karcher_mean(psis: &[Vec<f64>], time: &[f64], max_iter: usize) -> Vec<f64> {
675 let n = psis.len();
676 let m = psis[0].len();
677
678 let mut mu_psi = vec![0.0; m];
680 for psi in psis {
681 for j in 0..m {
682 mu_psi[j] += psi[j];
683 }
684 }
685 for j in 0..m {
686 mu_psi[j] /= n as f64;
687 }
688 normalize_to_sphere(&mut mu_psi, time);
689
690 for _ in 0..max_iter {
692 let mean_v = mean_tangent_vector(psis, &mu_psi, time);
693 let step_norm = l2_norm_l2(&mean_v, time);
694 if step_norm < 1e-8 {
695 break;
696 }
697 mu_psi = exp_map_sphere(&mu_psi, &mean_v, time);
698 normalize_to_sphere(&mut mu_psi, time);
699 }
700
701 mu_psi
702}
703
704pub(crate) fn shooting_vectors_from_psis(
706 psis: &[Vec<f64>],
707 mu_psi: &[f64],
708 time: &[f64],
709) -> FdMatrix {
710 let n = psis.len();
711 let m = psis[0].len();
712 let rows: Vec<Vec<f64>> = iter_maybe_parallel!(0..n)
716 .map(|i| inv_exp_map_sphere(mu_psi, &psis[i], time))
717 .collect();
718 let mut shooting = FdMatrix::zeros(n, m);
719 for (i, v) in rows.into_iter().enumerate() {
720 for j in 0..m {
721 shooting[(i, j)] = v[j];
722 }
723 }
724 shooting
725}
726
727pub(crate) fn build_augmented_srsfs(
729 qn: &FdMatrix,
730 aligned_data: &FdMatrix,
731 n: usize,
732 m: usize,
733) -> FdMatrix {
734 let id = m / 2;
735 let m_aug = m + 1;
736 let rows: Vec<Vec<f64>> = iter_maybe_parallel!(0..n)
739 .map(|i| {
740 let mut row = Vec::with_capacity(m_aug);
741 for j in 0..m {
742 row.push(qn[(i, j)]);
743 }
744 let fid = aligned_data[(i, id)];
745 row.push(fid.signum() * fid.abs().sqrt());
746 row
747 })
748 .collect();
749 let mut q_aug = FdMatrix::zeros(n, m_aug);
750 for (i, row) in rows.into_iter().enumerate() {
751 for j in 0..m_aug {
752 q_aug[(i, j)] = row[j];
753 }
754 }
755 q_aug
756}
757
758pub(crate) fn center_matrix(mat: &FdMatrix, n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
760 let mut mean = vec![0.0; m];
761 for j in 0..m {
762 for i in 0..n {
763 mean[j] += mat[(i, j)];
764 }
765 mean[j] /= n as f64;
766 }
767 let mut centered = FdMatrix::zeros(n, m);
768 for i in 0..n {
769 for j in 0..m {
770 centered[(i, j)] = mat[(i, j)] - mean[j];
771 }
772 }
773 (centered, mean)
774}
775
776fn svd_scores_and_eigenvalues(
784 svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
785 ncomp: usize,
786 n: usize,
787) -> Option<(FdMatrix, Vec<f64>)> {
788 let u = svd.u.as_ref()?;
789 let eigenvalues: Vec<f64> = svd
790 .singular_values
791 .iter()
792 .take(ncomp)
793 .map(|&s| s * s / (n - 1) as f64)
794 .collect();
795 let mut scores = FdMatrix::zeros(n, ncomp);
796 if n >= SCORES_PARALLEL_THRESHOLD {
797 for k in 0..ncomp {
799 let sv = svd.singular_values[k];
800 let col: Vec<f64> = iter_maybe_parallel!(0..n).map(|i| u[(i, k)] * sv).collect();
801 for (i, val) in col.into_iter().enumerate() {
802 scores[(i, k)] = val;
803 }
804 }
805 } else {
806 for k in 0..ncomp {
808 let sv = svd.singular_values[k];
809 for i in 0..n {
810 scores[(i, k)] = u[(i, k)] * sv;
811 }
812 }
813 }
814 Some((scores, eigenvalues))
815}
816
817fn split_joint_eigenvectors(
819 v_t: &nalgebra::DMatrix<f64>,
820 ncomp: usize,
821 m_aug: usize,
822 m: usize,
823) -> (FdMatrix, FdMatrix) {
824 let mut vert_component = FdMatrix::zeros(ncomp, m_aug);
825 let mut horiz_component = FdMatrix::zeros(ncomp, m);
826 for k in 0..ncomp {
827 for j in 0..m_aug {
828 vert_component[(k, j)] = v_t[(k, j)];
829 }
830 for j in 0..m {
831 horiz_component[(k, j)] = v_t[(k, m_aug + j)];
832 }
833 }
834 (vert_component, horiz_component)
835}
836
837fn build_symmetric_covariance(
839 data: &FdMatrix,
840 mean: &[f64],
841 n: usize,
842 d: usize,
843) -> nalgebra::DMatrix<f64> {
844 let nf = (n - 1) as f64;
845 let mut k_mat = nalgebra::DMatrix::zeros(d, d);
846 for i in 0..n {
847 for p in 0..d {
848 let dp = data[(i, p)] - mean[p];
849 for q in p..d {
850 k_mat[(p, q)] += dp * (data[(i, q)] - mean[q]);
851 }
852 }
853 }
854 for p in 0..d {
855 k_mat[(p, p)] /= nf;
856 for q in (p + 1)..d {
857 k_mat[(p, q)] /= nf;
858 k_mat[(q, p)] = k_mat[(p, q)];
859 }
860 }
861 k_mat
862}
863
864fn project_onto_eigenvectors(
866 data: &FdMatrix,
867 mean: &[f64],
868 u_cov: &nalgebra::DMatrix<f64>,
869 n: usize,
870 d: usize,
871 ncomp: usize,
872) -> FdMatrix {
873 let mut scores = FdMatrix::zeros(n, ncomp);
874 for k in 0..ncomp {
875 for i in 0..n {
876 let mut s = 0.0;
877 for j in 0..d {
878 s += (data[(i, j)] - mean[j]) * u_cov[(j, k)];
879 }
880 scores[(i, k)] = s;
881 }
882 }
883 scores
884}
885
886fn normalize_to_sphere(mu: &mut [f64], time: &[f64]) {
888 let norm = l2_norm_l2(mu, time);
889 if norm > 1e-10 {
890 for v in mu.iter_mut() {
891 *v /= norm;
892 }
893 }
894}
895
896fn mean_tangent_vector(psis: &[Vec<f64>], mu_psi: &[f64], time: &[f64]) -> Vec<f64> {
898 let n = psis.len();
899 let m = mu_psi.len();
900 let mut mean_v = vec![0.0; m];
901 for psi in psis {
902 let v = inv_exp_map_sphere(mu_psi, psi, time);
903 for j in 0..m {
904 mean_v[j] += v[j];
905 }
906 }
907 for j in 0..m {
908 mean_v[j] /= n as f64;
909 }
910 mean_v
911}
912
913fn build_combined_representation(
915 q_centered: &FdMatrix,
916 shooting: &FdMatrix,
917 c: f64,
918 n: usize,
919 m_aug: usize,
920 m: usize,
921) -> FdMatrix {
922 let combined_dim = m_aug + m;
923 let mut combined = FdMatrix::zeros(n, combined_dim);
924 for i in 0..n {
925 for j in 0..m_aug {
926 combined[(i, j)] = q_centered[(i, j)];
927 }
928 for j in 0..m {
929 combined[(i, m_aug + j)] = c * shooting[(i, j)];
930 }
931 }
932 combined
933}
934
935fn optimize_balance_c(
939 _karcher: &KarcherMeanResult,
940 _argvals: &[f64],
941 q_centered: &FdMatrix,
942 shooting: &FdMatrix,
943 ncomp: usize,
944) -> f64 {
945 let m_aug = q_centered.ncols();
946 let m = shooting.ncols();
947 optimize_balance_c_raw(q_centered, shooting, ncomp, m_aug, m)
948}
949
950fn optimize_balance_c_raw(
952 q_centered: &FdMatrix,
953 shooting: &FdMatrix,
954 ncomp: usize,
955 m_aug: usize,
956 m: usize,
957) -> f64 {
958 let n = shooting.nrows();
959 let combined_dim = m_aug + m;
960
961 let golden_ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
962 let mut a = 0.0_f64;
963 let mut b = 10.0_f64;
964
965 let eval_c = |c: f64| -> f64 {
966 let mut combined = FdMatrix::zeros(n, combined_dim);
967 for i in 0..n {
968 for j in 0..m_aug {
969 combined[(i, j)] = q_centered[(i, j)];
970 }
971 for j in 0..m {
972 combined[(i, m_aug + j)] = c * shooting[(i, j)];
973 }
974 }
975
976 let svd = SVD::new(combined.to_dmatrix(), true, true);
977 if let (Some(_u), Some(_v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
978 let nc = ncomp.min(svd.singular_values.len());
979 let total_var: f64 = svd.singular_values.iter().map(|&s| s * s).sum();
981 let explained: f64 = svd.singular_values.iter().take(nc).map(|&s| s * s).sum();
982 total_var - explained
983 } else {
984 f64::INFINITY
985 }
986 };
987
988 for _ in 0..20 {
989 let c1 = b - golden_ratio * (b - a);
990 let c2 = a + golden_ratio * (b - a);
991 if eval_c(c1) < eval_c(c2) {
992 b = c2;
993 } else {
994 a = c1;
995 }
996 }
997
998 (a + b) / 2.0
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004 use crate::alignment::karcher_mean;
1005 use std::f64::consts::PI;
1006
1007 fn generate_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
1008 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1009 let mut data = FdMatrix::zeros(n, m);
1010 for i in 0..n {
1011 let shift = 0.1 * (i as f64 - n as f64 / 2.0);
1012 let scale = 1.0 + 0.2 * (i as f64 / n as f64);
1013 for j in 0..m {
1014 data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
1015 }
1016 }
1017 (data, t)
1018 }
1019
1020 #[test]
1021 fn test_vert_fpca_basic() {
1022 let (data, t) = generate_test_data(15, 51);
1023 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1024 let result = vert_fpca(&km, &t, 3);
1025 assert!(result.is_ok(), "vert_fpca should succeed");
1026
1027 let res = result.unwrap();
1028 assert_eq!(res.scores.shape(), (15, 3));
1029 assert_eq!(res.eigenvalues.len(), 3);
1030 assert_eq!(res.eigenfunctions_q.shape(), (3, 52)); assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
1032
1033 for ev in &res.eigenvalues {
1035 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1036 }
1037 for i in 1..res.eigenvalues.len() {
1038 assert!(
1039 res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1040 "Eigenvalues should be decreasing"
1041 );
1042 }
1043
1044 for i in 1..res.cumulative_variance.len() {
1046 assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1047 }
1048 assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1049 }
1050
1051 #[test]
1052 fn test_horiz_fpca_basic() {
1053 let (data, t) = generate_test_data(15, 51);
1054 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1055 let result = horiz_fpca(&km, &t, 3);
1056 assert!(result.is_ok(), "horiz_fpca should succeed");
1057
1058 let res = result.unwrap();
1059 assert_eq!(res.scores.shape(), (15, 3));
1060 assert_eq!(res.eigenvalues.len(), 3);
1061 assert_eq!(res.eigenfunctions_psi.shape(), (3, 51));
1062 assert_eq!(res.shooting_vectors.shape(), (15, 51));
1063 }
1064
1065 #[test]
1066 fn test_joint_fpca_basic() {
1067 let (data, t) = generate_test_data(15, 51);
1068 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1069 let result = joint_fpca(&km, &t, 3, Some(1.0));
1070 assert!(result.is_ok(), "joint_fpca should succeed");
1071
1072 let res = result.unwrap();
1073 assert_eq!(res.scores.shape(), (15, 3));
1074 assert_eq!(res.eigenvalues.len(), 3);
1075 assert!(res.balance_c >= 0.0);
1076 }
1077
1078 #[test]
1079 fn test_joint_fpca_optimize_c() {
1080 let (data, t) = generate_test_data(15, 51);
1081 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1082 let result = joint_fpca(&km, &t, 3, None);
1083 assert!(
1084 result.is_ok(),
1085 "joint_fpca with C optimization should succeed"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_vert_fpca_invalid_input() {
1091 let data = FdMatrix::zeros(1, 10); let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1093 let km = KarcherMeanResult {
1094 mean: vec![0.0; 10],
1095 mean_srsf: vec![0.0; 10],
1096 gammas: FdMatrix::zeros(1, 10),
1097 aligned_data: data,
1098 n_iter: 0,
1099 converged: true,
1100 aligned_srsfs: None,
1101 };
1102 assert!(vert_fpca(&km, &t, 3).is_err());
1103 }
1104
1105 #[test]
1106 fn test_horiz_fpca_eigenvalue_properties() {
1107 let (data, t) = generate_test_data(15, 51);
1108 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1109 let res = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1110
1111 for ev in &res.eigenvalues {
1113 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1114 }
1115 for i in 1..res.eigenvalues.len() {
1117 assert!(
1118 res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1119 "Eigenvalues should be decreasing"
1120 );
1121 }
1122 for i in 1..res.cumulative_variance.len() {
1124 assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1125 }
1126 assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1127 }
1128
1129 #[test]
1130 fn test_joint_fpca_eigenvalue_properties() {
1131 let (data, t) = generate_test_data(15, 51);
1132 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1133 let res = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1134
1135 for ev in &res.eigenvalues {
1137 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1138 }
1139 for i in 1..res.eigenvalues.len() {
1141 assert!(
1142 res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1143 "Eigenvalues should be decreasing"
1144 );
1145 }
1146 for i in 1..res.cumulative_variance.len() {
1148 assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1149 }
1150 assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1151 }
1152
1153 #[test]
1154 fn test_vert_fpca_ncomp_sensitivity() -> Result<(), crate::error::FdarError> {
1155 let (data, t) = generate_test_data(15, 51);
1156 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1157
1158 for &ncomp in &[1, 2, 5, 10] {
1159 let res = vert_fpca(&km, &t, ncomp)?;
1160 assert_eq!(res.scores.shape(), (15, ncomp));
1161 assert_eq!(res.eigenvalues.len(), ncomp);
1162 assert_eq!(res.eigenfunctions_q.shape(), (ncomp, 52));
1163 assert_eq!(res.eigenfunctions_f.shape(), (ncomp, 51));
1164 assert_eq!(res.cumulative_variance.len(), ncomp);
1165 }
1166 Ok(())
1167 }
1168
1169 #[test]
1170 fn test_vert_fpca_score_orthogonality() {
1171 let (data, t) = generate_test_data(15, 51);
1172 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1173 let res = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1174
1175 let n = 15;
1176 for k1 in 0..3 {
1178 for k2 in (k1 + 1)..3 {
1179 let dot: f64 = (0..n)
1180 .map(|i| res.scores[(i, k1)] * res.scores[(i, k2)])
1181 .sum();
1182 let norm1: f64 = (0..n)
1183 .map(|i| res.scores[(i, k1)].powi(2))
1184 .sum::<f64>()
1185 .sqrt();
1186 let norm2: f64 = (0..n)
1187 .map(|i| res.scores[(i, k2)].powi(2))
1188 .sum::<f64>()
1189 .sqrt();
1190 if norm1 > 1e-10 && norm2 > 1e-10 {
1191 let cos_angle = (dot / (norm1 * norm2)).abs();
1192 assert!(
1193 cos_angle < 0.15,
1194 "Score components {} and {} should be approximately orthogonal, cos={}",
1195 k1,
1196 k2,
1197 cos_angle
1198 );
1199 }
1200 }
1201 }
1202 }
1203
1204 #[test]
1209 fn test_shooting_vectors_parallel_equiv() {
1210 let (data, t) = generate_test_data(51, 51);
1211 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1212 let psis = warps_to_normalized_psi(&km.gammas, &t);
1213 let time: Vec<f64> = (0..51).map(|i| i as f64 / 50.0).collect();
1214 let mu_psi = sphere_karcher_mean(&psis, &time, 50);
1215
1216 let parallel_result = shooting_vectors_from_psis(&psis, &mu_psi, &time);
1218
1219 let n = psis.len();
1221 let m = psis[0].len();
1222 let mut seq_result = FdMatrix::zeros(n, m);
1223 for i in 0..n {
1224 let v = inv_exp_map_sphere(&mu_psi, &psis[i], &time);
1225 for j in 0..m {
1226 seq_result[(i, j)] = v[j];
1227 }
1228 }
1229
1230 assert_eq!(
1231 parallel_result.shape(),
1232 seq_result.shape(),
1233 "shapes must match"
1234 );
1235 for i in 0..n {
1236 for j in 0..m {
1237 assert_eq!(
1238 parallel_result[(i, j)],
1239 seq_result[(i, j)],
1240 "shooting_vectors_from_psis bit-identical at ({i},{j})"
1241 );
1242 }
1243 }
1244 }
1245
1246 #[test]
1250 fn test_scores_threshold() {
1251 fn check_scores_exact(n: usize, m: usize) {
1253 let (data, t) = generate_test_data(n, m);
1254 let km = karcher_mean(&data, &t, 5, 1e-4, 0.0);
1255 let ncomp = 2.min(n - 1);
1256 let psis = warps_to_normalized_psi(&km.gammas, &t);
1258 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1259 let mu_psi = sphere_karcher_mean(&psis, &time, 20);
1260 let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
1261 let (centered, _) = center_matrix(&shooting, n, m);
1262 let svd = nalgebra::SVD::new(centered.to_dmatrix(), true, true);
1263 let u = svd.u.as_ref().expect("SVD U must exist");
1264 let (scores, _eigenvalues) =
1265 svd_scores_and_eigenvalues(&svd, ncomp, n).expect("scores must exist");
1266 for k in 0..ncomp {
1268 let sv = svd.singular_values[k];
1269 for i in 0..n {
1270 let expected = u[(i, k)] * sv;
1271 assert_eq!(
1272 scores[(i, k)],
1273 expected,
1274 "scores bit-identical at N={n} (i={i}, k={k})"
1275 );
1276 }
1277 }
1278 }
1279
1280 check_scores_exact(10, 31);
1282 check_scores_exact(51, 31);
1284 }
1285
1286 #[test]
1289 fn test_augmented_srsfs_parallel_equiv() {
1290 let (data, t) = generate_test_data(51, 51);
1291 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1292 let n = 51;
1293 let m = 51;
1294 let qn = match &km.aligned_srsfs {
1295 Some(s) => s.clone(),
1296 None => crate::alignment::srsf_transform(&km.aligned_data, &t),
1297 };
1298
1299 let parallel_result = build_augmented_srsfs(&qn, &km.aligned_data, n, m);
1301
1302 let id = m / 2;
1304 let m_aug = m + 1;
1305 let mut seq_result = FdMatrix::zeros(n, m_aug);
1306 for i in 0..n {
1307 for j in 0..m {
1308 seq_result[(i, j)] = qn[(i, j)];
1309 }
1310 let fid = km.aligned_data[(i, id)];
1311 seq_result[(i, m)] = fid.signum() * fid.abs().sqrt();
1312 }
1313
1314 assert_eq!(
1315 parallel_result.shape(),
1316 seq_result.shape(),
1317 "shapes must match"
1318 );
1319 for i in 0..n {
1320 for j in 0..m_aug {
1321 assert_eq!(
1322 parallel_result[(i, j)],
1323 seq_result[(i, j)],
1324 "build_augmented_srsfs bit-identical at ({i},{j})"
1325 );
1326 }
1327 }
1328 }
1329
1330 #[test]
1335 fn test_vert_fpca_parallel_equiv() {
1336 let n = 51;
1337 let m = 51;
1338 let (data, t) = generate_test_data(n, m);
1339 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1340 let ncomp = 3;
1341
1342 let res1 = vert_fpca(&km, &t, ncomp).expect("vert_fpca should succeed");
1345 let res2 = vert_fpca(&km, &t, ncomp).expect("vert_fpca should succeed");
1346
1347 assert_eq!(res1.scores.shape(), (n, ncomp));
1348 assert_eq!(res1.eigenvalues.len(), ncomp);
1349
1350 for i in 0..n {
1352 for k in 0..ncomp {
1353 assert_eq!(
1354 res1.scores[(i, k)],
1355 res2.scores[(i, k)],
1356 "vert_fpca scores deterministic at N={n} (i={i}, k={k})"
1357 );
1358 }
1359 }
1360 for k in 0..ncomp {
1361 assert_eq!(
1362 res1.eigenvalues[k], res2.eigenvalues[k],
1363 "vert_fpca eigenvalues deterministic at k={k}"
1364 );
1365 }
1366 }
1367
1368 #[test]
1371 fn test_joint_fpca_parallel_equiv() {
1372 let n = 51;
1373 let m = 51;
1374 let (data, t) = generate_test_data(n, m);
1375 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1376 let ncomp = 3;
1377 let balance_c = Some(1.0);
1380
1381 let res1 = joint_fpca(&km, &t, ncomp, balance_c).expect("joint_fpca should succeed");
1382 let res2 = joint_fpca(&km, &t, ncomp, balance_c).expect("joint_fpca should succeed");
1383
1384 assert_eq!(res1.scores.shape(), (n, ncomp));
1385 assert_eq!(res1.eigenvalues.len(), ncomp);
1386
1387 for i in 0..n {
1388 for k in 0..ncomp {
1389 assert_eq!(
1390 res1.scores[(i, k)],
1391 res2.scores[(i, k)],
1392 "joint_fpca scores deterministic at N={n} (i={i}, k={k})"
1393 );
1394 }
1395 }
1396 for k in 0..ncomp {
1397 assert_eq!(
1398 res1.eigenvalues[k], res2.eigenvalues[k],
1399 "joint_fpca eigenvalues deterministic at k={k}"
1400 );
1401 }
1402 }
1403
1404 #[test]
1407 fn test_vert_fpca_from_alignment_matches_original() {
1408 let (data, t) = generate_test_data(15, 51);
1409 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1410
1411 let original = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1412 let from_aln = vert_fpca_from_alignment(&km.aligned_data, km.aligned_srsfs.as_ref(), &t, 3)
1413 .expect("vert_fpca_from_alignment should succeed");
1414
1415 assert_eq!(original.scores.shape(), from_aln.scores.shape());
1416 assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1417 for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1418 assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1419 }
1420 }
1421
1422 #[test]
1423 fn test_vert_fpca_from_alignment_without_srsfs() {
1424 let (data, t) = generate_test_data(15, 51);
1425 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1426
1427 let res = vert_fpca_from_alignment(&km.aligned_data, None, &t, 3)
1428 .expect("vert_fpca_from_alignment without srsfs should succeed");
1429 assert_eq!(res.scores.shape(), (15, 3));
1430 }
1431
1432 #[test]
1433 fn test_horiz_fpca_from_alignment_matches_original() {
1434 let (data, t) = generate_test_data(15, 51);
1435 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1436
1437 let original = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1438 let from_aln = horiz_fpca_from_alignment(&km.gammas, &t, 3)
1439 .expect("horiz_fpca_from_alignment should succeed");
1440
1441 assert_eq!(original.scores.shape(), from_aln.scores.shape());
1442 assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1443 for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1444 assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1445 }
1446 }
1447
1448 #[test]
1449 fn test_joint_fpca_from_alignment_matches_original() {
1450 let (data, t) = generate_test_data(15, 51);
1451 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1452
1453 let original = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1454 let from_aln = joint_fpca_from_alignment(
1455 &km.aligned_data,
1456 km.aligned_srsfs.as_ref(),
1457 &km.gammas,
1458 &t,
1459 3,
1460 Some(1.0),
1461 )
1462 .expect("joint_fpca_from_alignment should succeed");
1463
1464 assert_eq!(original.scores.shape(), from_aln.scores.shape());
1465 assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1466 for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1467 assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1468 }
1469 }
1470
1471 #[test]
1472 fn test_joint_fpca_from_alignment_optimize_c() {
1473 let (data, t) = generate_test_data(15, 51);
1474 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1475
1476 let res = joint_fpca_from_alignment(
1477 &km.aligned_data,
1478 km.aligned_srsfs.as_ref(),
1479 &km.gammas,
1480 &t,
1481 3,
1482 None,
1483 )
1484 .expect("joint_fpca_from_alignment with C optimization should succeed");
1485 assert_eq!(res.scores.shape(), (15, 3));
1486 assert!(res.balance_c >= 0.0);
1487 }
1488
1489 #[test]
1490 fn test_from_alignment_invalid_input() {
1491 let data = FdMatrix::zeros(1, 10);
1492 let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1493
1494 assert!(vert_fpca_from_alignment(&data, None, &t, 3).is_err());
1495 assert!(horiz_fpca_from_alignment(&data, &t, 3).is_err());
1496 assert!(joint_fpca_from_alignment(&data, None, &data, &t, 3, Some(1.0)).is_err());
1497 }
1498}