1use crate::alignment::reparameterize_curve;
12use crate::matrix::FdMatrix;
13use crate::seasonal::{compute_prominence, find_peaks_1d};
14
15#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum LandmarkKind {
18 Peak,
20 Valley,
22 ZeroCrossing,
24 Inflection,
26 Custom,
28}
29
30#[derive(Debug, Clone)]
32pub struct Landmark {
33 pub position: f64,
35 pub kind: LandmarkKind,
37 pub value: f64,
39 pub prominence: f64,
41}
42
43#[derive(Debug, Clone)]
45pub struct LandmarkResult {
46 pub registered: FdMatrix,
48 pub gammas: FdMatrix,
50 pub landmarks: Vec<Vec<Landmark>>,
52 pub target_landmarks: Vec<f64>,
54}
55
56pub fn detect_landmarks(
69 curve: &[f64],
70 argvals: &[f64],
71 kind: LandmarkKind,
72 min_prominence: f64,
73) -> Vec<Landmark> {
74 let m = curve.len();
75 if m < 3 || argvals.len() != m {
76 return Vec::new();
77 }
78
79 match kind {
80 LandmarkKind::Peak => detect_peaks(curve, argvals, min_prominence),
81 LandmarkKind::Valley => detect_valleys(curve, argvals, min_prominence),
82 LandmarkKind::ZeroCrossing => detect_zero_crossings(curve, argvals),
83 LandmarkKind::Inflection => detect_inflections(curve, argvals),
84 LandmarkKind::Custom => Vec::new(), }
86}
87
88fn detect_peaks(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
89 let peak_indices = find_peaks_1d(curve, 1);
90 peak_indices
91 .into_iter()
92 .filter_map(|idx| {
93 let prom = compute_prominence(curve, idx);
94 if prom >= min_prominence {
95 Some(Landmark {
96 position: argvals[idx],
97 kind: LandmarkKind::Peak,
98 value: curve[idx],
99 prominence: prom,
100 })
101 } else {
102 None
103 }
104 })
105 .collect()
106}
107
108fn detect_valleys(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
109 let negated: Vec<f64> = curve.iter().map(|&v| -v).collect();
111 let peak_indices = find_peaks_1d(&negated, 1);
112 peak_indices
113 .into_iter()
114 .filter_map(|idx| {
115 let prom = compute_prominence(&negated, idx);
116 if prom >= min_prominence {
117 Some(Landmark {
118 position: argvals[idx],
119 kind: LandmarkKind::Valley,
120 value: curve[idx],
121 prominence: prom,
122 })
123 } else {
124 None
125 }
126 })
127 .collect()
128}
129
130fn detect_zero_crossings(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
131 let m = curve.len();
132 let mut landmarks = Vec::new();
133 for i in 0..(m - 1) {
134 if curve[i] * curve[i + 1] < 0.0 {
135 let frac = curve[i].abs() / (curve[i].abs() + curve[i + 1].abs());
137 let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
138 landmarks.push(Landmark {
139 position: t,
140 kind: LandmarkKind::ZeroCrossing,
141 value: 0.0,
142 prominence: 0.0,
143 });
144 }
145 }
146 landmarks
147}
148
149fn detect_inflections(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
150 let m = curve.len();
151 if m < 4 {
152 return Vec::new();
153 }
154 let mut d2 = vec![0.0; m];
156 for i in 1..(m - 1) {
157 let h1 = argvals[i] - argvals[i - 1];
158 let h2 = argvals[i + 1] - argvals[i];
159 d2[i] = 2.0
160 * (curve[i + 1] / (h2 * (h1 + h2)) - curve[i] / (h1 * h2)
161 + curve[i - 1] / (h1 * (h1 + h2)));
162 }
163 let mut landmarks = Vec::new();
165 for i in 1..(m - 2) {
166 if d2[i] * d2[i + 1] < 0.0 {
167 let frac = d2[i].abs() / (d2[i].abs() + d2[i + 1].abs());
168 let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
169 let val = curve[i]
171 + (curve[i + 1] - curve[i]) * (t - argvals[i]) / (argvals[i + 1] - argvals[i]);
172 landmarks.push(Landmark {
173 position: t,
174 kind: LandmarkKind::Inflection,
175 value: val,
176 prominence: 0.0,
177 });
178 }
179 }
180 landmarks
181}
182
183fn fritsch_carlson_fix(delta: &[f64], d: &mut [f64]) {
190 for i in 0..delta.len() {
191 if delta[i].abs() < 1e-15 {
192 d[i] = 0.0;
193 d[i + 1] = 0.0;
194 } else {
195 let alpha = d[i] / delta[i];
196 let beta = d[i + 1] / delta[i];
197 let s2 = alpha * alpha + beta * beta;
198 if s2 > 9.0 {
199 let tau = 3.0 / s2.sqrt();
200 d[i] = tau * alpha * delta[i];
201 d[i + 1] = tau * beta * delta[i];
202 }
203 }
204 }
205}
206
207fn hermite_eval(t: f64, x_knots: &[f64], y_knots: &[f64], d: &[f64]) -> f64 {
212 let k = x_knots.len();
213 let seg = if t <= x_knots[0] {
214 0
215 } else if t >= x_knots[k - 1] {
216 k - 2
217 } else {
218 match x_knots.binary_search_by(|v| v.partial_cmp(&t).unwrap()) {
219 Ok(i) => i.min(k - 2),
220 Err(i) => (i - 1).min(k - 2),
221 }
222 };
223
224 let h = x_knots[seg + 1] - x_knots[seg];
225 if h.abs() < 1e-15 {
226 return y_knots[seg];
227 }
228
229 let s = (t - x_knots[seg]) / h;
230 let h00 = (1.0 + 2.0 * s) * (1.0 - s) * (1.0 - s);
231 let h10 = s * (1.0 - s) * (1.0 - s);
232 let h01 = s * s * (3.0 - 2.0 * s);
233 let h11 = s * s * (s - 1.0);
234 h00 * y_knots[seg] + h10 * h * d[seg] + h01 * y_knots[seg + 1] + h11 * h * d[seg + 1]
235}
236
237fn build_hermite_knots(
241 source: &[f64],
242 target: &[f64],
243 t0: f64,
244 t_end: f64,
245) -> (Vec<f64>, Vec<f64>) {
246 let mut x_knots = Vec::with_capacity(target.len() + 2);
247 let mut y_knots = Vec::with_capacity(source.len() + 2);
248 x_knots.push(t0);
249 y_knots.push(t0);
250 for (&s, &t) in source.iter().zip(target.iter()) {
251 if t > t0 && t < t_end {
252 x_knots.push(t);
253 y_knots.push(s);
254 }
255 }
256 x_knots.push(t_end);
257 y_knots.push(t_end);
258 (x_knots, y_knots)
259}
260
261fn hermite_tangents(x_knots: &[f64], y_knots: &[f64]) -> (Vec<f64>, Vec<f64>) {
263 let k = x_knots.len();
264 let mut delta = vec![0.0; k - 1];
265 for i in 0..(k - 1) {
266 let dx = x_knots[i + 1] - x_knots[i];
267 delta[i] = if dx.abs() < 1e-15 {
268 0.0
269 } else {
270 (y_knots[i + 1] - y_knots[i]) / dx
271 };
272 }
273
274 let mut d = vec![0.0; k];
275 if k == 2 {
276 d[0] = delta[0];
277 d[1] = delta[0];
278 } else {
279 d[0] = delta[0];
280 d[k - 1] = delta[k - 2];
281 for i in 1..(k - 1) {
282 d[i] = (delta[i - 1] + delta[i]) / 2.0;
283 }
284 }
285
286 fritsch_carlson_fix(&delta, &mut d);
287 (delta, d)
288}
289
290fn monotone_landmark_warp(source: &[f64], target: &[f64], argvals: &[f64]) -> Vec<f64> {
295 let m = argvals.len();
296 if source.is_empty() || source.len() != target.len() {
297 return argvals.to_vec();
298 }
299
300 let t0 = argvals[0];
301 let t_end = argvals[m - 1];
302
303 let (x_knots, y_knots) = build_hermite_knots(source, target, t0, t_end);
304 if x_knots.len() < 2 {
305 return argvals.to_vec();
306 }
307
308 let (_delta, d) = hermite_tangents(&x_knots, &y_knots);
309
310 let mut gamma: Vec<f64> = argvals
311 .iter()
312 .map(|&t| hermite_eval(t, &x_knots, &y_knots, &d))
313 .collect();
314
315 gamma[0] = t0;
317 gamma[m - 1] = t_end;
318 for i in 1..m {
319 gamma[i] = gamma[i].clamp(t0, t_end);
320 if gamma[i] < gamma[i - 1] {
321 gamma[i] = gamma[i - 1];
322 }
323 }
324
325 gamma
326}
327
328fn compute_target_landmarks(landmarks: &[Vec<f64>], target: Option<&[f64]>, n: usize) -> Vec<f64> {
332 if let Some(t) = target {
333 return t.to_vec();
334 }
335 let min_count = landmarks.iter().map(|l| l.len()).min().unwrap_or(0);
336 if min_count == 0 {
337 return Vec::new();
338 }
339 let mut mean_pos = vec![0.0; min_count];
340 for lm in landmarks {
341 for (j, &pos) in lm.iter().take(min_count).enumerate() {
342 mean_pos[j] += pos;
343 }
344 }
345 for v in &mut mean_pos {
346 *v /= n as f64;
347 }
348 mean_pos
349}
350
351pub fn landmark_register(
365 data: &FdMatrix,
366 argvals: &[f64],
367 landmarks: &[Vec<f64>],
368 target: Option<&[f64]>,
369) -> LandmarkResult {
370 let (n, m) = data.shape();
371 if n == 0 || m == 0 || argvals.len() != m || landmarks.len() != n {
372 return LandmarkResult {
373 registered: data.clone(),
374 gammas: FdMatrix::zeros(n, m),
375 landmarks: landmarks
376 .iter()
377 .map(|lm| {
378 lm.iter()
379 .map(|&p| Landmark {
380 position: p,
381 kind: LandmarkKind::Custom,
382 value: 0.0,
383 prominence: 0.0,
384 })
385 .collect()
386 })
387 .collect(),
388 target_landmarks: target.unwrap_or(&[]).to_vec(),
389 };
390 }
391
392 let target_pos = compute_target_landmarks(landmarks, target, n);
393
394 let k = target_pos.len();
395
396 let mut registered = FdMatrix::zeros(n, m);
398 let mut gammas = FdMatrix::zeros(n, m);
399 let mut landmark_info = Vec::with_capacity(n);
400
401 for i in 0..n {
402 let source: Vec<f64> = landmarks[i].iter().take(k).cloned().collect();
403 let gamma = monotone_landmark_warp(&source, &target_pos, argvals);
404 let fi = data.row(i);
405 let f_aligned = reparameterize_curve(&fi, argvals, &gamma);
406
407 for j in 0..m {
408 registered[(i, j)] = f_aligned[j];
409 gammas[(i, j)] = gamma[j];
410 }
411
412 landmark_info.push(
413 landmarks[i]
414 .iter()
415 .map(|&p| Landmark {
416 position: p,
417 kind: LandmarkKind::Custom,
418 value: 0.0,
419 prominence: 0.0,
420 })
421 .collect(),
422 );
423 }
424
425 LandmarkResult {
426 registered,
427 gammas,
428 landmarks: landmark_info,
429 target_landmarks: target_pos,
430 }
431}
432
433pub fn detect_and_register(
445 data: &FdMatrix,
446 argvals: &[f64],
447 kind: LandmarkKind,
448 min_prominence: f64,
449 expected_count: usize,
450) -> LandmarkResult {
451 let (n, m) = data.shape();
452 if n == 0 || m == 0 || argvals.len() != m {
453 return LandmarkResult {
454 registered: data.clone(),
455 gammas: FdMatrix::zeros(n, m),
456 landmarks: Vec::new(),
457 target_landmarks: Vec::new(),
458 };
459 }
460
461 let mut all_landmarks = Vec::with_capacity(n);
463 let mut all_positions = Vec::with_capacity(n);
464
465 for i in 0..n {
466 let curve = data.row(i);
467 let lms = detect_landmarks(&curve, argvals, kind, min_prominence);
468 let positions: Vec<f64> = lms
469 .iter()
470 .take(expected_count)
471 .map(|l| l.position)
472 .collect();
473 all_positions.push(positions);
474 all_landmarks.push(lms);
475 }
476
477 let result = landmark_register(data, argvals, &all_positions, None);
479
480 LandmarkResult {
481 registered: result.registered,
482 gammas: result.gammas,
483 landmarks: all_landmarks,
484 target_landmarks: result.target_landmarks,
485 }
486}
487
488#[cfg(test)]
491mod tests {
492 use super::*;
493 use std::f64::consts::PI;
494
495 fn uniform_grid(n: usize) -> Vec<f64> {
496 (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
497 }
498
499 #[test]
500 fn test_monotone_warp_identity() {
501 let m = 50;
502 let t = uniform_grid(m);
503 let source = vec![0.25, 0.5, 0.75];
504 let target = vec![0.25, 0.5, 0.75]; let gamma = monotone_landmark_warp(&source, &target, &t);
506 for j in 0..m {
507 assert!(
508 (gamma[j] - t[j]).abs() < 1e-10,
509 "Identity warp should give gamma(t)=t at j={j}: got {}",
510 gamma[j]
511 );
512 }
513 }
514
515 #[test]
516 fn test_monotone_warp_hits_landmarks() {
517 let m = 100;
518 let t = uniform_grid(m);
519 let source = vec![0.3, 0.6];
520 let target = vec![0.4, 0.7];
521 let gamma = monotone_landmark_warp(&source, &target, &t);
522
523 for (&s, &tgt) in source.iter().zip(target.iter()) {
526 let idx = (tgt * (m - 1) as f64).round() as usize;
527 assert!(
528 (gamma[idx] - s).abs() < 0.05,
529 "Warp should map target {tgt} to source {s}, got {}",
530 gamma[idx]
531 );
532 }
533 }
534
535 #[test]
536 fn test_monotone_warp_monotonicity() {
537 let m = 100;
538 let t = uniform_grid(m);
539 let source = vec![0.2, 0.8];
541 let target = vec![0.5, 0.6];
542 let gamma = monotone_landmark_warp(&source, &target, &t);
543 for j in 1..m {
544 assert!(
545 gamma[j] >= gamma[j - 1] - 1e-15,
546 "Warp must be monotone at j={j}: {} < {}",
547 gamma[j],
548 gamma[j - 1]
549 );
550 }
551 }
552
553 #[test]
554 fn test_monotone_warp_boundaries() {
555 let m = 50;
556 let t = uniform_grid(m);
557 let source = vec![0.3, 0.7];
558 let target = vec![0.4, 0.8];
559 let gamma = monotone_landmark_warp(&source, &target, &t);
560 assert!(
561 (gamma[0] - t[0]).abs() < 1e-15,
562 "Warp must start at domain start"
563 );
564 assert!(
565 (gamma[m - 1] - t[m - 1]).abs() < 1e-15,
566 "Warp must end at domain end"
567 );
568 }
569
570 #[test]
571 fn test_detect_peaks_sin() {
572 let m = 200;
573 let t = uniform_grid(m);
574 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
576 let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.1);
577 assert!(
578 !lms.is_empty(),
579 "Should detect at least one peak in sin(2πt)"
580 );
581 assert!(
582 (lms[0].position - 0.25).abs() < 0.02,
583 "Peak should be near 0.25, got {}",
584 lms[0].position
585 );
586 }
587
588 #[test]
589 fn test_detect_valleys() {
590 let m = 200;
591 let t = uniform_grid(m);
592 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
594 let lms = detect_landmarks(&curve, &t, LandmarkKind::Valley, 0.1);
595 assert!(
596 !lms.is_empty(),
597 "Should detect at least one valley in sin(2πt)"
598 );
599 assert!(
600 (lms[0].position - 0.75).abs() < 0.02,
601 "Valley should be near 0.75, got {}",
602 lms[0].position
603 );
604 }
605
606 #[test]
607 fn test_detect_zero_crossings() {
608 let m = 200;
609 let t = uniform_grid(m);
610 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
612 let lms = detect_landmarks(&curve, &t, LandmarkKind::ZeroCrossing, 0.0);
613 assert!(!lms.is_empty(), "Should detect zero crossings in sin(2πt)");
614 let has_half = lms.iter().any(|l| (l.position - 0.5).abs() < 0.02);
616 assert!(has_half, "Should detect zero crossing near t=0.5");
617 }
618
619 #[test]
620 fn test_registration_reduces_variability() {
621 let m = 100;
622 let n = 5;
623 let t = uniform_grid(m);
624
625 let shifts = [0.0, 0.02, -0.03, 0.04, -0.01];
627 let mut col_major = vec![0.0; n * m];
628 for i in 0..n {
629 for j in 0..m {
630 col_major[i + j * n] = (2.0 * PI * (t[j] - shifts[i])).sin();
631 }
632 }
633 let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
634
635 let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.5, 1);
636
637 let mut reg_peaks = Vec::new();
640 for i in 0..n {
641 let curve = result.registered.row(i);
642 let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.5);
643 if let Some(p) = lms.first() {
644 reg_peaks.push(p.position);
645 }
646 }
647
648 if reg_peaks.len() >= 2 {
649 let mean_pos: f64 = reg_peaks.iter().sum::<f64>() / reg_peaks.len() as f64;
650 let variance: f64 = reg_peaks
651 .iter()
652 .map(|&p| (p - mean_pos).powi(2))
653 .sum::<f64>()
654 / reg_peaks.len() as f64;
655 assert!(
656 variance < 0.01,
657 "After registration, peak position variance should be small, got {variance}"
658 );
659 }
660 }
661
662 #[test]
663 fn test_single_curve_identity() {
664 let m = 50;
665 let t = uniform_grid(m);
666 let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
667 let data = FdMatrix::from_column_major(curve.clone(), 1, m).unwrap();
668 let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.1, 1);
669 let max_diff: f64 = (0..m)
671 .map(|j| (result.registered[(0, j)] - data[(0, j)]).abs())
672 .fold(0.0_f64, f64::max);
673 assert!(
674 max_diff < 0.1,
675 "Single curve should be nearly unchanged, max_diff={max_diff}"
676 );
677 }
678
679 #[test]
680 fn test_no_landmarks_identity() {
681 let m = 50;
682 let t = uniform_grid(m);
683 let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
685 let landmarks = vec![vec![]];
686 let result = landmark_register(&data, &t, &landmarks, None);
687 for j in 0..m {
689 assert!(
690 (result.gammas[(0, j)] - t[j]).abs() < 1e-10,
691 "No-landmark warp should be identity at j={j}"
692 );
693 }
694 }
695
696 #[test]
699 fn test_monotone_warp_reference_scipy_pchip() {
700 let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
703 let warp = monotone_landmark_warp(&[0.3, 0.6], &[0.4, 0.7], &eval_11);
704
705 #[rustfmt::skip]
706 let scipy_ref = [
707 0.000000000000000, 0.064845278864971, 0.137206457925636,
708 0.215964408023483, 0.300000000000000, 0.390737116764514,
709 0.490606653620352, 0.600000000000000, 0.721164021164021,
710 0.855026455026455, 1.000000000000000,
711 ];
712
713 assert_eq!(warp.len(), 11);
714 for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
715 assert!(
716 (actual - expected).abs() < 0.01,
717 "warp[{j}]: got {actual:.6}, expected {expected:.6}"
718 );
719 }
720 }
721
722 #[test]
723 fn test_monotone_warp_reference_scipy_extreme() {
724 let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
727 let warp = monotone_landmark_warp(&[0.2, 0.8], &[0.5, 0.6], &eval_11);
728
729 #[rustfmt::skip]
730 let scipy_ref = [
731 0.000000000000000, 0.005903448275862, 0.025710344827586,
732 0.062565517241379, 0.119613793103448, 0.200000000000000,
733 0.800000000000000, 0.893750000000000, 0.955555555555556,
734 0.989583333333333, 1.000000000000000,
735 ];
736
737 assert_eq!(warp.len(), 11);
738 for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
739 assert!(
740 (actual - expected).abs() < 0.02,
741 "warp[{j}]: got {actual:.6}, expected {expected:.6}"
742 );
743 }
744
745 for j in 1..warp.len() {
747 assert!(
748 warp[j] >= warp[j - 1],
749 "Monotonicity violated at {j}: {} < {}",
750 warp[j],
751 warp[j - 1]
752 );
753 }
754 }
755}