use crate::errors::CurveError;
use super::Interpolator;
#[derive(Debug, Clone)]
pub struct MonotoneSteffen {
times: Vec<f64>,
values: Vec<f64>,
slopes: Vec<f64>,
}
impl MonotoneSteffen {
pub fn new(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
if knots.len() < 2 {
return Err(CurveError::TooFewNodes { found: knots.len() });
}
let count = knots.len();
let mut times = Vec::with_capacity(count);
let mut values = Vec::with_capacity(count);
for (idx, &(time, value)) in knots.iter().enumerate() {
if !time.is_finite() {
return Err(CurveError::InvalidTime { t: time });
}
if !value.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: idx,
value,
});
}
if idx > 0 {
let prev = times[idx - 1];
#[allow(clippy::float_cmp)]
let is_duplicate = time == prev;
if is_duplicate {
return Err(CurveError::DuplicateNode { t: time });
}
if time < prev {
return Err(CurveError::NodesNotIncreasing { at_index: idx });
}
}
times.push(time);
values.push(value);
}
let mut secants = Vec::with_capacity(count - 1);
for idx in 0..count - 1 {
let dt = times[idx + 1] - times[idx];
secants.push((values[idx + 1] - values[idx]) / dt);
}
let mut slopes = vec![0.0_f64; count];
if count == 2 {
slopes[0] = secants[0];
slopes[1] = secants[0];
return Ok(Self {
times,
values,
slopes,
});
}
for idx in 1..count - 1 {
let h_left = times[idx] - times[idx - 1];
let h_right = times[idx + 1] - times[idx];
let s_left = secants[idx - 1];
let s_right = secants[idx];
let p_cand = (s_left * h_right + s_right * h_left) / (h_left + h_right);
slopes[idx] = if s_left * s_right <= 0.0 {
0.0
} else {
let abs_left = s_left.abs();
let abs_right = s_right.abs();
let half_p = p_cand.abs() * 0.5;
let m_abs = abs_left.min(abs_right).min(half_p);
2.0 * m_abs.copysign(s_left)
};
}
let h0 = times[1] - times[0];
let h1 = times[2] - times[1];
let m0_extrap = secants[0] + (secants[0] - secants[1]) * h0 / (h0 + h1);
slopes[0] = limit_endpoint(m0_extrap, secants[0]);
let h_last = times[count - 1] - times[count - 2];
let h_prev = times[count - 2] - times[count - 3];
let m_last_extrap = secants[count - 2]
+ (secants[count - 2] - secants[count - 3]) * h_last / (h_prev + h_last);
slopes[count - 1] = limit_endpoint(m_last_extrap, secants[count - 2]);
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 count = self.times.len();
if t <= self.times[0] {
return 0;
}
if t >= self.times[count - 1] {
return count - 2;
}
let mut lo = 0_usize;
let mut hi = count - 1;
while hi - lo > 1 {
let mid = lo + (hi - lo) / 2;
if self.times[mid] <= t {
lo = mid;
} else {
hi = mid;
}
}
lo
}
}
#[inline]
fn limit_endpoint(m: f64, s: f64) -> f64 {
if m * s <= 0.0 {
0.0
} else if m.abs() > 2.0 * s.abs() {
2.0 * s
} else {
m
}
}
impl Interpolator for MonotoneSteffen {
fn build(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
Self::new(knots)
}
fn eval(&self, t: f64) -> f64 {
let count = self.times.len();
if t <= self.times[0] {
return self.values[0];
}
if t >= self.times[count - 1] {
return self.values[count - 1];
}
let idx = self.locate(t);
let t_lo = self.times[idx];
let t_hi = self.times[idx + 1];
let dt = t_hi - t_lo;
let u = (t - t_lo) / dt;
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[idx]
+ h10 * dt * self.slopes[idx]
+ h01 * self.values[idx + 1]
+ h11 * dt * self.slopes[idx + 1]
}
fn deriv(&self, t: f64) -> Option<f64> {
let count = self.times.len();
if t < self.times[0] || t > self.times[count - 1] {
return Some(0.0);
}
let idx = self.locate(t);
let t_lo = self.times[idx];
let t_hi = self.times[idx + 1];
let dt = t_hi - t_lo;
let u = (t - t_lo) / dt;
let u2 = u * u;
let dh00 = (6.0 * u2 - 6.0 * u) / dt;
let dh10 = 3.0 * u2 - 4.0 * u + 1.0;
let dh01 = (-6.0 * u2 + 6.0 * u) / dt;
let dh11 = 3.0 * u2 - 2.0 * u;
Some(
dh00 * self.values[idx]
+ dh10 * self.slopes[idx]
+ dh01 * self.values[idx + 1]
+ dh11 * self.slopes[idx + 1],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
let err = MonotoneSteffen::new(&[]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 0 }));
}
#[test]
fn rejects_single_knot() {
let err = MonotoneSteffen::new(&[(0.0, 1.0)]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 1 }));
}
#[test]
fn rejects_non_monotone_times() {
let err = MonotoneSteffen::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 = MonotoneSteffen::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 = MonotoneSteffen::new(&[(0.0, 1.0), (1.0, f64::NAN), (2.0, 2.0)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_inf_value() {
let err =
MonotoneSteffen::new(&[(0.0, 1.0), (1.0, f64::INFINITY), (2.0, 2.0)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_nan_time() {
let err = MonotoneSteffen::new(&[(0.0, 1.0), (f64::NAN, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn rejects_inf_time() {
let err = MonotoneSteffen::new(&[(0.0, 1.0), (f64::INFINITY, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn knot_reproduction_exact() {
let knots = [
(0.0, 1.0),
(0.5, 0.97),
(1.0, 0.95),
(2.0, 0.90),
(5.0, 0.78),
];
let interp = MonotoneSteffen::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-14, "knot ({t}, {y}) -> {v}");
}
}
#[test]
fn two_knot_linear() {
let interp = MonotoneSteffen::new(&[(0.0, 1.0), (2.0, 5.0)]).unwrap();
let v = interp.eval(1.0);
assert!((v - 3.0).abs() < 1e-15);
let d = interp.deriv(0.5).unwrap();
assert!((d - 2.0).abs() < 1e-15);
}
#[test]
fn linear_data_is_reproduced_exactly() {
let knots: Vec<(f64, f64)> = (0..6)
.map(|i| {
let x = f64::from(i);
(x, 2.0 + 3.0 * x)
})
.collect();
let interp = MonotoneSteffen::new(&knots).unwrap();
for &m in &interp.slopes {
assert!((m - 3.0).abs() < 1e-13, "slope = {m}");
}
for t in [0.25_f64, 0.5, 1.7, 2.3, 3.6, 4.9] {
let v = interp.eval(t);
let expected = 2.0 + 3.0 * t;
assert!((v - expected).abs() < 1e-13, "t={t}: v={v}");
}
}
#[test]
fn plateau_stays_flat() {
let interp = MonotoneSteffen::new(&[(0.0, 1.0), (1.0, 1.0), (2.0, 1.0)]).unwrap();
for &m in &interp.slopes {
#[allow(clippy::float_cmp)]
let is_zero = m == 0.0;
assert!(is_zero, "expected zero slope, got {m}");
}
for t in [0.0_f64, 0.1, 0.5, 0.7, 1.0, 1.3, 1.7, 2.0] {
let v = interp.eval(t);
assert!((v - 1.0).abs() < 1e-15, "t={t}: v={v}");
}
}
#[test]
fn turning_point_zero_slope() {
let interp = MonotoneSteffen::new(&[(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)]).unwrap();
#[allow(clippy::float_cmp)]
let apex_zero = interp.slopes[1] == 0.0;
assert!(apex_zero, "apex slope = {}", interp.slopes[1]);
}
#[test]
fn monotonicity_preserved_on_random_monotone_data() {
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
let next = |s: &mut u64| -> f64 {
*s ^= *s << 13;
*s ^= *s >> 7;
*s ^= *s << 17;
let mantissa = *s >> 11;
let bits = (1023_u64 << 52) | mantissa;
(f64::from_bits(bits) - 1.0) + 1e-9
};
for trial in 0_u32..30 {
let count = 5 + (trial as usize % 6); let mut times = Vec::with_capacity(count);
let mut values = Vec::with_capacity(count);
let mut t = 0.0_f64;
let mut y = 0.0_f64;
for _ in 0..count {
t += next(&mut state) + 0.1;
y += next(&mut state) + 0.05;
times.push(t);
values.push(y);
}
let knots: Vec<(f64, f64)> = times
.iter()
.zip(values.iter())
.map(|(&a, &b)| (a, b))
.collect();
let interp = MonotoneSteffen::new(&knots).unwrap();
let t0 = knots[0].0;
let t_end = knots[count - 1].0;
let steps = 500_u32;
let mut prev = interp.eval(t0);
for k in 1..=steps {
let frac = f64::from(k) / f64::from(steps);
let tt = t0 + frac * (t_end - t0);
let v = interp.eval(tt);
assert!(
v + 1e-12 >= prev,
"trial {trial}: non-monotone at t={tt}: prev={prev}, v={v}"
);
prev = v;
}
}
}
#[test]
fn exponential_growth_preserves_monotonicity() {
let knots: Vec<(f64, f64)> = (0..10)
.map(|i| {
let x = f64::from(i) * 0.5;
(x, x.exp())
})
.collect();
let interp = MonotoneSteffen::new(&knots).unwrap();
let t_end = knots[knots.len() - 1].0;
let mut prev = interp.eval(0.0);
let mut t = 0.0_f64;
while t <= t_end {
let v = interp.eval(t);
assert!(
v + 1e-12 >= prev,
"non-monotone at t={t}: prev={prev}, v={v}"
);
prev = v;
t += 0.005;
}
}
#[test]
fn flat_extrapolation_left_right() {
let interp = MonotoneSteffen::new(&[(0.5, 0.97), (1.0, 0.95), (2.0, 0.90)]).unwrap();
assert!((interp.eval(0.0) - 0.97).abs() < 1e-15);
assert!((interp.eval(-100.0) - 0.97).abs() < 1e-15);
assert!((interp.eval(3.0) - 0.90).abs() < 1e-15);
assert!((interp.eval(100.0) - 0.90).abs() < 1e-15);
}
#[test]
fn deriv_finite_difference_interior() {
let knots = [(0.0, 0.0), (1.0, 1.0), (2.0, 4.0), (3.0, 9.0), (4.0, 16.0)];
let interp = MonotoneSteffen::new(&knots).unwrap();
let t = 1.5_f64;
let dy_dt = interp.deriv(t).unwrap();
let h = 1e-6_f64;
let fd = (interp.eval(t + h) - interp.eval(t - h)) / (2.0 * h);
assert!((dy_dt - fd).abs() < 1e-6, "analytic={dy_dt}, fd={fd}");
}
#[test]
fn c1_continuity_at_interior_knots() {
let knots = [(0.0, 0.0), (1.0, 1.0), (2.5, 3.0), (4.0, 4.5), (6.0, 5.0)];
let interp = MonotoneSteffen::new(&knots).unwrap();
let h = 1e-7_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 broken at t={t}: left={d_left}, right={d_right}"
);
}
}
#[test]
fn deriv_zero_in_extrapolation_region() {
let interp = MonotoneSteffen::new(&[(0.0, 1.0), (1.0, 0.95), (2.0, 0.90)]).unwrap();
let d_left = interp.deriv(-1.0).unwrap();
assert!((d_left - 0.0).abs() < 1e-15);
let d_right = interp.deriv(3.0).unwrap();
assert!((d_right - 0.0).abs() < 1e-15);
}
#[test]
fn build_trait_method_equivalent_to_new() {
let knots = [(0.0, 1.0), (1.0, 2.0), (2.0, 4.0)];
let a = MonotoneSteffen::new(&knots).unwrap();
let b = <MonotoneSteffen as Interpolator>::build(&knots).unwrap();
assert!((a.eval(0.5) - b.eval(0.5)).abs() < 1e-15);
assert_eq!(a.len(), b.len());
}
#[test]
fn len_and_is_empty() {
let interp = MonotoneSteffen::new(&[(0.0, 1.0), (1.0, 2.0), (2.0, 1.5)]).unwrap();
assert_eq!(interp.len(), 3);
assert!(!interp.is_empty());
}
#[test]
fn clone_yields_equivalent_interpolant() {
let interp = MonotoneSteffen::new(&[(0.0, 1.0), (1.0, 2.0), (2.0, 4.0)]).unwrap();
let copy = interp.clone();
assert!((interp.eval(0.5) - copy.eval(0.5)).abs() < 1e-15);
}
}