use crate::errors::CurveError;
use super::Interpolator;
#[derive(Debug, Clone)]
pub struct MonotoneCubic {
times: Vec<f64>,
values: Vec<f64>,
slopes: Vec<f64>,
}
impl MonotoneCubic {
pub fn new(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
if knots.len() < 2 {
return Err(CurveError::TooFewNodes { found: knots.len() });
}
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 slopes = compute_slopes(×, &values);
Ok(Self {
times,
values,
slopes,
})
}
#[must_use]
#[inline]
pub fn len(&self) -> usize {
self.times.len()
}
#[must_use]
#[inline]
pub fn is_empty(&self) -> bool {
self.times.is_empty()
}
#[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
}
}
fn compute_slopes(times: &[f64], values: &[f64]) -> Vec<f64> {
let n = times.len();
if n == 2 {
let s = (values[1] - values[0]) / (times[1] - times[0]);
return vec![s, s];
}
let mut secant = Vec::with_capacity(n - 1);
let mut step = Vec::with_capacity(n - 1);
for i in 0..n - 1 {
let h = times[i + 1] - times[i];
step.push(h);
secant.push((values[i + 1] - values[i]) / h);
}
let mut m = vec![0.0_f64; n];
for i in 1..n - 1 {
let s_left = secant[i - 1];
let s_right = secant[i];
if s_left * s_right <= 0.0 {
m[i] = 0.0;
} else {
let h_left = step[i - 1];
let h_right = step[i];
let w_left = 2.0 * h_right + h_left;
let w_right = h_right + 2.0 * h_left;
m[i] = 3.0 * (h_left + h_right) / (w_left / s_left + w_right / s_right);
}
}
m[0] = endpoint_slope(step[0], step[1], secant[0], secant[1]);
m[n - 1] = endpoint_slope(step[n - 2], step[n - 3], secant[n - 2], secant[n - 3]);
for i in 0..n - 1 {
let s = secant[i];
if s == 0.0 {
m[i] = 0.0;
m[i + 1] = 0.0;
continue;
}
let alpha = m[i] / s;
let beta = m[i + 1] / s;
let radius_sq = alpha.mul_add(alpha, beta * beta);
if radius_sq > 9.0 {
let tau = 3.0 / radius_sq.sqrt();
m[i] = tau * alpha * s;
m[i + 1] = tau * beta * s;
}
}
m
}
fn endpoint_slope(h_near: f64, h_far: f64, s_near: f64, s_far: f64) -> f64 {
let raw = ((2.0 * h_near + h_far) * s_near - h_near * s_far) / (h_near + h_far);
if raw * s_near <= 0.0 {
return 0.0;
}
if raw.abs() > 3.0 * s_near.abs() {
return 3.0 * s_near;
}
raw
}
impl Interpolator for MonotoneCubic {
fn build(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
Self::new(knots)
}
#[allow(clippy::many_single_char_names)] fn eval(&self, t: f64) -> f64 {
let last = self.times.len() - 1;
if t <= self.times[0] {
return self.values[0];
}
if t >= self.times[last] {
return self.values[last];
}
let seg = self.locate(t);
let step = self.times[seg + 1] - self.times[seg];
let u = (t - self.times[seg]) / step;
let u2 = u * u;
let u3 = u2 * u;
let h00 = 2.0 * u3 - 3.0 * u2 + 1.0;
let h10 = u3 - 2.0 * u2 + u;
let h01 = -2.0 * u3 + 3.0 * u2;
let h11 = u3 - u2;
h00 * self.values[seg]
+ h10 * step * self.slopes[seg]
+ h01 * self.values[seg + 1]
+ h11 * step * self.slopes[seg + 1]
}
#[allow(clippy::many_single_char_names)] fn deriv(&self, t: f64) -> Option<f64> {
let last = self.times.len() - 1;
if t < self.times[0] || t > self.times[last] {
return Some(0.0);
}
let seg = self.locate(t);
let step = self.times[seg + 1] - self.times[seg];
let u = (t - self.times[seg]) / step;
let u2 = u * u;
let dh00 = 6.0 * u2 - 6.0 * u;
let dh10 = 3.0 * u2 - 4.0 * u + 1.0;
let dh01 = -6.0 * u2 + 6.0 * u;
let dh11 = 3.0 * u2 - 2.0 * u;
let dy_du = dh00 * self.values[seg]
+ dh10 * step * self.slopes[seg]
+ dh01 * self.values[seg + 1]
+ dh11 * step * self.slopes[seg + 1];
Some(dy_du / step)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
let err = MonotoneCubic::new(&[]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 0 }));
}
#[test]
fn rejects_single_knot() {
let err = MonotoneCubic::new(&[(0.0, 1.0)]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 1 }));
}
#[test]
fn rejects_non_monotone_times() {
let err = MonotoneCubic::new(&[(0.0, 1.0), (2.0, 0.9), (1.0, 0.95)]).unwrap_err();
assert!(matches!(
err,
CurveError::NodesNotIncreasing { at_index: 2 }
));
}
#[test]
fn rejects_duplicate_times() {
let err = MonotoneCubic::new(&[(0.0, 1.0), (1.0, 0.95), (1.0, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::DuplicateNode { .. }));
}
#[test]
fn rejects_nan_value() {
let err = MonotoneCubic::new(&[(0.0, 1.0), (1.0, f64::NAN)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_inf_value() {
let err = MonotoneCubic::new(&[(0.0, 1.0), (1.0, f64::INFINITY)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_nan_time() {
let err = MonotoneCubic::new(&[(0.0, 1.0), (f64::NAN, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn knot_reproduction_exact() {
let knots = [(0.0, 0.0), (1.0, 1.0), (2.0, 4.0), (3.0, 9.0), (5.0, 25.0)];
let interp = MonotoneCubic::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "knot ({t}, {y}) -> {v}");
}
}
#[test]
fn two_knot_reduces_to_linear() {
let interp = MonotoneCubic::new(&[(0.0, 1.0), (1.0, 3.0)]).unwrap();
for &t in &[0.0_f64, 0.25, 0.5, 0.75, 1.0] {
let expected = 1.0 + 2.0 * t;
let v = interp.eval(t);
assert!((v - expected).abs() < 1e-15, "t={t}: {v} vs {expected}");
}
}
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
#[allow(clippy::cast_possible_truncation)] fn next_u32(&mut self) -> u32 {
self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(self.0 >> 16) as u32
}
fn next_unit(&mut self) -> f64 {
f64::from(self.next_u32()) / f64::from(u32::MAX)
}
}
#[test]
fn monotone_input_yields_monotone_output_thirty_sets() {
let mut rng = Lcg::new(0x00C0_FFEE_u64);
for set_idx in 0..30 {
let n = 8 + (rng.next_u32() % 5) as usize;
let mut times = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
let mut t = 0.0_f64;
let mut y = 0.0_f64;
for _ in 0..n {
times.push(t);
values.push(y);
t += 0.05 + rng.next_unit();
y += 0.01 + 5.0 * rng.next_unit();
}
let knots: Vec<(f64, f64)> =
times.iter().copied().zip(values.iter().copied()).collect();
let interp = MonotoneCubic::new(&knots).unwrap();
let t_lo = times[0];
let t_hi = times[n - 1];
let grid: u32 = 200;
let mut prev = interp.eval(t_lo);
for k in 1..=grid {
let t = t_lo + (t_hi - t_lo) * f64::from(k) / f64::from(grid);
let v = interp.eval(t);
assert!(
v + 1e-12 >= prev,
"set {set_idx}: non-monotone at t={t}, prev={prev}, v={v}"
);
prev = v;
}
}
}
#[test]
fn rpn15a_monotone_on_fine_grid() {
let knots = [
(7.99, 0.0_f64),
(8.09, 2.764_29e-5),
(8.19, 4.374_98e-5),
(8.70, 0.169_183),
(9.20, 0.469_428),
(10.00, 0.943_740),
(12.00, 0.998_636),
(15.00, 0.999_919),
(20.00, 0.999_994),
];
let interp = MonotoneCubic::new(&knots).unwrap();
let mut prev = interp.eval(7.99);
let mut t = 7.99_f64;
let step = 0.01_f64;
while t <= 20.0 {
let v = interp.eval(t);
assert!(
v + 1e-12 >= prev,
"non-monotone on RPN15A at t={t}: prev={prev}, v={v}"
);
prev = v;
t += step;
}
assert!(interp.eval(11.0) <= 1.0);
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "RPN15A knot ({t}, {y}) -> {v}");
}
}
#[test]
fn non_monotone_input_zeros_slope_at_turning_point() {
let knots = [
(0.0, 0.0),
(1.0, 2.0),
(2.0, 1.0), (3.0, 4.0),
(4.0, 8.0),
];
let interp = MonotoneCubic::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "knot ({t}, {y}) -> {v}");
}
let d = interp.deriv(2.0).unwrap();
assert!(
d.abs() < 1e-15,
"expected zero slope at turning point, got {d}"
);
}
#[test]
fn reproduces_linear_function() {
let f = |x: f64| 2.0 + 3.0 * x;
let knots: Vec<(f64, f64)> = [0.0_f64, 0.5, 1.7, 3.1, 4.0, 6.0, 9.0]
.iter()
.map(|&x| (x, f(x)))
.collect();
let interp = MonotoneCubic::new(&knots).unwrap();
for &t in &[0.1_f64, 0.7, 1.0, 2.5, 3.7, 5.2, 7.9] {
let v = interp.eval(t);
let expected = f(t);
assert!(
(v - expected).abs() < 1e-12,
"t={t}: got {v}, want {expected}"
);
}
for &t in &[0.3_f64, 1.8, 4.5, 7.0] {
let d = interp.deriv(t).unwrap();
assert!((d - 3.0).abs() < 1e-12, "t={t}: deriv {d}");
}
}
#[test]
fn c1_continuous_at_interior_knots() {
let knots = [
(0.0, 0.0_f64),
(1.0, 1.5),
(2.5, 3.0),
(4.0, 7.0),
(5.0, 12.0),
(7.0, 13.0),
(10.0, 14.5),
];
let interp = MonotoneCubic::new(&knots).unwrap();
let h = 1e-6_f64;
for &(t, _) in &knots[1..knots.len() - 1] {
let d_left = (interp.eval(t) - interp.eval(t - h)) / h;
let d_right = (interp.eval(t + h) - interp.eval(t)) / h;
assert!(
(d_left - d_right).abs() < 1e-5,
"C^1 mismatch at t={t}: left={d_left}, right={d_right}"
);
let analytic = interp.deriv(t).unwrap();
assert!(
(analytic - d_right).abs() < 1e-5,
"deriv mismatch at t={t}: analytic={analytic}, fd_right={d_right}"
);
}
}
#[test]
fn flat_extrapolation_both_sides() {
let interp = MonotoneCubic::new(&[(0.0, 1.0), (1.0, 2.0), (2.0, 5.0)]).unwrap();
assert!((interp.eval(-100.0) - 1.0).abs() < 1e-15);
assert!((interp.eval(100.0) - 5.0).abs() < 1e-15);
assert!((interp.deriv(-1.0).unwrap() - 0.0).abs() < 1e-15);
assert!((interp.deriv(3.0).unwrap() - 0.0).abs() < 1e-15);
}
#[test]
fn build_trait_method_equivalent_to_new() {
let knots = [(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)];
let a = MonotoneCubic::new(&knots).unwrap();
let b = <MonotoneCubic as Interpolator>::build(&knots).unwrap();
assert!((a.eval(0.7) - b.eval(0.7)).abs() < 1e-15);
assert_eq!(a.len(), b.len());
}
#[test]
fn len_and_is_empty() {
let interp = MonotoneCubic::new(&[(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]).unwrap();
assert_eq!(interp.len(), 3);
assert!(!interp.is_empty());
}
#[test]
fn clone_yields_equivalent_interpolant() {
let interp = MonotoneCubic::new(&[(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]).unwrap();
let copy = interp.clone();
assert!((interp.eval(0.5) - copy.eval(0.5)).abs() < 1e-15);
}
#[test]
fn flat_segment_yields_flat_interpolant() {
let knots = [(0.0, 1.0), (1.0, 2.0), (2.0, 2.0), (3.0, 5.0)];
let interp = MonotoneCubic::new(&knots).unwrap();
for &t in &[1.1_f64, 1.4, 1.7, 1.95] {
let v = interp.eval(t);
assert!((v - 2.0).abs() < 1e-12, "expected flat at t={t}, got {v}");
}
}
}