use crate::errors::{CurveError, TypeError};
use crate::math::tridiag::thomas;
use super::Interpolator;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SplineBoundary {
Natural,
NotAKnot,
Clamped {
first: f64,
last: f64,
},
}
#[derive(Debug, Clone)]
pub struct CubicSpline {
times: Vec<f64>,
values: Vec<f64>,
second: Vec<f64>,
boundary: SplineBoundary,
}
impl CubicSpline {
pub fn new(knots: &[(f64, f64)], boundary: SplineBoundary) -> Result<Self, CurveError> {
if knots.len() < 2 {
return Err(CurveError::TooFewNodes { found: knots.len() });
}
if let SplineBoundary::Clamped { first, last } = boundary {
if !first.is_finite() {
return Err(CurveError::Type(TypeError::NonFinite {
name: "clamped boundary slope (first)",
}));
}
if !last.is_finite() {
return Err(CurveError::Type(TypeError::NonFinite {
name: "clamped boundary slope (last)",
}));
}
}
let n = knots.len();
let mut times = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
for (i, &(t, y)) in knots.iter().enumerate() {
if !t.is_finite() {
return Err(CurveError::InvalidTime { t });
}
if !y.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: i,
value: y,
});
}
if i > 0 {
let prev = times[i - 1];
#[allow(clippy::float_cmp)]
let is_duplicate = t == prev;
if is_duplicate {
return Err(CurveError::DuplicateNode { t });
}
if t < prev {
return Err(CurveError::NodesNotIncreasing { at_index: i });
}
}
times.push(t);
values.push(y);
}
let second = solve_second_derivatives(×, &values, boundary)?;
Ok(Self {
times,
values,
second,
boundary,
})
}
#[must_use]
#[inline]
pub fn len(&self) -> usize {
self.times.len()
}
#[must_use]
#[inline]
pub fn is_empty(&self) -> bool {
self.times.is_empty()
}
#[must_use]
#[inline]
pub fn boundary(&self) -> SplineBoundary {
self.boundary
}
#[inline]
fn locate(&self, t: f64) -> usize {
let n = self.times.len();
if t <= self.times[0] {
return 0;
}
if t >= self.times[n - 1] {
return n - 2;
}
let mut lo = 0_usize;
let mut hi = n - 1;
while hi - lo > 1 {
let mid = lo + (hi - lo) / 2;
if self.times[mid] <= t {
lo = mid;
} else {
hi = mid;
}
}
lo
}
}
impl Interpolator for CubicSpline {
fn build(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
Self::new(knots, SplineBoundary::Natural)
}
#[allow(clippy::many_single_char_names)]
fn eval(&self, t: f64) -> f64 {
let n = self.times.len();
if t <= self.times[0] {
return self.values[0];
}
if t >= self.times[n - 1] {
return self.values[n - 1];
}
let i = self.locate(t);
let t_lo = self.times[i];
let t_hi = self.times[i + 1];
let h = t_hi - t_lo;
let m_lo = self.second[i];
let m_hi = self.second[i + 1];
let y_lo = self.values[i];
let y_hi = self.values[i + 1];
let a = t_hi - t;
let b = t - t_lo;
(a * a * a * m_lo + b * b * b * m_hi) / (6.0 * h)
+ (y_lo / h - m_lo * h / 6.0) * a
+ (y_hi / h - m_hi * h / 6.0) * b
}
#[allow(clippy::many_single_char_names)]
fn deriv(&self, t: f64) -> Option<f64> {
let n = self.times.len();
if t < self.times[0] || t > self.times[n - 1] {
return Some(0.0);
}
let i = self.locate(t);
let t_lo = self.times[i];
let t_hi = self.times[i + 1];
let h = t_hi - t_lo;
let m_lo = self.second[i];
let m_hi = self.second[i + 1];
let y_lo = self.values[i];
let y_hi = self.values[i + 1];
let a = t_hi - t;
let b = t - t_lo;
Some(
-(a * a) * m_lo / (2.0 * h)
+ (b * b) * m_hi / (2.0 * h)
+ (y_hi - y_lo) / h
+ (m_lo - m_hi) * h / 6.0,
)
}
}
fn solve_second_derivatives(
times: &[f64],
values: &[f64],
boundary: SplineBoundary,
) -> Result<Vec<f64>, CurveError> {
let n = times.len();
if n == 2 {
return Ok(vec![0.0, 0.0]);
}
let mut h = vec![0.0_f64; n - 1];
let mut slope = vec![0.0_f64; n - 1];
for i in 0..(n - 1) {
h[i] = times[i + 1] - times[i];
slope[i] = (values[i + 1] - values[i]) / h[i];
}
match boundary {
SplineBoundary::Natural => solve_natural(&h, &slope, n),
SplineBoundary::Clamped { first, last } => solve_clamped(&h, &slope, n, first, last),
SplineBoundary::NotAKnot => solve_not_a_knot(&h, &slope, n),
}
}
fn solve_natural(h: &[f64], slope: &[f64], n: usize) -> Result<Vec<f64>, CurveError> {
let m = n - 2;
if m == 0 {
return Ok(vec![0.0; n]);
}
let mut sub = vec![0.0_f64; m];
let mut diag = vec![0.0_f64; m];
let mut sup = vec![0.0_f64; m];
let mut rhs = vec![0.0_f64; m];
for k in 0..m {
let i = k + 1;
sub[k] = if k == 0 { 0.0 } else { h[i - 1] };
diag[k] = 2.0 * (h[i - 1] + h[i]);
sup[k] = if k == m - 1 { 0.0 } else { h[i] };
rhs[k] = 6.0 * (slope[i] - slope[i - 1]);
}
let interior = thomas(&sub, &diag, &sup, &rhs).map_err(CurveError::from)?;
let mut second = vec![0.0_f64; n];
for (k, &m_k) in interior.iter().enumerate() {
second[k + 1] = m_k;
}
Ok(second)
}
fn solve_clamped(
h: &[f64],
slope: &[f64],
n: usize,
first: f64,
last: f64,
) -> Result<Vec<f64>, CurveError> {
let mut sub = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut sup = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
diag[0] = 2.0 * h[0];
sup[0] = h[0];
rhs[0] = 6.0 * (slope[0] - first);
for i in 1..(n - 1) {
sub[i] = h[i - 1];
diag[i] = 2.0 * (h[i - 1] + h[i]);
sup[i] = h[i];
rhs[i] = 6.0 * (slope[i] - slope[i - 1]);
}
let last_idx = n - 1;
sub[last_idx] = h[last_idx - 1];
diag[last_idx] = 2.0 * h[last_idx - 1];
rhs[last_idx] = 6.0 * (last - slope[last_idx - 1]);
thomas(&sub, &diag, &sup, &rhs).map_err(CurveError::from)
}
fn solve_not_a_knot(h: &[f64], slope: &[f64], n: usize) -> Result<Vec<f64>, CurveError> {
if n == 3 {
let m_const = 2.0 * (slope[1] - slope[0]) / (h[0] + h[1]);
return Ok(vec![m_const, m_const, m_const]);
}
let m = n - 2;
let mut sub = vec![0.0_f64; m];
let mut diag = vec![0.0_f64; m];
let mut sup = vec![0.0_f64; m];
let mut rhs = vec![0.0_f64; m];
diag[0] = (h[0] + h[1]) * (h[0] + 2.0 * h[1]);
if m >= 2 {
sup[0] = h[1] * h[1] - h[0] * h[0];
}
rhs[0] = 6.0 * h[1] * (slope[1] - slope[0]);
if m >= 3 {
for k in 1..(m - 1) {
let i = k + 1;
sub[k] = h[i - 1];
diag[k] = 2.0 * (h[i - 1] + h[i]);
sup[k] = h[i];
rhs[k] = 6.0 * (slope[i] - slope[i - 1]);
}
}
let last_k = m - 1;
let h_a = h[n - 3]; let h_b = h[n - 2]; if last_k >= 1 {
sub[last_k] = h_a * h_a - h_b * h_b;
}
diag[last_k] = (h_a + h_b) * (2.0 * h_a + h_b);
rhs[last_k] = 6.0 * h_a * (slope[n - 2] - slope[n - 3]);
let interior = thomas(&sub, &diag, &sup, &rhs).map_err(CurveError::from)?;
let m_1 = interior[0];
let m_2 = if m >= 2 { interior[1] } else { m_1 };
let m_0 = ((h[0] + h[1]) * m_1 - h[0] * m_2) / h[1];
let m_nm2 = interior[last_k];
let m_nm3 = if last_k >= 1 {
interior[last_k - 1]
} else {
m_nm2
};
let m_nm1 = ((h[n - 3] + h[n - 2]) * m_nm2 - h[n - 2] * m_nm3) / h[n - 3];
let mut second = vec![0.0_f64; n];
second[0] = m_0;
for (k, &v) in interior.iter().enumerate() {
second[k + 1] = v;
}
second[n - 1] = m_nm1;
Ok(second)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
let err = CubicSpline::new(&[], SplineBoundary::Natural).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 0 }));
}
#[test]
fn rejects_single_knot() {
let err = CubicSpline::new(&[(0.0, 1.0)], SplineBoundary::NotAKnot).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 1 }));
}
#[test]
fn rejects_non_monotone_times() {
let err = CubicSpline::new(
&[(0.0, 1.0), (2.0, 0.9), (1.0, 0.95)],
SplineBoundary::Natural,
)
.unwrap_err();
assert!(matches!(
err,
CurveError::NodesNotIncreasing { at_index: 2 }
));
}
#[test]
fn rejects_duplicate_times() {
let err = CubicSpline::new(
&[(0.0, 1.0), (1.0, 0.95), (1.0, 0.9)],
SplineBoundary::Natural,
)
.unwrap_err();
assert!(matches!(err, CurveError::DuplicateNode { .. }));
}
#[test]
fn rejects_nan_value() {
let err =
CubicSpline::new(&[(0.0, 1.0), (1.0, f64::NAN)], SplineBoundary::Natural).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_nan_time() {
let err =
CubicSpline::new(&[(0.0, 1.0), (f64::NAN, 0.9)], SplineBoundary::Natural).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn rejects_inf_time() {
let err = CubicSpline::new(&[(0.0, 1.0), (f64::INFINITY, 0.9)], SplineBoundary::Natural)
.unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn rejects_nan_clamped_first() {
let err = CubicSpline::new(
&[(0.0, 1.0), (1.0, 0.9)],
SplineBoundary::Clamped {
first: f64::NAN,
last: 0.0,
},
)
.unwrap_err();
assert!(matches!(err, CurveError::Type(TypeError::NonFinite { .. })));
}
#[test]
fn rejects_nan_clamped_last() {
let err = CubicSpline::new(
&[(0.0, 1.0), (1.0, 0.9)],
SplineBoundary::Clamped {
first: 0.0,
last: f64::INFINITY,
},
)
.unwrap_err();
assert!(matches!(err, CurveError::Type(TypeError::NonFinite { .. })));
}
#[test]
fn knot_reproduction_natural() {
let knots = [(0.0, 1.0), (0.5, 0.97), (1.0, 0.95), (2.0, 0.90)];
let spline = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
for &(t, y) in &knots {
assert!(
(spline.eval(t) - y).abs() < 1e-12,
"natural: knot ({t}, {y}) -> {}",
spline.eval(t)
);
}
}
#[test]
fn knot_reproduction_not_a_knot() {
let knots = [
(0.0, 1.0),
(0.5, 0.97),
(1.0, 0.95),
(2.0, 0.90),
(3.5, 0.80),
];
let spline = CubicSpline::new(&knots, SplineBoundary::NotAKnot).unwrap();
for &(t, y) in &knots {
assert!(
(spline.eval(t) - y).abs() < 1e-12,
"not-a-knot: knot ({t}, {y}) -> {}",
spline.eval(t)
);
}
}
#[test]
fn knot_reproduction_clamped() {
let knots = [(0.0, 1.0), (0.5, 0.97), (1.0, 0.95), (2.0, 0.90)];
let spline = CubicSpline::new(
&knots,
SplineBoundary::Clamped {
first: -0.05,
last: -0.02,
},
)
.unwrap();
for &(t, y) in &knots {
assert!(
(spline.eval(t) - y).abs() < 1e-12,
"clamped: knot ({t}, {y}) -> {}",
spline.eval(t)
);
}
}
#[test]
fn natural_has_zero_second_derivative_at_endpoints() {
let knots = [(0.0, 1.0), (0.5, 0.97), (1.0, 0.95), (2.0, 0.90)];
let spline = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
let n = spline.len();
assert!(spline.second[0].abs() < 1e-15);
assert!(spline.second[n - 1].abs() < 1e-15);
let t0 = knots[0].0;
let tn = knots[knots.len() - 1].0;
let h = 1e-5;
let d2_left = (spline.deriv(t0 + h).unwrap() - spline.deriv(t0).unwrap()) / h;
let d2_right = (spline.deriv(tn).unwrap() - spline.deriv(tn - h).unwrap()) / h;
assert!(d2_left.abs() < 1e-4, "d2 at left endpoint = {d2_left}");
assert!(d2_right.abs() < 1e-4, "d2 at right endpoint = {d2_right}");
}
#[test]
fn clamped_matches_specified_slopes_at_endpoints() {
let first = 0.7_f64;
let last = -1.3_f64;
let knots = [(0.0, 0.0), (1.0, 1.0), (2.0, 0.5), (3.0, 0.8)];
let spline = CubicSpline::new(&knots, SplineBoundary::Clamped { first, last }).unwrap();
let d0 = spline.deriv(0.0).unwrap();
let dn = spline.deriv(3.0).unwrap();
assert!(
(d0 - first).abs() < 1e-12,
"clamped first slope: got {d0}, want {first}"
);
assert!(
(dn - last).abs() < 1e-12,
"clamped last slope: got {dn}, want {last}"
);
}
#[test]
fn not_a_knot_reproduces_cubic_exactly() {
let knots: Vec<(f64, f64)> = (0..5)
.map(|i| {
let x = f64::from(i);
(x, x * x * x)
})
.collect();
let spline = CubicSpline::new(&knots, SplineBoundary::NotAKnot).unwrap();
let v = spline.eval(2.5);
assert!((v - 15.625).abs() < 1e-10, "y(2.5) = {v}, want 15.625");
for &t in &[0.25_f64, 0.75, 1.5, 2.0, 2.75, 3.1, 3.9] {
let expected = t * t * t;
let got = spline.eval(t);
assert!(
(got - expected).abs() < 1e-10,
"y({t}) = {got}, want {expected}"
);
}
}
#[test]
fn not_a_knot_three_knots_reproduces_quadratic() {
let knots = [(0.0, 1.0), (1.0, 2.0), (3.0, 10.0)];
let spline = CubicSpline::new(&knots, SplineBoundary::NotAKnot).unwrap();
for &t in &[0.0_f64, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0] {
let expected = t * t + 1.0;
let got = spline.eval(t);
assert!(
(got - expected).abs() < 1e-12,
"y({t}) = {got}, want {expected}"
);
}
}
#[test]
fn clamped_reproduces_cubic_with_exact_slopes() {
let knots: Vec<(f64, f64)> = (0..5)
.map(|i| {
let x = f64::from(i);
(x, x * x * x)
})
.collect();
let spline = CubicSpline::new(
&knots,
SplineBoundary::Clamped {
first: 0.0,
last: 48.0,
},
)
.unwrap();
for &t in &[0.25_f64, 0.75, 1.5, 2.5, 3.1, 3.9] {
let expected = t * t * t;
let got = spline.eval(t);
assert!(
(got - expected).abs() < 1e-10,
"clamped cubic: y({t}) = {got}, want {expected}"
);
}
}
#[test]
fn two_knot_spline_is_linear_natural() {
let knots = [(0.0, 1.0), (2.0, 0.0)];
let spline = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
for &t in &[0.0, 0.5, 1.0, 1.5, 2.0] {
let expected = 1.0 - t / 2.0;
let got = spline.eval(t);
assert!(
(got - expected).abs() < 1e-15,
"y({t}) = {got}, want {expected}"
);
}
}
#[test]
fn two_knot_spline_is_linear_not_a_knot() {
let knots = [(0.0, 1.0), (2.0, 0.0)];
let spline = CubicSpline::new(&knots, SplineBoundary::NotAKnot).unwrap();
let d = spline.deriv(1.0).unwrap();
assert!((d + 0.5).abs() < 1e-15);
assert!((spline.eval(1.0) - 0.5).abs() < 1e-15);
}
#[test]
fn c2_continuity_at_interior_knot() {
let knots = [(0.0, 0.0), (1.0, 1.0), (2.0, 0.5), (3.0, 0.8), (4.5, 0.2)];
let spline = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
let t_int = knots[2].0;
let h = 1e-5;
let d_left = (spline.deriv(t_int).unwrap() - spline.deriv(t_int - h).unwrap()) / h;
let d_right = (spline.deriv(t_int + h).unwrap() - spline.deriv(t_int).unwrap()) / h;
assert!(
(d_left - d_right).abs() < 1e-4,
"C^2 mismatch at t={t_int}: left={d_left}, right={d_right}"
);
}
#[test]
fn deriv_finite_difference_cubic() {
let knots: Vec<(f64, f64)> = (0..5)
.map(|i| {
let x = f64::from(i);
(x, x * x * x)
})
.collect();
let spline = CubicSpline::new(&knots, SplineBoundary::NotAKnot).unwrap();
for &t in &[0.5_f64, 1.5, 2.5, 3.5] {
let expected = 3.0 * t * t;
let got = spline.deriv(t).unwrap();
assert!(
(got - expected).abs() < 1e-10,
"y'({t}) = {got}, want {expected}"
);
}
}
#[test]
fn deriv_zero_in_extrapolation_region() {
let knots = [(0.0, 1.0), (1.0, 0.95), (2.0, 0.9)];
let spline = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
assert!(spline.deriv(-1.0).unwrap().abs() < 1e-15);
assert!(spline.deriv(3.0).unwrap().abs() < 1e-15);
}
#[test]
fn flat_extrapolation() {
let knots = [(0.0, 1.0), (1.0, 0.95), (2.0, 0.90)];
let spline = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
assert!((spline.eval(-100.0) - 1.0).abs() < 1e-15);
assert!((spline.eval(100.0) - 0.90).abs() < 1e-15);
}
#[test]
fn build_trait_method_returns_natural_default() {
let knots = [(0.0, 1.0), (0.5, 0.97), (1.0, 0.95)];
let via_trait = <CubicSpline as Interpolator>::build(&knots).unwrap();
let direct = CubicSpline::new(&knots, SplineBoundary::Natural).unwrap();
assert!((via_trait.eval(0.25) - direct.eval(0.25)).abs() < 1e-15);
assert_eq!(via_trait.boundary(), SplineBoundary::Natural);
}
#[test]
fn len_and_is_empty() {
let spline = CubicSpline::new(
&[(0.0, 1.0), (1.0, 0.95), (2.0, 0.9)],
SplineBoundary::Natural,
)
.unwrap();
assert_eq!(spline.len(), 3);
assert!(!spline.is_empty());
}
#[test]
fn clone_yields_equivalent_interpolant() {
let spline = CubicSpline::new(
&[(0.0, 1.0), (0.5, 0.97), (1.0, 0.95)],
SplineBoundary::NotAKnot,
)
.unwrap();
let copy = spline.clone();
assert!((spline.eval(0.25) - copy.eval(0.25)).abs() < 1e-15);
assert_eq!(spline.boundary(), copy.boundary());
}
}