use crate::error::FdarError;
use crate::iter_maybe_parallel;
use crate::matrix::FdMatrix;
use crate::shapelet::z_normalize_window;
use rustfft::num_complex::Complex;
use rustfft::FftPlanner;
#[cfg(feature = "parallel")]
use rayon::iter::ParallelIterator;
const NORM_EPS: f64 = 1e-12;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct SbdResult {
pub distance: f64,
pub shift: isize,
}
#[must_use = "SBD result carries the distance and optimal shift"]
pub fn sbd(x: &[f64], y: &[f64]) -> Result<SbdResult, FdarError> {
if x.is_empty() || y.is_empty() {
return Err(FdarError::InvalidDimension {
parameter: "x/y",
expected: "non-empty series".to_string(),
actual: format!("x.len()={}, y.len()={}", x.len(), y.len()),
});
}
let x_z = z_normalize_window(x);
let y_z = z_normalize_window(y);
let norm_x = x_z.iter().map(|v| v * v).sum::<f64>().sqrt();
let norm_y = y_z.iter().map(|v| v * v).sum::<f64>().sqrt();
let denom = norm_x * norm_y;
if denom <= NORM_EPS {
return Ok(SbdResult {
distance: 1.0,
shift: 0,
});
}
let m = x.len().max(y.len());
let fft_len = (2 * m - 1).next_power_of_two();
let mut planner = FftPlanner::<f64>::new();
let fft = planner.plan_fft_forward(fft_len);
let ifft = planner.plan_fft_inverse(fft_len);
let mut xbuf = vec![Complex::new(0.0, 0.0); fft_len];
let mut ybuf = vec![Complex::new(0.0, 0.0); fft_len];
for (b, &v) in xbuf.iter_mut().zip(x_z.iter()) {
b.re = v;
}
for (b, &v) in ybuf.iter_mut().zip(y_z.iter()) {
b.re = v;
}
fft.process(&mut xbuf);
fft.process(&mut ybuf);
for (xb, yb) in xbuf.iter_mut().zip(ybuf.iter()) {
*xb *= yb.conj();
}
ifft.process(&mut xbuf);
let scale = 1.0 / (fft_len as f64 * denom);
let m_signed = m as isize;
let fft_len_signed = fft_len as isize;
let mut best_ncc = f64::NEG_INFINITY;
let mut best_shift: isize = 0;
for k in 0..m {
let ncc = xbuf[k].re * scale;
if ncc > best_ncc {
best_ncc = ncc;
best_shift = k as isize;
}
}
for k in (fft_len - (m - 1))..fft_len {
let ncc = xbuf[k].re * scale;
if ncc > best_ncc {
best_ncc = ncc;
best_shift = k as isize - fft_len_signed;
}
}
debug_assert!(best_shift.abs() < m_signed);
let max_ncc = best_ncc.clamp(-1.0, 1.0);
Ok(SbdResult {
distance: 1.0 - max_ncc,
shift: best_shift,
})
}
#[must_use = "the SBD distance matrix is the whole point of calling this"]
pub fn sbd_distance_matrix(data: &FdMatrix) -> Result<FdMatrix, FdarError> {
let n = data.nrows();
let m = data.ncols();
if n == 0 || m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "non-empty matrix (n>0 rows, m>0 cols)".to_string(),
actual: format!("{n}x{m}"),
});
}
let upper: Vec<f64> = iter_maybe_parallel!(0..n)
.flat_map(|i| {
let mut xrow = vec![0.0f64; m];
data.row_to_buf(i, &mut xrow);
let mut out = Vec::with_capacity(n.saturating_sub(i + 1));
let mut yrow = vec![0.0f64; m];
for j in (i + 1)..n {
data.row_to_buf(j, &mut yrow);
let d = sbd(&xrow, &yrow).map(|r| r.distance).unwrap_or(1.0);
out.push(d);
}
out
})
.collect();
let mut dist = FdMatrix::zeros(n, n);
let mut idx = 0;
for i in 0..n {
for j in (i + 1)..n {
let d = upper[idx];
dist[(i, j)] = d;
dist[(j, i)] = d;
idx += 1;
}
}
Ok(dist)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_x() -> Vec<f64> {
vec![1.0, 3.0, 2.0, 5.0, 4.0, 1.0, 2.0, 6.0]
}
#[test]
fn test_sbd_self_zero() {
let x = sample_x();
let r = sbd(&x, &x).unwrap();
assert!(
r.distance.abs() < 1e-10,
"sbd(x,x) must be 0, got {}",
r.distance
);
assert_eq!(r.shift, 0);
}
#[test]
fn test_sbd_symmetric() {
let x = sample_x();
let y = vec![2.0, 1.0, 4.0, 3.0, 0.0, 5.0, 1.0, 2.0];
let dxy = sbd(&x, &y).unwrap().distance;
let dyx = sbd(&y, &x).unwrap().distance;
assert!(
(dxy - dyx).abs() < 1e-10,
"sbd must be symmetric: {dxy} vs {dyx}"
);
}
#[test]
fn test_sbd_shifted_copy() {
let n = 128usize;
let k = 5usize;
let x: Vec<f64> = (0..n)
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / 16.0).sin())
.collect();
let mut y = vec![0.0; n];
y[k..n].copy_from_slice(&x[..(n - k)]);
let r = sbd(&x, &y).unwrap();
assert_eq!(
r.shift.unsigned_abs(),
k,
"expected |shift| == {k}, got {}",
r.shift
);
assert!(
r.shift.unsigned_abs() < n,
"shift must be a signed lag, not a fft_len wrap: {}",
r.shift
);
assert!(
r.distance < 0.05,
"shifted copy must be near 0, got {}",
r.distance
);
}
#[test]
fn test_sbd_offset_scale_invariant() {
let x = sample_x();
let x_off: Vec<f64> = x.iter().map(|v| v + 100.0).collect();
let d_off = sbd(&x, &x_off).unwrap().distance;
assert!(d_off < 1e-10, "SBD must be offset-invariant: {d_off}");
let x_sc: Vec<f64> = x.iter().map(|v| v * 50.0).collect();
let d_sc = sbd(&x, &x_sc).unwrap().distance;
assert!(d_sc < 1e-10, "SBD must be scale-invariant: {d_sc}");
}
#[test]
fn test_sbd_ncc_bounds() {
let x = sample_x();
let cases = [
vec![6.0, 2.0, 1.0, 4.0, 5.0, 0.0, 3.0, 1.0],
vec![-1.0, -3.0, -2.0, -5.0, -4.0, -1.0, -2.0, -6.0], vec![1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0],
];
for y in &cases {
let r = sbd(&x, y).unwrap();
assert!(
(0.0..=2.0).contains(&r.distance),
"distance out of [0,2]: {}",
r.distance
);
let ncc = 1.0 - r.distance;
assert!(
(-1.0 - 1e-10..=1.0 + 1e-10).contains(&ncc),
"NCCc out of [-1,1]: {ncc}"
);
}
}
#[test]
fn test_sbd_constant_series() {
let x = sample_x();
let c = vec![7.0; x.len()];
let r = sbd(&x, &c).unwrap();
assert_eq!(r.distance, 1.0);
assert_eq!(r.shift, 0);
assert!(!r.distance.is_nan());
let r2 = sbd(&c, &c).unwrap();
assert_eq!(r2.distance, 1.0);
assert_eq!(r2.shift, 0);
}
fn sample_matrix() -> FdMatrix {
let rows = [
vec![1.0, 2.0, 3.0, 2.0, 1.0, 0.0],
vec![0.0, 1.0, 2.0, 3.0, 2.0, 1.0],
vec![5.0, 4.0, 3.0, 2.0, 1.0, 0.0],
vec![2.0, 2.0, 4.0, 1.0, 3.0, 5.0],
];
let n = rows.len();
let m = rows[0].len();
let mut mat = FdMatrix::zeros(n, m);
for (i, r) in rows.iter().enumerate() {
for (j, &v) in r.iter().enumerate() {
mat[(i, j)] = v;
}
}
mat
}
#[test]
fn test_sbd_matrix_symmetric_zero_diag() {
let mat = sample_matrix();
let d = sbd_distance_matrix(&mat).unwrap();
let n = mat.nrows();
for i in 0..n {
assert!(d[(i, i)].abs() < 1e-15, "diagonal not zero at {i}");
for j in 0..n {
assert!(
(d[(i, j)] - d[(j, i)]).abs() < 1e-15,
"not symmetric at ({i},{j})"
);
}
}
let mut xrow = vec![0.0; mat.ncols()];
let mut yrow = vec![0.0; mat.ncols()];
for i in 0..n {
mat.row_to_buf(i, &mut xrow);
for j in 0..n {
if i == j {
continue;
}
mat.row_to_buf(j, &mut yrow);
let expected = sbd(&xrow, &yrow).unwrap().distance;
assert!(
(d[(i, j)] - expected).abs() < 1e-15,
"matrix entry mismatch at ({i},{j})"
);
}
}
}
#[test]
fn test_sbd_matrix_parallel_matches() {
let mat = sample_matrix();
let a = sbd_distance_matrix(&mat).unwrap();
let b = sbd_distance_matrix(&mat).unwrap();
let n = mat.nrows();
for i in 0..n {
for j in 0..n {
assert_eq!(
a[(i, j)].to_bits(),
b[(i, j)].to_bits(),
"non-deterministic at ({i},{j})"
);
}
}
let mut xrow = vec![0.0; mat.ncols()];
let mut yrow = vec![0.0; mat.ncols()];
for i in 0..n {
mat.row_to_buf(i, &mut xrow);
for j in (i + 1)..n {
mat.row_to_buf(j, &mut yrow);
let d = sbd(&xrow, &yrow).unwrap().distance;
assert_eq!(a[(i, j)].to_bits(), d.to_bits());
}
}
}
}