1use crate::alignment::reparameterize_curve;
12use crate::matrix::FdMatrix;
13use crate::seasonal::{compute_prominence, find_peaks_1d};
14
15#[derive(Debug, Clone, Copy, PartialEq)]
17#[non_exhaustive]
18pub enum LandmarkKind {
19 Peak,
21 Valley,
23 ZeroCrossing,
25 Inflection,
27 Custom,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct Landmark {
34 pub position: f64,
36 pub kind: LandmarkKind,
38 pub value: f64,
40 pub prominence: f64,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46#[non_exhaustive]
47pub struct LandmarkResult {
48 pub registered: FdMatrix,
50 pub gammas: FdMatrix,
52 pub landmarks: Vec<Vec<Landmark>>,
54 pub target_landmarks: Vec<f64>,
56}
57
58pub fn detect_landmarks(
71 curve: &[f64],
72 argvals: &[f64],
73 kind: LandmarkKind,
74 min_prominence: f64,
75) -> Vec<Landmark> {
76 let m = curve.len();
77 if m < 3 || argvals.len() != m {
78 return Vec::new();
79 }
80
81 match kind {
82 LandmarkKind::Peak => detect_peaks(curve, argvals, min_prominence),
83 LandmarkKind::Valley => detect_valleys(curve, argvals, min_prominence),
84 LandmarkKind::ZeroCrossing => detect_zero_crossings(curve, argvals),
85 LandmarkKind::Inflection => detect_inflections(curve, argvals),
86 LandmarkKind::Custom => Vec::new(), }
88}
89
90fn detect_peaks(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
91 let peak_indices = find_peaks_1d(curve, 1);
92 peak_indices
93 .into_iter()
94 .filter_map(|idx| {
95 let prom = compute_prominence(curve, idx);
96 if prom >= min_prominence {
97 Some(Landmark {
98 position: argvals[idx],
99 kind: LandmarkKind::Peak,
100 value: curve[idx],
101 prominence: prom,
102 })
103 } else {
104 None
105 }
106 })
107 .collect()
108}
109
110fn detect_valleys(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
111 let negated: Vec<f64> = curve.iter().map(|&v| -v).collect();
113 let peak_indices = find_peaks_1d(&negated, 1);
114 peak_indices
115 .into_iter()
116 .filter_map(|idx| {
117 let prom = compute_prominence(&negated, idx);
118 if prom >= min_prominence {
119 Some(Landmark {
120 position: argvals[idx],
121 kind: LandmarkKind::Valley,
122 value: curve[idx],
123 prominence: prom,
124 })
125 } else {
126 None
127 }
128 })
129 .collect()
130}
131
132fn detect_zero_crossings(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
133 let m = curve.len();
134 let mut landmarks = Vec::new();
135 for i in 0..(m - 1) {
136 if curve[i] * curve[i + 1] < 0.0 {
137 let frac = curve[i].abs() / (curve[i].abs() + curve[i + 1].abs());
139 let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
140 landmarks.push(Landmark {
141 position: t,
142 kind: LandmarkKind::ZeroCrossing,
143 value: 0.0,
144 prominence: 0.0,
145 });
146 }
147 }
148 landmarks
149}
150
151fn detect_inflections(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
152 let m = curve.len();
153 if m < 4 {
154 return Vec::new();
155 }
156 let mut d2 = vec![0.0; m];
158 for i in 1..(m - 1) {
159 let h1 = argvals[i] - argvals[i - 1];
160 let h2 = argvals[i + 1] - argvals[i];
161 d2[i] = 2.0
162 * (curve[i + 1] / (h2 * (h1 + h2)) - curve[i] / (h1 * h2)
163 + curve[i - 1] / (h1 * (h1 + h2)));
164 }
165 let mut landmarks = Vec::new();
167 for i in 1..(m - 2) {
168 if d2[i] * d2[i + 1] < 0.0 {
169 let frac = d2[i].abs() / (d2[i].abs() + d2[i + 1].abs());
170 let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
171 let val = curve[i]
173 + (curve[i + 1] - curve[i]) * (t - argvals[i]) / (argvals[i + 1] - argvals[i]);
174 landmarks.push(Landmark {
175 position: t,
176 kind: LandmarkKind::Inflection,
177 value: val,
178 prominence: 0.0,
179 });
180 }
181 }
182 landmarks
183}
184
185fn fritsch_carlson_fix(delta: &[f64], d: &mut [f64]) {
192 for i in 0..delta.len() {
193 if delta[i].abs() < 1e-15 {
194 d[i] = 0.0;
195 d[i + 1] = 0.0;
196 } else {
197 let alpha = d[i] / delta[i];
198 let beta = d[i + 1] / delta[i];
199 let s2 = alpha * alpha + beta * beta;
200 if s2 > 9.0 {
201 let tau = 3.0 / s2.sqrt();
202 d[i] = tau * alpha * delta[i];
203 d[i + 1] = tau * beta * delta[i];
204 }
205 }
206 }
207}
208
209fn hermite_eval(t: f64, x_knots: &[f64], y_knots: &[f64], d: &[f64]) -> f64 {
214 let k = x_knots.len();
215 let seg = if t <= x_knots[0] {
216 0
217 } else if t >= x_knots[k - 1] {
218 k - 2
219 } else {
220 match x_knots.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
221 Ok(i) => i.min(k - 2),
222 Err(i) => (i - 1).min(k - 2),
223 }
224 };
225
226 let h = x_knots[seg + 1] - x_knots[seg];
227 if h.abs() < 1e-15 {
228 return y_knots[seg];
229 }
230
231 let s = (t - x_knots[seg]) / h;
232 let h00 = (1.0 + 2.0 * s) * (1.0 - s) * (1.0 - s);
233 let h10 = s * (1.0 - s) * (1.0 - s);
234 let h01 = s * s * (3.0 - 2.0 * s);
235 let h11 = s * s * (s - 1.0);
236 h00 * y_knots[seg] + h10 * h * d[seg] + h01 * y_knots[seg + 1] + h11 * h * d[seg + 1]
237}
238
239fn build_hermite_knots(
243 source: &[f64],
244 target: &[f64],
245 t0: f64,
246 t_end: f64,
247) -> (Vec<f64>, Vec<f64>) {
248 let mut x_knots = Vec::with_capacity(target.len() + 2);
249 let mut y_knots = Vec::with_capacity(source.len() + 2);
250 x_knots.push(t0);
251 y_knots.push(t0);
252 for (&s, &t) in source.iter().zip(target.iter()) {
253 if t > t0 && t < t_end {
254 x_knots.push(t);
255 y_knots.push(s);
256 }
257 }
258 x_knots.push(t_end);
259 y_knots.push(t_end);
260 (x_knots, y_knots)
261}
262
263fn hermite_tangents(x_knots: &[f64], y_knots: &[f64]) -> (Vec<f64>, Vec<f64>) {
265 let k = x_knots.len();
266 let mut delta = vec![0.0; k - 1];
267 for i in 0..(k - 1) {
268 let dx = x_knots[i + 1] - x_knots[i];
269 delta[i] = if dx.abs() < 1e-15 {
270 0.0
271 } else {
272 (y_knots[i + 1] - y_knots[i]) / dx
273 };
274 }
275
276 let mut d = vec![0.0; k];
277 if k == 2 {
278 d[0] = delta[0];
279 d[1] = delta[0];
280 } else {
281 d[0] = delta[0];
282 d[k - 1] = delta[k - 2];
283 for i in 1..(k - 1) {
284 d[i] = (delta[i - 1] + delta[i]) / 2.0;
285 }
286 }
287
288 fritsch_carlson_fix(&delta, &mut d);
289 (delta, d)
290}
291
292fn monotone_landmark_warp(source: &[f64], target: &[f64], argvals: &[f64]) -> Vec<f64> {
297 let m = argvals.len();
298 if source.is_empty() || source.len() != target.len() {
299 return argvals.to_vec();
300 }
301
302 let t0 = argvals[0];
303 let t_end = argvals[m - 1];
304
305 let (x_knots, y_knots) = build_hermite_knots(source, target, t0, t_end);
306 if x_knots.len() < 2 {
307 return argvals.to_vec();
308 }
309
310 let (_delta, d) = hermite_tangents(&x_knots, &y_knots);
311
312 let mut gamma: Vec<f64> = argvals
313 .iter()
314 .map(|&t| hermite_eval(t, &x_knots, &y_knots, &d))
315 .collect();
316
317 gamma[0] = t0;
319 gamma[m - 1] = t_end;
320 for i in 1..m {
321 gamma[i] = gamma[i].clamp(t0, t_end);
322 if gamma[i] < gamma[i - 1] {
323 gamma[i] = gamma[i - 1];
324 }
325 }
326
327 gamma
328}
329
330fn compute_target_landmarks(landmarks: &[Vec<f64>], target: Option<&[f64]>, n: usize) -> Vec<f64> {
334 if let Some(t) = target {
335 return t.to_vec();
336 }
337 let min_count = landmarks.iter().map(std::vec::Vec::len).min().unwrap_or(0);
338 if min_count == 0 {
339 return Vec::new();
340 }
341 let mut mean_pos = vec![0.0; min_count];
342 for lm in landmarks {
343 for (j, &pos) in lm.iter().take(min_count).enumerate() {
344 mean_pos[j] += pos;
345 }
346 }
347 for v in &mut mean_pos {
348 *v /= n as f64;
349 }
350 mean_pos
351}
352
353pub fn landmark_register(
367 data: &FdMatrix,
368 argvals: &[f64],
369 landmarks: &[Vec<f64>],
370 target: Option<&[f64]>,
371) -> LandmarkResult {
372 let (n, m) = data.shape();
373 if n == 0 || m == 0 || argvals.len() != m || landmarks.len() != n {
374 return LandmarkResult {
375 registered: data.clone(),
376 gammas: FdMatrix::zeros(n, m),
377 landmarks: landmarks
378 .iter()
379 .map(|lm| {
380 lm.iter()
381 .map(|&p| Landmark {
382 position: p,
383 kind: LandmarkKind::Custom,
384 value: 0.0,
385 prominence: 0.0,
386 })
387 .collect()
388 })
389 .collect(),
390 target_landmarks: target.unwrap_or(&[]).to_vec(),
391 };
392 }
393
394 let target_pos = compute_target_landmarks(landmarks, target, n);
395
396 let k = target_pos.len();
397
398 let mut registered = FdMatrix::zeros(n, m);
400 let mut gammas = FdMatrix::zeros(n, m);
401 let mut landmark_info = Vec::with_capacity(n);
402
403 for i in 0..n {
404 let source: Vec<f64> = landmarks[i].iter().take(k).copied().collect();
405 let gamma = monotone_landmark_warp(&source, &target_pos, argvals);
406 let fi = data.row(i);
407 let f_aligned = reparameterize_curve(&fi, argvals, &gamma);
408
409 for j in 0..m {
410 registered[(i, j)] = f_aligned[j];
411 gammas[(i, j)] = gamma[j];
412 }
413
414 landmark_info.push(
415 landmarks[i]
416 .iter()
417 .map(|&p| Landmark {
418 position: p,
419 kind: LandmarkKind::Custom,
420 value: 0.0,
421 prominence: 0.0,
422 })
423 .collect(),
424 );
425 }
426
427 LandmarkResult {
428 registered,
429 gammas,
430 landmarks: landmark_info,
431 target_landmarks: target_pos,
432 }
433}
434
435pub fn detect_and_register(
447 data: &FdMatrix,
448 argvals: &[f64],
449 kind: LandmarkKind,
450 min_prominence: f64,
451 expected_count: usize,
452) -> LandmarkResult {
453 let (n, m) = data.shape();
454 if n == 0 || m == 0 || argvals.len() != m {
455 return LandmarkResult {
456 registered: data.clone(),
457 gammas: FdMatrix::zeros(n, m),
458 landmarks: Vec::new(),
459 target_landmarks: Vec::new(),
460 };
461 }
462
463 let mut all_landmarks = Vec::with_capacity(n);
465 let mut all_positions = Vec::with_capacity(n);
466
467 for i in 0..n {
468 let curve = data.row(i);
469 let lms = detect_landmarks(&curve, argvals, kind, min_prominence);
470 let positions: Vec<f64> = lms
471 .iter()
472 .take(expected_count)
473 .map(|l| l.position)
474 .collect();
475 all_positions.push(positions);
476 all_landmarks.push(lms);
477 }
478
479 let result = landmark_register(data, argvals, &all_positions, None);
481
482 LandmarkResult {
483 registered: result.registered,
484 gammas: result.gammas,
485 landmarks: all_landmarks,
486 target_landmarks: result.target_landmarks,
487 }
488}
489
490#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::test_helpers::uniform_grid;
496 use std::f64::consts::PI;
497
498 #[test]
499 fn test_monotone_warp_identity() {
500 let m = 50;
501 let t = uniform_grid(m);
502 let source = vec![0.25, 0.5, 0.75];
503 let target = vec![0.25, 0.5, 0.75]; let gamma = monotone_landmark_warp(&source, &target, &t);
505 for j in 0..m {
506 assert!(
507 (gamma[j] - t[j]).abs() < 1e-10,
508 "Identity warp should give gamma(t)=t at j={j}: got {}",
509 gamma[j]
510 );
511 }
512 }
513
514 #[test]
515 fn test_monotone_warp_hits_landmarks() {
516 let m = 100;
517 let t = uniform_grid(m);
518 let source = vec![0.3, 0.6];
519 let target = vec![0.4, 0.7];
520 let gamma = monotone_landmark_warp(&source, &target, &t);
521
522 for (&s, &tgt) in source.iter().zip(target.iter()) {
525 let idx = (tgt * (m - 1) as f64).round() as usize;
526 assert!(
527 (gamma[idx] - s).abs() < 0.05,
528 "Warp should map target {tgt} to source {s}, got {}",
529 gamma[idx]
530 );
531 }
532 }
533
534 #[test]
535 fn test_monotone_warp_monotonicity() {
536 let m = 100;
537 let t = uniform_grid(m);
538 let source = vec![0.2, 0.8];
540 let target = vec![0.5, 0.6];
541 let gamma = monotone_landmark_warp(&source, &target, &t);
542 for j in 1..m {
543 assert!(
544 gamma[j] >= gamma[j - 1] - 1e-15,
545 "Warp must be monotone at j={j}: {} < {}",
546 gamma[j],
547 gamma[j - 1]
548 );
549 }
550 }
551
552 #[test]
553 fn test_monotone_warp_boundaries() {
554 let m = 50;
555 let t = uniform_grid(m);
556 let source = vec![0.3, 0.7];
557 let target = vec![0.4, 0.8];
558 let gamma = monotone_landmark_warp(&source, &target, &t);
559 assert!(
560 (gamma[0] - t[0]).abs() < 1e-15,
561 "Warp must start at domain start"
562 );
563 assert!(
564 (gamma[m - 1] - t[m - 1]).abs() < 1e-15,
565 "Warp must end at domain end"
566 );
567 }
568
569 #[test]
570 fn test_detect_peaks_sin() {
571 let m = 200;
572 let t = uniform_grid(m);
573 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
575 let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.1);
576 assert!(
577 !lms.is_empty(),
578 "Should detect at least one peak in sin(2πt)"
579 );
580 assert!(
581 (lms[0].position - 0.25).abs() < 0.02,
582 "Peak should be near 0.25, got {}",
583 lms[0].position
584 );
585 }
586
587 #[test]
588 fn test_detect_valleys() {
589 let m = 200;
590 let t = uniform_grid(m);
591 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
593 let lms = detect_landmarks(&curve, &t, LandmarkKind::Valley, 0.1);
594 assert!(
595 !lms.is_empty(),
596 "Should detect at least one valley in sin(2πt)"
597 );
598 assert!(
599 (lms[0].position - 0.75).abs() < 0.02,
600 "Valley should be near 0.75, got {}",
601 lms[0].position
602 );
603 }
604
605 #[test]
606 fn test_detect_zero_crossings() {
607 let m = 200;
608 let t = uniform_grid(m);
609 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
611 let lms = detect_landmarks(&curve, &t, LandmarkKind::ZeroCrossing, 0.0);
612 assert!(!lms.is_empty(), "Should detect zero crossings in sin(2πt)");
613 let has_half = lms.iter().any(|l| (l.position - 0.5).abs() < 0.02);
615 assert!(has_half, "Should detect zero crossing near t=0.5");
616 }
617
618 #[test]
619 fn test_registration_reduces_variability() {
620 let m = 100;
621 let n = 5;
622 let t = uniform_grid(m);
623
624 let shifts = [0.0, 0.02, -0.03, 0.04, -0.01];
626 let mut col_major = vec![0.0; n * m];
627 for i in 0..n {
628 for j in 0..m {
629 col_major[i + j * n] = (2.0 * PI * (t[j] - shifts[i])).sin();
630 }
631 }
632 let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
633
634 let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.5, 1);
635
636 let mut reg_peaks = Vec::new();
639 for i in 0..n {
640 let curve = result.registered.row(i);
641 let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.5);
642 if let Some(p) = lms.first() {
643 reg_peaks.push(p.position);
644 }
645 }
646
647 if reg_peaks.len() >= 2 {
648 let mean_pos: f64 = reg_peaks.iter().sum::<f64>() / reg_peaks.len() as f64;
649 let variance: f64 = reg_peaks
650 .iter()
651 .map(|&p| (p - mean_pos).powi(2))
652 .sum::<f64>()
653 / reg_peaks.len() as f64;
654 assert!(
655 variance < 0.01,
656 "After registration, peak position variance should be small, got {variance}"
657 );
658 }
659 }
660
661 #[test]
662 fn test_single_curve_identity() {
663 let m = 50;
664 let t = uniform_grid(m);
665 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
666 let data = FdMatrix::from_column_major(curve.clone(), 1, m).unwrap();
667 let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.1, 1);
668 let max_diff: f64 = (0..m)
670 .map(|j| (result.registered[(0, j)] - data[(0, j)]).abs())
671 .fold(0.0_f64, f64::max);
672 assert!(
673 max_diff < 0.1,
674 "Single curve should be nearly unchanged, max_diff={max_diff}"
675 );
676 }
677
678 #[test]
679 fn test_no_landmarks_identity() {
680 let m = 50;
681 let t = uniform_grid(m);
682 let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
684 let landmarks = vec![vec![]];
685 let result = landmark_register(&data, &t, &landmarks, None);
686 for j in 0..m {
688 assert!(
689 (result.gammas[(0, j)] - t[j]).abs() < 1e-10,
690 "No-landmark warp should be identity at j={j}"
691 );
692 }
693 }
694
695 #[test]
698 fn test_monotone_warp_reference_scipy_pchip() {
699 let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
702 let warp = monotone_landmark_warp(&[0.3, 0.6], &[0.4, 0.7], &eval_11);
703
704 #[rustfmt::skip]
705 let scipy_ref = [
706 0.000000000000000, 0.064845278864971, 0.137206457925636,
707 0.215964408023483, 0.300000000000000, 0.390737116764514,
708 0.490606653620352, 0.600000000000000, 0.721164021164021,
709 0.855026455026455, 1.000000000000000,
710 ];
711
712 assert_eq!(warp.len(), 11);
713 for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
714 assert!(
715 (actual - expected).abs() < 0.01,
716 "warp[{j}]: got {actual:.6}, expected {expected:.6}"
717 );
718 }
719 }
720
721 #[test]
722 fn test_detect_landmarks_short_curve() {
723 let t = vec![0.0, 1.0];
725 let curve = vec![1.0, 2.0];
726 assert!(detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.0).is_empty());
727 }
728
729 #[test]
730 fn test_detect_landmarks_mismatched_lengths() {
731 let t = vec![0.0, 0.5, 1.0];
732 let curve = vec![1.0, 2.0]; assert!(detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.0).is_empty());
734 }
735
736 #[test]
737 fn test_detect_landmarks_custom_kind() {
738 let t = uniform_grid(50);
739 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
740 assert!(detect_landmarks(&curve, &t, LandmarkKind::Custom, 0.0).is_empty());
742 }
743
744 #[test]
745 fn test_detect_inflections() {
746 let m = 200;
747 let t = uniform_grid(m);
748 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
750 let lms = detect_landmarks(&curve, &t, LandmarkKind::Inflection, 0.0);
751 assert!(!lms.is_empty(), "Should detect inflection points");
752 assert!(lms
754 .iter()
755 .all(|l| matches!(l.kind, LandmarkKind::Inflection)));
756 }
757
758 #[test]
759 fn test_detect_inflections_short_curve() {
760 let t = vec![0.0, 0.5, 1.0];
762 let curve = vec![0.0, 1.0, 0.0];
763 let lms = detect_landmarks(&curve, &t, LandmarkKind::Inflection, 0.0);
764 assert!(lms.is_empty());
765 }
766
767 #[test]
768 fn test_detect_peaks_high_prominence_filter() {
769 let m = 200;
770 let t = uniform_grid(m);
771 let curve: Vec<f64> = t.iter().map(|&ti| 0.01 * (2.0 * PI * ti).sin()).collect();
773 let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 1.0);
774 assert!(lms.is_empty(), "High prominence should filter small bumps");
775 }
776
777 #[test]
778 fn test_detect_valleys_high_prominence_filter() {
779 let m = 200;
780 let t = uniform_grid(m);
781 let curve: Vec<f64> = t.iter().map(|&ti| 0.01 * (2.0 * PI * ti).sin()).collect();
782 let lms = detect_landmarks(&curve, &t, LandmarkKind::Valley, 1.0);
783 assert!(
784 lms.is_empty(),
785 "High prominence should filter small valleys"
786 );
787 }
788
789 #[test]
790 fn test_hermite_tangents_two_points() {
791 let (delta, d) = hermite_tangents(&[0.0, 1.0], &[0.0, 1.0]);
793 assert_eq!(delta.len(), 1);
794 assert!((delta[0] - 1.0).abs() < 1e-15);
795 assert!((d[0] - 1.0).abs() < 1e-15);
796 assert!((d[1] - 1.0).abs() < 1e-15);
797 }
798
799 #[test]
800 fn test_hermite_tangents_zero_dx() {
801 let (delta, _d) = hermite_tangents(&[0.5, 0.5, 1.0], &[1.0, 2.0, 3.0]);
803 assert!((delta[0]).abs() < 1e-10, "Zero dx should give zero delta");
804 }
805
806 #[test]
807 fn test_landmark_register_empty_data() {
808 let data = FdMatrix::zeros(0, 0);
809 let result = landmark_register(&data, &[], &[], None);
810 assert_eq!(result.registered.nrows(), 0);
811 }
812
813 #[test]
814 fn test_landmark_register_mismatched_argvals() {
815 let m = 10;
816 let _t = uniform_grid(m);
817 let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
818 let wrong_t = vec![0.0; m + 1]; let landmarks = vec![vec![0.5]];
820 let result = landmark_register(&data, &wrong_t, &landmarks, None);
821 assert_eq!(result.registered.nrows(), 1);
823 }
824
825 #[test]
826 fn test_landmark_register_mismatched_n_landmarks() {
827 let m = 10;
828 let t = uniform_grid(m);
829 let data = FdMatrix::from_column_major(vec![1.0; 2 * m], 2, m).unwrap();
830 let landmarks = vec![vec![0.5]];
832 let result = landmark_register(&data, &t, &landmarks, None);
833 assert_eq!(result.registered.nrows(), 2);
834 }
835
836 #[test]
837 fn test_landmark_register_with_target() {
838 let m = 100;
839 let n = 3;
840 let t = uniform_grid(m);
841 let mut col_major = vec![0.0; n * m];
842 for i in 0..n {
843 for j in 0..m {
844 col_major[i + j * n] = (2.0 * PI * (t[j] - 0.02 * i as f64)).sin();
845 }
846 }
847 let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
848 let landmarks = vec![vec![0.25], vec![0.27], vec![0.29]];
849 let target = vec![0.25];
850 let result = landmark_register(&data, &t, &landmarks, Some(&target));
851 assert_eq!(result.target_landmarks, vec![0.25]);
852 assert_eq!(result.registered.shape(), (n, m));
853 }
854
855 #[test]
856 fn test_detect_and_register_empty() {
857 let data = FdMatrix::zeros(0, 0);
858 let result = detect_and_register(&data, &[], LandmarkKind::Peak, 0.1, 1);
859 assert_eq!(result.registered.nrows(), 0);
860 assert!(result.landmarks.is_empty());
861 }
862
863 #[test]
864 fn test_detect_and_register_mismatched_argvals() {
865 let m = 10;
866 let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
867 let wrong_t = vec![0.0; m + 1];
868 let result = detect_and_register(&data, &wrong_t, LandmarkKind::Peak, 0.1, 1);
869 assert_eq!(result.registered.nrows(), 1);
870 }
871
872 #[test]
873 fn test_monotone_warp_reference_scipy_extreme() {
874 let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
877 let warp = monotone_landmark_warp(&[0.2, 0.8], &[0.5, 0.6], &eval_11);
878
879 #[rustfmt::skip]
880 let scipy_ref = [
881 0.000000000000000, 0.005903448275862, 0.025710344827586,
882 0.062565517241379, 0.119613793103448, 0.200000000000000,
883 0.800000000000000, 0.893750000000000, 0.955555555555556,
884 0.989583333333333, 1.000000000000000,
885 ];
886
887 assert_eq!(warp.len(), 11);
888 for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
889 assert!(
890 (actual - expected).abs() < 0.02,
891 "warp[{j}]: got {actual:.6}, expected {expected:.6}"
892 );
893 }
894
895 for j in 1..warp.len() {
897 assert!(
898 warp[j] >= warp[j - 1],
899 "Monotonicity violated at {j}: {} < {}",
900 warp[j],
901 warp[j - 1]
902 );
903 }
904 }
905}