Skip to main content

numra_interp/
hermite.rs

1//! Piecewise Cubic Hermite Interpolating Polynomial (PCHIP).
2//!
3//! Shape-preserving interpolation using the Fritsch-Carlson method.
4//! Preserves monotonicity of the data.
5//!
6//! Author: Moussa Leblouba
7//! Date: 9 February 2026
8//! Modified: 2 May 2026
9
10use numra_core::Scalar;
11
12use crate::cubic_spline::coefficients_from_slopes;
13use crate::error::InterpError;
14use crate::{eval_piecewise_cubic, eval_piecewise_cubic_deriv, integrate_piecewise_cubic};
15use crate::{validate_data, Interpolant};
16
17/// Piecewise Cubic Hermite Interpolating Polynomial (PCHIP).
18///
19/// Uses the Fritsch-Carlson method to compute slopes that preserve
20/// monotonicity of the data.
21pub struct Pchip<S: Scalar> {
22    x: Vec<S>,
23    a: Vec<S>,
24    b: Vec<S>,
25    c: Vec<S>,
26    d: Vec<S>,
27}
28
29impl<S: Scalar> Pchip<S> {
30    /// Create a PCHIP interpolant from data points.
31    ///
32    /// # Errors
33    ///
34    /// Returns error if fewer than 2 points or data is not sorted.
35    pub fn new(x: &[S], y: &[S]) -> Result<Self, InterpError> {
36        validate_data(x, y, 2)?;
37        let n = x.len();
38        let slopes = compute_pchip_slopes(x, y);
39        let (a, b, c, d) = coefficients_from_slopes(x, y, &slopes);
40
41        Ok(Self {
42            x: x[..n].to_vec(),
43            a,
44            b,
45            c,
46            d,
47        })
48    }
49}
50
51impl<S: Scalar> Interpolant<S> for Pchip<S> {
52    fn interpolate(&self, x: S) -> S {
53        eval_piecewise_cubic(&self.x, &self.a, &self.b, &self.c, &self.d, x)
54    }
55
56    fn derivative(&self, x: S) -> Option<S> {
57        Some(eval_piecewise_cubic_deriv(
58            &self.x, &self.b, &self.c, &self.d, x,
59        ))
60    }
61
62    fn integrate(&self, a: S, b: S) -> Option<S> {
63        Some(integrate_piecewise_cubic(
64            &self.x, &self.a, &self.b, &self.c, &self.d, a, b,
65        ))
66    }
67}
68
69/// Compute PCHIP slopes using Fritsch-Carlson method.
70fn compute_pchip_slopes<S: Scalar>(x: &[S], y: &[S]) -> Vec<S> {
71    let n = x.len();
72    let mut slopes = vec![S::ZERO; n];
73
74    if n == 2 {
75        let s = (y[1] - y[0]) / (x[1] - x[0]);
76        slopes[0] = s;
77        slopes[1] = s;
78        return slopes;
79    }
80
81    // Compute secants
82    let n_seg = n - 1;
83    let h: Vec<S> = (0..n_seg).map(|i| x[i + 1] - x[i]).collect();
84    let s: Vec<S> = (0..n_seg).map(|i| (y[i + 1] - y[i]) / h[i]).collect();
85
86    // Interior slopes
87    for i in 1..n - 1 {
88        if (s[i - 1] > S::ZERO && s[i] > S::ZERO) || (s[i - 1] < S::ZERO && s[i] < S::ZERO) {
89            // Same sign: weighted harmonic mean
90            let w1 = S::TWO * h[i] + h[i - 1];
91            let w2 = h[i] + S::TWO * h[i - 1];
92            slopes[i] = (w1 + w2) / (w1 / s[i - 1] + w2 / s[i]);
93        }
94        // else: slopes[i] = 0 (already initialized)
95    }
96
97    // Endpoint slopes: one-sided formula with limiting
98    slopes[0] = endpoint_slope(h[0], h[1], s[0], s[1]);
99    slopes[n - 1] = endpoint_slope(h[n_seg - 1], h[n_seg - 2], s[n_seg - 1], s[n_seg - 2]);
100
101    slopes
102}
103
104/// One-sided endpoint slope with shape-preservation limiting.
105fn endpoint_slope<S: Scalar>(h1: S, h2: S, s1: S, s2: S) -> S {
106    let d = ((S::TWO * h1 + h2) * s1 - h1 * s2) / (h1 + h2);
107
108    // Limit to preserve shape
109    if (d > S::ZERO) != (s1 > S::ZERO) {
110        return S::ZERO;
111    }
112    let three = S::from_f64(3.0);
113    if (s1 > S::ZERO) != (s2 > S::ZERO) && d.abs() > three * s1.abs() {
114        return three * s1;
115    }
116    d
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use approx::assert_relative_eq;
123
124    #[test]
125    fn test_pchip_at_knots() {
126        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
127        let y = vec![0.0, 1.0, 0.5, 1.5, 1.0];
128        let p = Pchip::new(&x, &y).unwrap();
129        for (&xi, &yi) in x.iter().zip(y.iter()) {
130            assert_relative_eq!(p.interpolate(xi), yi, epsilon = 1e-12);
131        }
132    }
133
134    #[test]
135    fn test_pchip_monotonicity() {
136        // Monotone increasing data
137        let x = vec![0.0, 1.0, 2.0, 5.0, 10.0];
138        let y = vec![0.0, 1.0, 4.0, 5.0, 10.0];
139        let p = Pchip::new(&x, &y).unwrap();
140
141        // Check monotonicity between knots
142        let mut prev = p.interpolate(0.0);
143        for i in 1..100 {
144            let xi = i as f64 * 10.0 / 100.0;
145            let yi = p.interpolate(xi);
146            assert!(
147                yi >= prev - 1e-10,
148                "Monotonicity violation at x={}: {} < {}",
149                xi,
150                yi,
151                prev
152            );
153            prev = yi;
154        }
155    }
156
157    #[test]
158    fn test_pchip_step_function() {
159        // Step-like data: PCHIP should not overshoot
160        let x = vec![0.0, 1.0, 1.5, 2.0, 3.0];
161        let y = vec![0.0, 0.0, 1.0, 1.0, 1.0];
162        let p = Pchip::new(&x, &y).unwrap();
163
164        // Should not go below 0 or above 1
165        for i in 0..100 {
166            let xi = i as f64 * 3.0 / 100.0;
167            let yi = p.interpolate(xi);
168            assert!(yi >= -0.01 && yi <= 1.01, "Overshoot at x={}: y={}", xi, yi);
169        }
170    }
171
172    #[test]
173    fn test_pchip_two_points() {
174        let p = Pchip::new(&[0.0, 1.0], &[0.0, 1.0]).unwrap();
175        assert_relative_eq!(p.interpolate(0.5), 0.5, epsilon = 1e-14);
176    }
177
178    #[test]
179    fn test_pchip_derivative() {
180        let x = vec![0.0, 1.0, 2.0, 3.0];
181        let y = vec![0.0, 1.0, 4.0, 9.0]; // x^2
182        let p = Pchip::new(&x, &y).unwrap();
183        // Derivative at x=1 should be close to 2
184        let d = p.derivative(1.0).unwrap();
185        assert!((d - 2.0).abs() < 1.0, "Derivative at 1.0: {}", d);
186    }
187
188    #[test]
189    fn test_pchip_integrate() {
190        // Integral of x from 0 to 3 = 4.5
191        let x = vec![0.0, 1.0, 2.0, 3.0];
192        let y = vec![0.0, 1.0, 2.0, 3.0];
193        let p = Pchip::new(&x, &y).unwrap();
194        let integral = p.integrate(0.0, 3.0).unwrap();
195        assert_relative_eq!(integral, 4.5, epsilon = 1e-10);
196    }
197}