use crate::alignment::reparameterize_curve;
use crate::matrix::FdMatrix;
use crate::seasonal::{compute_prominence, find_peaks_1d};
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum LandmarkKind {
Peak,
Valley,
ZeroCrossing,
Inflection,
Custom,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Landmark {
pub position: f64,
pub kind: LandmarkKind,
pub value: f64,
pub prominence: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct LandmarkResult {
pub registered: FdMatrix,
pub gammas: FdMatrix,
pub landmarks: Vec<Vec<Landmark>>,
pub target_landmarks: Vec<f64>,
}
pub fn detect_landmarks(
curve: &[f64],
argvals: &[f64],
kind: LandmarkKind,
min_prominence: f64,
) -> Vec<Landmark> {
let m = curve.len();
if m < 3 || argvals.len() != m {
return Vec::new();
}
match kind {
LandmarkKind::Peak => detect_peaks(curve, argvals, min_prominence),
LandmarkKind::Valley => detect_valleys(curve, argvals, min_prominence),
LandmarkKind::ZeroCrossing => detect_zero_crossings(curve, argvals),
LandmarkKind::Inflection => detect_inflections(curve, argvals),
LandmarkKind::Custom => Vec::new(), }
}
fn detect_peaks(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
let peak_indices = find_peaks_1d(curve, 1);
peak_indices
.into_iter()
.filter_map(|idx| {
let prom = compute_prominence(curve, idx);
if prom >= min_prominence {
Some(Landmark {
position: argvals[idx],
kind: LandmarkKind::Peak,
value: curve[idx],
prominence: prom,
})
} else {
None
}
})
.collect()
}
fn detect_valleys(curve: &[f64], argvals: &[f64], min_prominence: f64) -> Vec<Landmark> {
let negated: Vec<f64> = curve.iter().map(|&v| -v).collect();
let peak_indices = find_peaks_1d(&negated, 1);
peak_indices
.into_iter()
.filter_map(|idx| {
let prom = compute_prominence(&negated, idx);
if prom >= min_prominence {
Some(Landmark {
position: argvals[idx],
kind: LandmarkKind::Valley,
value: curve[idx],
prominence: prom,
})
} else {
None
}
})
.collect()
}
fn detect_zero_crossings(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
let m = curve.len();
let mut landmarks = Vec::new();
for i in 0..(m - 1) {
if curve[i] * curve[i + 1] < 0.0 {
let frac = curve[i].abs() / (curve[i].abs() + curve[i + 1].abs());
let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
landmarks.push(Landmark {
position: t,
kind: LandmarkKind::ZeroCrossing,
value: 0.0,
prominence: 0.0,
});
}
}
landmarks
}
fn detect_inflections(curve: &[f64], argvals: &[f64]) -> Vec<Landmark> {
let m = curve.len();
if m < 4 {
return Vec::new();
}
let mut d2 = vec![0.0; m];
for i in 1..(m - 1) {
let h1 = argvals[i] - argvals[i - 1];
let h2 = argvals[i + 1] - argvals[i];
d2[i] = 2.0
* (curve[i + 1] / (h2 * (h1 + h2)) - curve[i] / (h1 * h2)
+ curve[i - 1] / (h1 * (h1 + h2)));
}
let mut landmarks = Vec::new();
for i in 1..(m - 2) {
if d2[i] * d2[i + 1] < 0.0 {
let frac = d2[i].abs() / (d2[i].abs() + d2[i + 1].abs());
let t = argvals[i] + frac * (argvals[i + 1] - argvals[i]);
let val = curve[i]
+ (curve[i + 1] - curve[i]) * (t - argvals[i]) / (argvals[i + 1] - argvals[i]);
landmarks.push(Landmark {
position: t,
kind: LandmarkKind::Inflection,
value: val,
prominence: 0.0,
});
}
}
landmarks
}
fn fritsch_carlson_fix(delta: &[f64], d: &mut [f64]) {
for i in 0..delta.len() {
if delta[i].abs() < 1e-15 {
d[i] = 0.0;
d[i + 1] = 0.0;
} else {
let alpha = d[i] / delta[i];
let beta = d[i + 1] / delta[i];
let s2 = alpha * alpha + beta * beta;
if s2 > 9.0 {
let tau = 3.0 / s2.sqrt();
d[i] = tau * alpha * delta[i];
d[i + 1] = tau * beta * delta[i];
}
}
}
}
fn hermite_eval(t: f64, x_knots: &[f64], y_knots: &[f64], d: &[f64]) -> f64 {
let k = x_knots.len();
let seg = if t <= x_knots[0] {
0
} else if t >= x_knots[k - 1] {
k - 2
} else {
match x_knots.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
Ok(i) => i.min(k - 2),
Err(i) => (i - 1).min(k - 2),
}
};
let h = x_knots[seg + 1] - x_knots[seg];
if h.abs() < 1e-15 {
return y_knots[seg];
}
let s = (t - x_knots[seg]) / h;
let h00 = (1.0 + 2.0 * s) * (1.0 - s) * (1.0 - s);
let h10 = s * (1.0 - s) * (1.0 - s);
let h01 = s * s * (3.0 - 2.0 * s);
let h11 = s * s * (s - 1.0);
h00 * y_knots[seg] + h10 * h * d[seg] + h01 * y_knots[seg + 1] + h11 * h * d[seg + 1]
}
fn build_hermite_knots(
source: &[f64],
target: &[f64],
t0: f64,
t_end: f64,
) -> (Vec<f64>, Vec<f64>) {
let mut x_knots = Vec::with_capacity(target.len() + 2);
let mut y_knots = Vec::with_capacity(source.len() + 2);
x_knots.push(t0);
y_knots.push(t0);
for (&s, &t) in source.iter().zip(target.iter()) {
if t > t0 && t < t_end {
x_knots.push(t);
y_knots.push(s);
}
}
x_knots.push(t_end);
y_knots.push(t_end);
(x_knots, y_knots)
}
fn hermite_tangents(x_knots: &[f64], y_knots: &[f64]) -> (Vec<f64>, Vec<f64>) {
let k = x_knots.len();
let mut delta = vec![0.0; k - 1];
for i in 0..(k - 1) {
let dx = x_knots[i + 1] - x_knots[i];
delta[i] = if dx.abs() < 1e-15 {
0.0
} else {
(y_knots[i + 1] - y_knots[i]) / dx
};
}
let mut d = vec![0.0; k];
if k == 2 {
d[0] = delta[0];
d[1] = delta[0];
} else {
d[0] = delta[0];
d[k - 1] = delta[k - 2];
for i in 1..(k - 1) {
d[i] = (delta[i - 1] + delta[i]) / 2.0;
}
}
fritsch_carlson_fix(&delta, &mut d);
(delta, d)
}
fn monotone_landmark_warp(source: &[f64], target: &[f64], argvals: &[f64]) -> Vec<f64> {
let m = argvals.len();
if source.is_empty() || source.len() != target.len() {
return argvals.to_vec();
}
let t0 = argvals[0];
let t_end = argvals[m - 1];
let (x_knots, y_knots) = build_hermite_knots(source, target, t0, t_end);
if x_knots.len() < 2 {
return argvals.to_vec();
}
let (_delta, d) = hermite_tangents(&x_knots, &y_knots);
let mut gamma: Vec<f64> = argvals
.iter()
.map(|&t| hermite_eval(t, &x_knots, &y_knots, &d))
.collect();
gamma[0] = t0;
gamma[m - 1] = t_end;
for i in 1..m {
gamma[i] = gamma[i].clamp(t0, t_end);
if gamma[i] < gamma[i - 1] {
gamma[i] = gamma[i - 1];
}
}
gamma
}
fn compute_target_landmarks(landmarks: &[Vec<f64>], target: Option<&[f64]>, n: usize) -> Vec<f64> {
if let Some(t) = target {
return t.to_vec();
}
let min_count = landmarks.iter().map(std::vec::Vec::len).min().unwrap_or(0);
if min_count == 0 {
return Vec::new();
}
let mut mean_pos = vec![0.0; min_count];
for lm in landmarks {
for (j, &pos) in lm.iter().take(min_count).enumerate() {
mean_pos[j] += pos;
}
}
for v in &mut mean_pos {
*v /= n as f64;
}
mean_pos
}
pub fn landmark_register(
data: &FdMatrix,
argvals: &[f64],
landmarks: &[Vec<f64>],
target: Option<&[f64]>,
) -> LandmarkResult {
let (n, m) = data.shape();
if n == 0 || m == 0 || argvals.len() != m || landmarks.len() != n {
return LandmarkResult {
registered: data.clone(),
gammas: FdMatrix::zeros(n, m),
landmarks: landmarks
.iter()
.map(|lm| {
lm.iter()
.map(|&p| Landmark {
position: p,
kind: LandmarkKind::Custom,
value: 0.0,
prominence: 0.0,
})
.collect()
})
.collect(),
target_landmarks: target.unwrap_or(&[]).to_vec(),
};
}
let target_pos = compute_target_landmarks(landmarks, target, n);
let k = target_pos.len();
let mut registered = FdMatrix::zeros(n, m);
let mut gammas = FdMatrix::zeros(n, m);
let mut landmark_info = Vec::with_capacity(n);
for i in 0..n {
let source: Vec<f64> = landmarks[i].iter().take(k).copied().collect();
let gamma = monotone_landmark_warp(&source, &target_pos, argvals);
let fi = data.row(i);
let f_aligned = reparameterize_curve(&fi, argvals, &gamma);
for j in 0..m {
registered[(i, j)] = f_aligned[j];
gammas[(i, j)] = gamma[j];
}
landmark_info.push(
landmarks[i]
.iter()
.map(|&p| Landmark {
position: p,
kind: LandmarkKind::Custom,
value: 0.0,
prominence: 0.0,
})
.collect(),
);
}
LandmarkResult {
registered,
gammas,
landmarks: landmark_info,
target_landmarks: target_pos,
}
}
pub fn detect_and_register(
data: &FdMatrix,
argvals: &[f64],
kind: LandmarkKind,
min_prominence: f64,
expected_count: usize,
) -> LandmarkResult {
let (n, m) = data.shape();
if n == 0 || m == 0 || argvals.len() != m {
return LandmarkResult {
registered: data.clone(),
gammas: FdMatrix::zeros(n, m),
landmarks: Vec::new(),
target_landmarks: Vec::new(),
};
}
let mut all_landmarks = Vec::with_capacity(n);
let mut all_positions = Vec::with_capacity(n);
for i in 0..n {
let curve = data.row(i);
let lms = detect_landmarks(&curve, argvals, kind, min_prominence);
let positions: Vec<f64> = lms
.iter()
.take(expected_count)
.map(|l| l.position)
.collect();
all_positions.push(positions);
all_landmarks.push(lms);
}
let result = landmark_register(data, argvals, &all_positions, None);
LandmarkResult {
registered: result.registered,
gammas: result.gammas,
landmarks: all_landmarks,
target_landmarks: result.target_landmarks,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::uniform_grid;
use std::f64::consts::PI;
#[test]
fn test_monotone_warp_identity() {
let m = 50;
let t = uniform_grid(m);
let source = vec![0.25, 0.5, 0.75];
let target = vec![0.25, 0.5, 0.75]; let gamma = monotone_landmark_warp(&source, &target, &t);
for j in 0..m {
assert!(
(gamma[j] - t[j]).abs() < 1e-10,
"Identity warp should give gamma(t)=t at j={j}: got {}",
gamma[j]
);
}
}
#[test]
fn test_monotone_warp_hits_landmarks() {
let m = 100;
let t = uniform_grid(m);
let source = vec![0.3, 0.6];
let target = vec![0.4, 0.7];
let gamma = monotone_landmark_warp(&source, &target, &t);
for (&s, &tgt) in source.iter().zip(target.iter()) {
let idx = (tgt * (m - 1) as f64).round() as usize;
assert!(
(gamma[idx] - s).abs() < 0.05,
"Warp should map target {tgt} to source {s}, got {}",
gamma[idx]
);
}
}
#[test]
fn test_monotone_warp_monotonicity() {
let m = 100;
let t = uniform_grid(m);
let source = vec![0.2, 0.8];
let target = vec![0.5, 0.6];
let gamma = monotone_landmark_warp(&source, &target, &t);
for j in 1..m {
assert!(
gamma[j] >= gamma[j - 1] - 1e-15,
"Warp must be monotone at j={j}: {} < {}",
gamma[j],
gamma[j - 1]
);
}
}
#[test]
fn test_monotone_warp_boundaries() {
let m = 50;
let t = uniform_grid(m);
let source = vec![0.3, 0.7];
let target = vec![0.4, 0.8];
let gamma = monotone_landmark_warp(&source, &target, &t);
assert!(
(gamma[0] - t[0]).abs() < 1e-15,
"Warp must start at domain start"
);
assert!(
(gamma[m - 1] - t[m - 1]).abs() < 1e-15,
"Warp must end at domain end"
);
}
#[test]
fn test_detect_peaks_sin() {
let m = 200;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.1);
assert!(
!lms.is_empty(),
"Should detect at least one peak in sin(2πt)"
);
assert!(
(lms[0].position - 0.25).abs() < 0.02,
"Peak should be near 0.25, got {}",
lms[0].position
);
}
#[test]
fn test_detect_valleys() {
let m = 200;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
let lms = detect_landmarks(&curve, &t, LandmarkKind::Valley, 0.1);
assert!(
!lms.is_empty(),
"Should detect at least one valley in sin(2πt)"
);
assert!(
(lms[0].position - 0.75).abs() < 0.02,
"Valley should be near 0.75, got {}",
lms[0].position
);
}
#[test]
fn test_detect_zero_crossings() {
let m = 200;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
let lms = detect_landmarks(&curve, &t, LandmarkKind::ZeroCrossing, 0.0);
assert!(!lms.is_empty(), "Should detect zero crossings in sin(2πt)");
let has_half = lms.iter().any(|l| (l.position - 0.5).abs() < 0.02);
assert!(has_half, "Should detect zero crossing near t=0.5");
}
#[test]
fn test_registration_reduces_variability() {
let m = 100;
let n = 5;
let t = uniform_grid(m);
let shifts = [0.0, 0.02, -0.03, 0.04, -0.01];
let mut col_major = vec![0.0; n * m];
for i in 0..n {
for j in 0..m {
col_major[i + j * n] = (2.0 * PI * (t[j] - shifts[i])).sin();
}
}
let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.5, 1);
let mut reg_peaks = Vec::new();
for i in 0..n {
let curve = result.registered.row(i);
let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.5);
if let Some(p) = lms.first() {
reg_peaks.push(p.position);
}
}
if reg_peaks.len() >= 2 {
let mean_pos: f64 = reg_peaks.iter().sum::<f64>() / reg_peaks.len() as f64;
let variance: f64 = reg_peaks
.iter()
.map(|&p| (p - mean_pos).powi(2))
.sum::<f64>()
/ reg_peaks.len() as f64;
assert!(
variance < 0.01,
"After registration, peak position variance should be small, got {variance}"
);
}
}
#[test]
fn test_single_curve_identity() {
let m = 50;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
let data = FdMatrix::from_column_major(curve.clone(), 1, m).unwrap();
let result = detect_and_register(&data, &t, LandmarkKind::Peak, 0.1, 1);
let max_diff: f64 = (0..m)
.map(|j| (result.registered[(0, j)] - data[(0, j)]).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff < 0.1,
"Single curve should be nearly unchanged, max_diff={max_diff}"
);
}
#[test]
fn test_no_landmarks_identity() {
let m = 50;
let t = uniform_grid(m);
let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
let landmarks = vec![vec![]];
let result = landmark_register(&data, &t, &landmarks, None);
for j in 0..m {
assert!(
(result.gammas[(0, j)] - t[j]).abs() < 1e-10,
"No-landmark warp should be identity at j={j}"
);
}
}
#[test]
fn test_monotone_warp_reference_scipy_pchip() {
let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
let warp = monotone_landmark_warp(&[0.3, 0.6], &[0.4, 0.7], &eval_11);
#[rustfmt::skip]
let scipy_ref = [
0.000000000000000, 0.064845278864971, 0.137206457925636,
0.215964408023483, 0.300000000000000, 0.390737116764514,
0.490606653620352, 0.600000000000000, 0.721164021164021,
0.855026455026455, 1.000000000000000,
];
assert_eq!(warp.len(), 11);
for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
assert!(
(actual - expected).abs() < 0.01,
"warp[{j}]: got {actual:.6}, expected {expected:.6}"
);
}
}
#[test]
fn test_detect_landmarks_short_curve() {
let t = vec![0.0, 1.0];
let curve = vec![1.0, 2.0];
assert!(detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.0).is_empty());
}
#[test]
fn test_detect_landmarks_mismatched_lengths() {
let t = vec![0.0, 0.5, 1.0];
let curve = vec![1.0, 2.0]; assert!(detect_landmarks(&curve, &t, LandmarkKind::Peak, 0.0).is_empty());
}
#[test]
fn test_detect_landmarks_custom_kind() {
let t = uniform_grid(50);
let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
assert!(detect_landmarks(&curve, &t, LandmarkKind::Custom, 0.0).is_empty());
}
#[test]
fn test_detect_inflections() {
let m = 200;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
let lms = detect_landmarks(&curve, &t, LandmarkKind::Inflection, 0.0);
assert!(!lms.is_empty(), "Should detect inflection points");
assert!(lms
.iter()
.all(|l| matches!(l.kind, LandmarkKind::Inflection)));
}
#[test]
fn test_detect_inflections_short_curve() {
let t = vec![0.0, 0.5, 1.0];
let curve = vec![0.0, 1.0, 0.0];
let lms = detect_landmarks(&curve, &t, LandmarkKind::Inflection, 0.0);
assert!(lms.is_empty());
}
#[test]
fn test_detect_peaks_high_prominence_filter() {
let m = 200;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| 0.01 * (2.0 * PI * ti).sin()).collect();
let lms = detect_landmarks(&curve, &t, LandmarkKind::Peak, 1.0);
assert!(lms.is_empty(), "High prominence should filter small bumps");
}
#[test]
fn test_detect_valleys_high_prominence_filter() {
let m = 200;
let t = uniform_grid(m);
let curve: Vec<f64> = t.iter().map(|&ti| 0.01 * (2.0 * PI * ti).sin()).collect();
let lms = detect_landmarks(&curve, &t, LandmarkKind::Valley, 1.0);
assert!(
lms.is_empty(),
"High prominence should filter small valleys"
);
}
#[test]
fn test_hermite_tangents_two_points() {
let (delta, d) = hermite_tangents(&[0.0, 1.0], &[0.0, 1.0]);
assert_eq!(delta.len(), 1);
assert!((delta[0] - 1.0).abs() < 1e-15);
assert!((d[0] - 1.0).abs() < 1e-15);
assert!((d[1] - 1.0).abs() < 1e-15);
}
#[test]
fn test_hermite_tangents_zero_dx() {
let (delta, _d) = hermite_tangents(&[0.5, 0.5, 1.0], &[1.0, 2.0, 3.0]);
assert!((delta[0]).abs() < 1e-10, "Zero dx should give zero delta");
}
#[test]
fn test_landmark_register_empty_data() {
let data = FdMatrix::zeros(0, 0);
let result = landmark_register(&data, &[], &[], None);
assert_eq!(result.registered.nrows(), 0);
}
#[test]
fn test_landmark_register_mismatched_argvals() {
let m = 10;
let _t = uniform_grid(m);
let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
let wrong_t = vec![0.0; m + 1]; let landmarks = vec![vec![0.5]];
let result = landmark_register(&data, &wrong_t, &landmarks, None);
assert_eq!(result.registered.nrows(), 1);
}
#[test]
fn test_landmark_register_mismatched_n_landmarks() {
let m = 10;
let t = uniform_grid(m);
let data = FdMatrix::from_column_major(vec![1.0; 2 * m], 2, m).unwrap();
let landmarks = vec![vec![0.5]];
let result = landmark_register(&data, &t, &landmarks, None);
assert_eq!(result.registered.nrows(), 2);
}
#[test]
fn test_landmark_register_with_target() {
let m = 100;
let n = 3;
let t = uniform_grid(m);
let mut col_major = vec![0.0; n * m];
for i in 0..n {
for j in 0..m {
col_major[i + j * n] = (2.0 * PI * (t[j] - 0.02 * i as f64)).sin();
}
}
let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
let landmarks = vec![vec![0.25], vec![0.27], vec![0.29]];
let target = vec![0.25];
let result = landmark_register(&data, &t, &landmarks, Some(&target));
assert_eq!(result.target_landmarks, vec![0.25]);
assert_eq!(result.registered.shape(), (n, m));
}
#[test]
fn test_detect_and_register_empty() {
let data = FdMatrix::zeros(0, 0);
let result = detect_and_register(&data, &[], LandmarkKind::Peak, 0.1, 1);
assert_eq!(result.registered.nrows(), 0);
assert!(result.landmarks.is_empty());
}
#[test]
fn test_detect_and_register_mismatched_argvals() {
let m = 10;
let data = FdMatrix::from_column_major(vec![1.0; m], 1, m).unwrap();
let wrong_t = vec![0.0; m + 1];
let result = detect_and_register(&data, &wrong_t, LandmarkKind::Peak, 0.1, 1);
assert_eq!(result.registered.nrows(), 1);
}
#[test]
fn test_monotone_warp_reference_scipy_extreme() {
let eval_11: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
let warp = monotone_landmark_warp(&[0.2, 0.8], &[0.5, 0.6], &eval_11);
#[rustfmt::skip]
let scipy_ref = [
0.000000000000000, 0.005903448275862, 0.025710344827586,
0.062565517241379, 0.119613793103448, 0.200000000000000,
0.800000000000000, 0.893750000000000, 0.955555555555556,
0.989583333333333, 1.000000000000000,
];
assert_eq!(warp.len(), 11);
for (j, (&actual, &expected)) in warp.iter().zip(scipy_ref.iter()).enumerate() {
assert!(
(actual - expected).abs() < 0.02,
"warp[{j}]: got {actual:.6}, expected {expected:.6}"
);
}
for j in 1..warp.len() {
assert!(
warp[j] >= warp[j - 1],
"Monotonicity violated at {j}: {} < {}",
warp[j],
warp[j - 1]
);
}
}
}