Skip to main content

fdars_core/
landmark.rs

1//! Landmark-based registration for functional data.
2//!
3//! Aligns curves by mapping identified features (peaks, valleys, zero-crossings,
4//! inflection points) to common target positions. Uses monotone cubic Hermite
5//! interpolation (Fritsch-Carlson) to build smooth, monotone warping functions.
6//!
7//! - [`detect_landmarks`] — Detect features in a single curve
8//! - [`landmark_register`] — Register curves given known landmark positions
9//! - [`detect_and_register`] — Convenience: detect landmarks then register
10
11use crate::alignment::reparameterize_curve;
12use crate::matrix::FdMatrix;
13use crate::seasonal::{compute_prominence, find_peaks_1d};
14
15/// Kind of landmark feature to detect.
16#[derive(Debug, Clone, Copy, PartialEq)]
17#[non_exhaustive]
18pub enum LandmarkKind {
19    /// Local maximum.
20    Peak,
21    /// Local minimum.
22    Valley,
23    /// Sign change (zero-crossing via linear interpolation).
24    ZeroCrossing,
25    /// Inflection point (second derivative zero-crossing).
26    Inflection,
27    /// User-specified landmark (not auto-detected).
28    Custom,
29}
30
31/// A detected landmark on a curve.
32#[derive(Debug, Clone, PartialEq)]
33pub struct Landmark {
34    /// Position on the domain (t-value).
35    pub position: f64,
36    /// Kind of feature.
37    pub kind: LandmarkKind,
38    /// Curve value at the landmark.
39    pub value: f64,
40    /// Feature strength (prominence for peaks/valleys, 0.0 for others).
41    pub prominence: f64,
42}
43
44/// Result of landmark registration.
45#[derive(Debug, Clone, PartialEq)]
46#[non_exhaustive]
47pub struct LandmarkResult {
48    /// Registered (aligned) curves (n × m).
49    pub registered: FdMatrix,
50    /// Warping functions (n × m).
51    pub gammas: FdMatrix,
52    /// Detected landmarks per curve.
53    pub landmarks: Vec<Vec<Landmark>>,
54    /// Common target landmark positions.
55    pub target_landmarks: Vec<f64>,
56}
57
58// ─── Landmark Detection ─────────────────────────────────────────────────────
59
60/// Detect landmarks of a given kind in a single curve.
61///
62/// # Arguments
63/// * `curve` — Curve values (length m)
64/// * `argvals` — Evaluation points (length m)
65/// * `kind` — Type of landmark to detect
66/// * `min_prominence` — Minimum prominence to keep a peak/valley (ignored for other kinds)
67///
68/// # Returns
69/// Vector of detected landmarks, sorted by position.
70pub 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(), // Custom landmarks are user-supplied
87    }
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    // Negate curve, detect peaks, then negate back
112    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            // Linear interpolation to find exact zero crossing
138            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    // Compute second derivative via central differences
157    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    // Find zero crossings of second derivative
166    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            // Interpolate curve value at the inflection point
172            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
185// ─── Monotone Cubic Hermite Interpolation (Fritsch-Carlson) ─────────────────
186
187/// Fritsch-Carlson monotonicity adjustment of tangent slopes.
188///
189/// For each interval, if the secant is near-zero both tangents are zeroed;
190/// otherwise clamps (α²+β²) ≤ 9 to guarantee monotonicity.
191fn 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
209/// Evaluate the piecewise cubic Hermite interpolant at a single point.
210///
211/// Binary-searches `x_knots` for the interval, then evaluates
212/// the cubic Hermite basis with tangent slopes `d`.
213fn 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
239/// Build knot sequences from source/target landmarks, adding domain endpoints.
240///
241/// Returns `(x_knots, y_knots)` where `x_knots` = target positions, `y_knots` = source positions.
242fn 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
263/// Compute secant slopes and initial tangent slopes for Hermite interpolation.
264fn 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
292/// Build a monotone warping function that maps source landmarks to target landmarks.
293///
294/// Uses Fritsch-Carlson monotone cubic Hermite interpolation to ensure the
295/// resulting warping function is strictly monotone (a valid diffeomorphism).
296fn 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    // Post-process: clamp to domain and enforce monotonicity
318    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
330// ─── Registration ───────────────────────────────────────────────────────────
331
332/// Compute target landmark positions: use provided values, or mean of per-curve landmarks.
333fn 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
353/// Register curves using known landmark positions.
354///
355/// Builds monotone warping functions that map each curve's landmarks to common
356/// target positions, then reparameterizes the curves.
357///
358/// # Arguments
359/// * `data` — Functional data matrix (n × m)
360/// * `argvals` — Evaluation points (length m)
361/// * `landmarks` — Per-curve landmark positions (n vectors, each with K positions)
362/// * `target` — Target landmark positions (K values). If `None`, uses mean positions.
363///
364/// # Returns
365/// [`LandmarkResult`] containing registered curves, warps, and landmark info.
366pub 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    // Build warping functions and register
399    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
435/// Detect landmarks and register curves in one step.
436///
437/// # Arguments
438/// * `data` — Functional data matrix (n × m)
439/// * `argvals` — Evaluation points (length m)
440/// * `kind` — Type of landmark to detect
441/// * `min_prominence` — Minimum prominence for peak/valley detection
442/// * `expected_count` — Expected number of landmarks per curve (uses first N)
443///
444/// # Returns
445/// [`LandmarkResult`] with detected landmarks and registered curves.
446pub 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    // Detect landmarks for each curve
464    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    // Register using detected positions
480    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// ─── Tests ──────────────────────────────────────────────────────────────────
491
492#[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]; // same → identity
504        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        // gamma(target[k]) should equal source[k] so that f(gamma(t)) moves
523        // features from source positions to target positions
524        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        // Adversarial: big shifts
539        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        // sin(2πt) has peak at t≈0.25
574        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        // sin(2πt) has valley at t≈0.75
592        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        // sin(2πt) crosses zero at t=0, 0.5, 1.0
610        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        // The crossing near 0.5 should be found
614        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        // Create phase-shifted sinusoids: sin(2π(t - shift))
625        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        // After registration, peak positions should be more aligned
637        // Check by measuring variance of peak positions in registered curves
638        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        // Single curve registration should be close to identity
669        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        // Constant curve → no peaks
683        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        // With no landmarks, warp should be identity
687        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    // ── Reference-value tests (scipy PCHIP) ─────────────────────────────────
696
697    #[test]
698    fn test_monotone_warp_reference_scipy_pchip() {
699        // Reference: scipy.interpolate.PchipInterpolator([0,0.4,0.7,1], [0,0.3,0.6,1])
700        // evaluated at linspace(0,1,11)
701        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        // m < 3 should return empty
724        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]; // wrong length
733        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        // Custom kind always returns empty
741        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        // sin(2πt) has inflections at t≈0 and t≈0.5
749        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        // Check kind
753        assert!(lms
754            .iter()
755            .all(|l| matches!(l.kind, LandmarkKind::Inflection)));
756    }
757
758    #[test]
759    fn test_detect_inflections_short_curve() {
760        // m < 4 should return empty
761        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        // Small bumps should be filtered by high prominence
772        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        // k=2 branch
792        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        // Two coincident knots → delta should be 0
802        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]; // wrong length
819        let landmarks = vec![vec![0.5]];
820        let result = landmark_register(&data, &wrong_t, &landmarks, None);
821        // Should return early with cloned data
822        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        // Only provide 1 landmark vec for 2 curves
831        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        // Reference: scipy PchipInterpolator([0,0.5,0.6,1], [0,0.2,0.8,1])
875        // Extreme warp: source landmarks [0.2, 0.8] mapped to [0.5, 0.6]
876        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        // Verify monotonicity
896        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}