1use super::pairwise::elastic_align_pair;
5use super::srsf::compose_warps;
6use super::KarcherMeanResult;
7use crate::error::FdarError;
8use crate::helpers::{gradient_uniform, l2_distance, simpsons_weights};
9use crate::matrix::FdMatrix;
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct AlignmentQuality {
14 pub warp_complexity: Vec<f64>,
16 pub mean_warp_complexity: f64,
18 pub warp_smoothness: Vec<f64>,
20 pub mean_warp_smoothness: f64,
22 pub total_variance: f64,
24 pub amplitude_variance: f64,
26 pub phase_variance: f64,
28 pub phase_amplitude_ratio: f64,
30 pub pointwise_variance_ratio: Vec<f64>,
32 pub mean_variance_reduction: f64,
34}
35
36pub fn warp_complexity(gamma: &[f64], argvals: &[f64]) -> f64 {
40 crate::warping::phase_distance(gamma, argvals)
41}
42
43pub fn warp_smoothness(gamma: &[f64], argvals: &[f64]) -> f64 {
45 let m = gamma.len();
46 if m < 3 {
47 return 0.0;
48 }
49
50 let h = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
51 let gam_prime = gradient_uniform(gamma, h);
52 let gam_pprime = gradient_uniform(&gam_prime, h);
53
54 let integrand: Vec<f64> = gam_pprime.iter().map(|&g| g * g).collect();
55 crate::helpers::trapz(&integrand, argvals)
56}
57
58pub fn alignment_quality(
65 data: &FdMatrix,
66 karcher: &KarcherMeanResult,
67 argvals: &[f64],
68) -> AlignmentQuality {
69 let (n, m) = data.shape();
70 let weights = simpsons_weights(argvals);
71
72 let wc: Vec<f64> = (0..n)
74 .map(|i| {
75 let gamma: Vec<f64> = (0..m).map(|j| karcher.gammas[(i, j)]).collect();
76 warp_complexity(&gamma, argvals)
77 })
78 .collect();
79 let ws: Vec<f64> = (0..n)
80 .map(|i| {
81 let gamma: Vec<f64> = (0..m).map(|j| karcher.gammas[(i, j)]).collect();
82 warp_smoothness(&gamma, argvals)
83 })
84 .collect();
85
86 let mean_wc = wc.iter().sum::<f64>() / n as f64;
87 let mean_ws = ws.iter().sum::<f64>() / n as f64;
88
89 let orig_mean = crate::fdata::mean_1d(data);
91
92 let total_var: f64 = (0..n)
94 .map(|i| {
95 let fi = data.row(i);
96 let d = l2_distance(&fi, &orig_mean, &weights);
97 d * d
98 })
99 .sum::<f64>()
100 / n as f64;
101
102 let aligned_mean = crate::fdata::mean_1d(&karcher.aligned_data);
104
105 let amp_var: f64 = (0..n)
107 .map(|i| {
108 let fi = karcher.aligned_data.row(i);
109 let d = l2_distance(&fi, &aligned_mean, &weights);
110 d * d
111 })
112 .sum::<f64>()
113 / n as f64;
114
115 let phase_var = (total_var - amp_var).max(0.0);
116 let ratio = if total_var > 1e-10 {
117 phase_var / total_var
118 } else {
119 0.0
120 };
121
122 let mut pw_ratio = vec![0.0; m];
124 for j in 0..m {
125 let col_orig = data.column(j);
126 let mean_orig_j = col_orig.iter().sum::<f64>() / n as f64;
127 let var_orig: f64 = col_orig
128 .iter()
129 .map(|&v| (v - mean_orig_j).powi(2))
130 .sum::<f64>()
131 / n as f64;
132
133 let col_aligned = karcher.aligned_data.column(j);
134 let mean_aligned_j = col_aligned.iter().sum::<f64>() / n as f64;
135 let var_aligned: f64 = col_aligned
136 .iter()
137 .map(|&v| (v - mean_aligned_j).powi(2))
138 .sum::<f64>()
139 / n as f64;
140
141 pw_ratio[j] = if var_orig > 1e-15 {
142 var_aligned / var_orig
143 } else {
144 1.0
145 };
146 }
147
148 let mean_vr = pw_ratio.iter().sum::<f64>() / m as f64;
149
150 AlignmentQuality {
151 warp_complexity: wc,
152 mean_warp_complexity: mean_wc,
153 warp_smoothness: ws,
154 mean_warp_smoothness: mean_ws,
155 total_variance: total_var,
156 amplitude_variance: amp_var,
157 phase_variance: phase_var,
158 phase_amplitude_ratio: ratio,
159 pointwise_variance_ratio: pw_ratio,
160 mean_variance_reduction: mean_vr,
161 }
162}
163
164fn triplet_indices(n: usize, max_triplets: usize) -> Vec<(usize, usize, usize)> {
166 let total = n * (n - 1) * (n - 2) / 6;
167 let cap = if max_triplets > 0 {
168 max_triplets.min(total)
169 } else {
170 total
171 };
172 (0..n)
173 .flat_map(|i| ((i + 1)..n).flat_map(move |j| ((j + 1)..n).map(move |k| (i, j, k))))
174 .take(cap)
175 .collect()
176}
177
178fn triplet_warp_deviation(
180 data: &FdMatrix,
181 argvals: &[f64],
182 weights: &[f64],
183 i: usize,
184 j: usize,
185 k: usize,
186 lambda: f64,
187) -> f64 {
188 let fi = data.row(i);
189 let fj = data.row(j);
190 let fk = data.row(k);
191 let rij = elastic_align_pair(&fi, &fj, argvals, lambda);
192 let rjk = elastic_align_pair(&fj, &fk, argvals, lambda);
193 let rik = elastic_align_pair(&fi, &fk, argvals, lambda);
194 let composed = compose_warps(&rij.gamma, &rjk.gamma, argvals);
195 l2_distance(&composed, &rik.gamma, weights)
196}
197
198pub fn pairwise_consistency(
209 data: &FdMatrix,
210 argvals: &[f64],
211 lambda: f64,
212 max_triplets: usize,
213) -> f64 {
214 let n = data.nrows();
215 if n < 3 {
216 return 0.0;
217 }
218
219 let weights = simpsons_weights(argvals);
220 let triplets = triplet_indices(n, max_triplets);
221 if triplets.is_empty() {
222 return 0.0;
223 }
224
225 let total_dev: f64 = triplets
226 .iter()
227 .map(|&(i, j, k)| triplet_warp_deviation(data, argvals, &weights, i, j, k, lambda))
228 .sum();
229 total_dev / triplets.len() as f64
230}
231
232pub fn least_squares_score(registered: &FdMatrix, argvals: &[f64]) -> Result<f64, FdarError> {
280 let (n, m) = registered.shape();
281 if n == 0 || m == 0 {
282 return Err(FdarError::InvalidDimension {
283 parameter: "registered",
284 expected: "non-empty matrix".to_string(),
285 actual: format!("{}×{}", n, m),
286 });
287 }
288 if argvals.len() != m {
289 return Err(FdarError::InvalidDimension {
290 parameter: "argvals",
291 expected: m.to_string(),
292 actual: argvals.len().to_string(),
293 });
294 }
295 if argvals.len() < 2 {
296 return Err(FdarError::InvalidParameter {
297 parameter: "argvals",
298 message: "must have at least 2 evaluation points".to_string(),
299 });
300 }
301
302 let weights = simpsons_weights(argvals);
303 let mean = crate::fdata::mean_1d(registered);
304
305 let score = (0..n)
306 .map(|i| {
307 let fi = registered.row(i);
308 fi.iter()
309 .zip(mean.iter())
310 .zip(weights.iter())
311 .map(|((&a, &b), &w)| (a - b) * (a - b) * w)
312 .sum::<f64>()
313 })
314 .sum::<f64>()
315 / n as f64;
316
317 Ok(score)
318}
319
320pub fn sobolev_least_squares_score(
358 registered: &FdMatrix,
359 argvals: &[f64],
360 lambda: f64,
361) -> Result<f64, FdarError> {
362 let (n, m) = registered.shape();
363 if n == 0 || m == 0 {
364 return Err(FdarError::InvalidDimension {
365 parameter: "registered",
366 expected: "non-empty matrix".to_string(),
367 actual: format!("{}×{}", n, m),
368 });
369 }
370 if argvals.len() != m {
371 return Err(FdarError::InvalidDimension {
372 parameter: "argvals",
373 expected: m.to_string(),
374 actual: argvals.len().to_string(),
375 });
376 }
377 if argvals.len() < 2 {
378 return Err(FdarError::InvalidParameter {
379 parameter: "argvals",
380 message: "must have at least 2 evaluation points".to_string(),
381 });
382 }
383 if lambda < 0.0 {
384 return Err(FdarError::InvalidParameter {
385 parameter: "lambda",
386 message: "lambda must be non-negative".to_string(),
387 });
388 }
389
390 let weights = simpsons_weights(argvals);
391 let mean = crate::fdata::mean_1d(registered);
392
393 let ls_term = (0..n)
395 .map(|i| {
396 let fi = registered.row(i);
397 fi.iter()
398 .zip(mean.iter())
399 .zip(weights.iter())
400 .map(|((&a, &b), &w)| (a - b) * (a - b) * w)
401 .sum::<f64>()
402 })
403 .sum::<f64>()
404 / n as f64;
405
406 if lambda == 0.0 {
407 return Ok(ls_term);
408 }
409
410 let h = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
415 let uniform = argvals
416 .windows(2)
417 .all(|w| ((w[1] - w[0]) - h).abs() < 1e-9 * h.abs().max(1e-12));
418 if !uniform {
419 return Err(FdarError::InvalidParameter {
420 parameter: "argvals",
421 message: "sobolev_least_squares_score with lambda>0 requires a uniform grid; \
422 use gradient_nonuniform externally for non-uniform grids"
423 .to_string(),
424 });
425 }
426 let mean_prime = gradient_uniform(&mean, h);
427
428 let sobol_term = (0..n)
429 .map(|i| {
430 let fi_row = registered.row(i);
431 let fi_prime = gradient_uniform(&fi_row, h);
432 fi_prime
433 .iter()
434 .zip(mean_prime.iter())
435 .zip(weights.iter())
436 .map(|((&a, &b), &w)| (a - b) * (a - b) * w)
437 .sum::<f64>()
438 })
439 .sum::<f64>()
440 / n as f64;
441
442 Ok(ls_term + lambda * sobol_term)
443}
444
445pub fn pairwise_correlation_score(
485 registered: &FdMatrix,
486 argvals: &[f64],
487) -> Result<f64, FdarError> {
488 let (n, m) = registered.shape();
489 if m == 0 {
490 return Err(FdarError::InvalidDimension {
491 parameter: "registered",
492 expected: "non-empty matrix (m > 0)".to_string(),
493 actual: format!("{}×{}", n, m),
494 });
495 }
496 if argvals.len() != m {
497 return Err(FdarError::InvalidDimension {
498 parameter: "argvals",
499 expected: m.to_string(),
500 actual: argvals.len().to_string(),
501 });
502 }
503 if argvals.len() < 2 {
504 return Err(FdarError::InvalidParameter {
505 parameter: "argvals",
506 message: "must have at least 2 evaluation points".to_string(),
507 });
508 }
509 if n < 2 {
510 return Err(FdarError::InvalidParameter {
511 parameter: "n",
512 message: "pairwise correlation requires at least 2 curves".to_string(),
513 });
514 }
515
516 let weights = simpsons_weights(argvals);
517 let weight_sum: f64 = weights.iter().sum();
518
519 let centred: Vec<Vec<f64>> = (0..n)
523 .map(|i| {
524 let fi = registered.row(i);
525 let mu: f64 = fi
526 .iter()
527 .zip(weights.iter())
528 .map(|(&a, &w)| a * w)
529 .sum::<f64>()
530 / weight_sum;
531 fi.iter().map(|&a| a - mu).collect()
532 })
533 .collect();
534
535 let norms: Vec<f64> = centred
536 .iter()
537 .map(|fi_c| {
538 fi_c.iter()
539 .zip(weights.iter())
540 .map(|(&a, &w)| a * a * w)
541 .sum::<f64>()
542 .sqrt()
543 })
544 .collect();
545
546 let n_pairs = n * (n - 1) / 2;
547 let corr_sum: f64 = (0..n)
548 .flat_map(|i| (i + 1..n).map(move |k| (i, k)))
549 .map(|(i, k)| {
550 let denom = norms[i] * norms[k];
551 if denom < 1e-15 {
552 0.0
554 } else {
555 let inner: f64 = centred[i]
556 .iter()
557 .zip(centred[k].iter())
558 .zip(weights.iter())
559 .map(|((&a, &b), &w)| a * b * w)
560 .sum();
561 inner / denom
562 }
563 })
564 .sum();
565
566 Ok(corr_sum / n_pairs as f64)
567}
568
569#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::matrix::FdMatrix;
577
578 fn uniform_grid(n: usize) -> Vec<f64> {
579 (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
580 }
581
582 fn gaussian_bump(argvals: &[f64], mu: f64, sigma: f64) -> Vec<f64> {
583 argvals
584 .iter()
585 .map(|&t| (-(t - mu).powi(2) / (2.0 * sigma * sigma)).exp())
586 .collect()
587 }
588
589 fn make_shifted_bumps(n: usize, m: usize, delta: f64) -> (FdMatrix, Vec<f64>) {
592 let argvals = uniform_grid(m);
593 let mut data = FdMatrix::zeros(n, m);
594 for i in 0..n {
595 let mu = 0.5 + (i as f64 - (n as f64 - 1.0) / 2.0) * delta / n as f64;
596 let bump = gaussian_bump(&argvals, mu, 0.1);
597 for j in 0..m {
598 data[(i, j)] = bump[j];
599 }
600 }
601 (data, argvals)
602 }
603
604 #[test]
607 fn test_ls_score_identical_curves() {
608 let m = 51;
609 let argvals = uniform_grid(m);
610 let mut data = FdMatrix::zeros(4, m);
612 for i in 0..4 {
613 for j in 0..m {
614 data[(i, j)] = 2.5;
615 }
616 }
617 let score = least_squares_score(&data, &argvals).unwrap();
618 assert!(score.abs() < 1e-12, "expected 0.0, got {score}");
619 }
620
621 #[test]
624 fn test_sobolev_score_lambda_zero() {
625 let (data, argvals) = make_shifted_bumps(5, 51, 0.1);
626 let ls = least_squares_score(&data, &argvals).unwrap();
627 let sobol = sobolev_least_squares_score(&data, &argvals, 0.0).unwrap();
628 assert!(
629 (sobol - ls).abs() < 1e-12,
630 "sobolev(lambda=0) should equal least_squares_score: ls={ls}, sobol={sobol}"
631 );
632 }
633
634 #[test]
637 fn test_sobolev_score_lambda_positive() {
638 let (data, argvals) = make_shifted_bumps(5, 51, 0.1);
639 let sobol0 = sobolev_least_squares_score(&data, &argvals, 0.0).unwrap();
640 let sobol_pos = sobolev_least_squares_score(&data, &argvals, 1.0).unwrap();
641 assert!(
642 sobol_pos >= sobol0 - 1e-12,
643 "sobolev(lambda>0) should be >= sobolev(lambda=0): sobol0={sobol0}, sobol_pos={sobol_pos}"
644 );
645 }
646
647 #[test]
651 fn test_ls_score_drops_after_registration() {
652 let (data, argvals) = make_shifted_bumps(5, 101, 0.3);
653 let max_shift = 0.25;
654 let result =
655 crate::alignment::shift::least_squares_shift_registration(&data, &argvals, max_shift)
656 .unwrap();
657 let score_before = least_squares_score(&data, &argvals).unwrap();
658 let score_after = least_squares_score(&result.registered_data, &argvals).unwrap();
659 assert!(
660 score_after < score_before,
661 "LS score should drop after registration: before={score_before}, after={score_after}"
662 );
663 }
664
665 #[test]
668 fn test_pairwise_corr_rises_after_registration() {
669 let (data, argvals) = make_shifted_bumps(5, 101, 0.3);
670 let max_shift = 0.25;
671 let result =
672 crate::alignment::shift::least_squares_shift_registration(&data, &argvals, max_shift)
673 .unwrap();
674 let score_before = pairwise_correlation_score(&data, &argvals).unwrap();
675 let score_after = pairwise_correlation_score(&result.registered_data, &argvals).unwrap();
676 assert!(
677 score_after > score_before,
678 "Pairwise correlation should rise after registration: before={score_before}, after={score_after}"
679 );
680 }
681
682 #[test]
685 fn test_pairwise_corr_n1_error() {
686 let m = 51;
687 let argvals = uniform_grid(m);
688 let mut single = FdMatrix::zeros(1, m);
689 for j in 0..m {
690 single[(0, j)] = 1.0;
691 }
692 let result = pairwise_correlation_score(&single, &argvals);
693 assert!(
694 matches!(result, Err(FdarError::InvalidParameter { .. })),
695 "expected Err(InvalidParameter), got {result:?}"
696 );
697 }
698
699 #[test]
702 fn test_score_fns_reject_single_point_grid() {
703 let argvals_1pt = vec![0.5_f64];
706 let mut data_1col = FdMatrix::zeros(3, 1);
707 for i in 0..3 {
708 data_1col[(i, 0)] = 1.0;
709 }
710
711 let r1 = least_squares_score(&data_1col, &argvals_1pt);
712 assert!(
713 matches!(r1, Err(FdarError::InvalidParameter { .. })),
714 "least_squares_score m=1 should return Err(InvalidParameter), got {r1:?}"
715 );
716
717 let r2 = sobolev_least_squares_score(&data_1col, &argvals_1pt, 0.0);
718 assert!(
719 matches!(r2, Err(FdarError::InvalidParameter { .. })),
720 "sobolev_least_squares_score m=1 should return Err(InvalidParameter), got {r2:?}"
721 );
722
723 let mut data_1col_2rows = FdMatrix::zeros(2, 1);
726 data_1col_2rows[(0, 0)] = 1.0;
727 data_1col_2rows[(1, 0)] = 2.0;
728 let r3 = pairwise_correlation_score(&data_1col_2rows, &argvals_1pt);
729 assert!(
730 matches!(r3, Err(FdarError::InvalidParameter { .. })),
731 "pairwise_correlation_score m=1 should return Err(InvalidParameter), got {r3:?}"
732 );
733 }
734}