use crate::errors::CurveError;
use super::Interpolator;
use super::cubic_spline::{CubicSpline, SplineBoundary};
#[derive(Debug, Clone)]
pub struct MonotoneHyman {
times: Vec<f64>,
values: Vec<f64>,
slopes: Vec<f64>,
}
impl MonotoneHyman {
pub fn new(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
Self::with_boundary(knots, SplineBoundary::Natural)
}
pub fn with_boundary(
knots: &[(f64, f64)],
boundary: SplineBoundary,
) -> 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 base = CubicSpline::new(knots, boundary)?;
let mut slopes: Vec<f64> = times
.iter()
.map(|&t| {
base.deriv(t).unwrap_or(0.0)
})
.collect();
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);
}
if count == 2 {
slopes[0] = filter_endpoint(slopes[0], secants[0]);
slopes[1] = filter_endpoint(slopes[1], secants[0]);
} else {
slopes[0] = filter_endpoint(slopes[0], secants[0]);
slopes[count - 1] = filter_endpoint(slopes[count - 1], secants[count - 2]);
for idx in 1..count - 1 {
slopes[idx] = filter_interior(slopes[idx], secants[idx - 1], secants[idx]);
}
}
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 filter_interior(m: f64, s_left: f64, s_right: f64) -> f64 {
if s_left * s_right <= 0.0 {
return 0.0;
}
let envelope = 3.0 * s_left.abs().min(s_right.abs());
let clamped = m.abs().min(envelope);
clamped.copysign(s_left)
}
#[inline]
fn filter_endpoint(m: f64, s: f64) -> f64 {
if m * s <= 0.0 {
return 0.0;
}
let envelope = 3.0 * s.abs();
let clamped = m.abs().min(envelope);
clamped.copysign(s)
}
impl Interpolator for MonotoneHyman {
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 = MonotoneHyman::new(&[]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 0 }));
}
#[test]
fn rejects_single_knot() {
let err = MonotoneHyman::new(&[(0.0, 1.0)]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 1 }));
}
#[test]
fn rejects_non_monotone_times() {
let err = MonotoneHyman::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 = MonotoneHyman::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 = MonotoneHyman::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 = MonotoneHyman::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 = MonotoneHyman::new(&[(0.0, 1.0), (f64::NAN, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn rejects_inf_time() {
let err = MonotoneHyman::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 = MonotoneHyman::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "knot ({t}, {y}) -> {v}");
}
}
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 = MonotoneHyman::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_and_discriminator() {
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 = MonotoneHyman::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "RPN15A knot ({t}, {y}) -> {v}");
}
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;
}
let v11 = interp.eval(11.0);
assert!(v11 <= 1.0, "Hyman filter failed at x=11.0: f={v11}");
}
#[test]
fn rpn15a_filter_strictly_below_one_at_eleven() {
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 = MonotoneHyman::new(&knots).unwrap();
let v = interp.eval(11.0);
assert!(
(0.943_740..=0.998_636).contains(&v),
"Hyman filter at x=11.0: got {v}, expected in [0.943_740, 0.998_636]"
);
}
#[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 = MonotoneHyman::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 = MonotoneHyman::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 &m in &interp.slopes {
assert!((m - 3.0).abs() < 1e-12, "slope = {m}");
}
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 plateau_stays_flat() {
let interp = MonotoneHyman::new(&[(0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.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, 2.5, 3.0] {
let v = interp.eval(t);
assert!((v - 1.0).abs() < 1e-15, "t={t}: v={v}");
}
}
#[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 = MonotoneHyman::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}"
);
}
}
#[test]
fn two_knot_reduces_to_linear() {
let interp = MonotoneHyman::new(&[(0.0, 1.0), (2.0, 5.0)]).unwrap();
for &t in &[0.0_f64, 0.25, 0.5, 1.0, 1.5, 2.0] {
let expected = 1.0 + 2.0 * t;
let v = interp.eval(t);
assert!(
(v - expected).abs() < 1e-15,
"t={t}: got {v}, want {expected}"
);
}
let d = interp.deriv(1.0).unwrap();
assert!((d - 2.0).abs() < 1e-15);
}
#[test]
fn filter_clamps_to_envelope_when_spline_overshoots() {
let knots = [(0.0, 0.0), (0.1, 0.5), (1.0, 0.6)];
let interp = MonotoneHyman::new(&knots).unwrap();
let s0 = 5.0_f64;
let s1 = 0.1_f64 / 0.9;
let envelope_mid = 3.0 * s0.min(s1);
assert!(
interp.slopes[1].abs() <= envelope_mid + 1e-12,
"interior slope = {}, envelope = {envelope_mid}",
interp.slopes[1]
);
for &m in &interp.slopes {
assert!(m >= 0.0, "slope = {m}");
}
}
#[test]
fn with_boundary_not_a_knot_runs() {
let knots = [
(0.0, 0.0_f64),
(1.0, 1.0),
(2.0, 4.0),
(3.0, 9.0),
(4.0, 16.0),
];
let interp = MonotoneHyman::with_boundary(&knots, SplineBoundary::NotAKnot).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "knot ({t}, {y}) -> {v}");
}
let mut prev = interp.eval(0.0);
let mut t = 0.0_f64;
while t <= 4.0 {
let v = interp.eval(t);
assert!((v + 1e-12) >= prev, "non-monotone at t={t}");
prev = v;
t += 0.01;
}
}
#[test]
fn flat_extrapolation_left_right() {
let interp = MonotoneHyman::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);
assert!((interp.deriv(-1.0).unwrap() - 0.0).abs() < 1e-15);
assert!((interp.deriv(5.0).unwrap() - 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 = MonotoneHyman::new(&knots).unwrap();
let b = <MonotoneHyman 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 = MonotoneHyman::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 = MonotoneHyman::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);
}
}