rustyqlib/core/interpolation/
cubic_spline.rs1use crate::core::optimization::numerics::solve_dense;
19use crate::core::errors::RustyQLibError;
20
21#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum BoundaryCondition {
24 Natural,
25 Clamped { start_slope: f64, end_slope: f64 },
27 NotAKnot,
28}
29
30#[derive(Debug, Clone)]
32pub struct CubicSpline {
33 xs: Vec<f64>,
34 ys: Vec<f64>,
35 m: Vec<f64>,
37}
38
39impl CubicSpline {
40 pub fn new(xs: &[f64], ys: &[f64], bc: BoundaryCondition) -> Result<Self, RustyQLibError> {
44 let n = xs.len();
45 if n < 2 || ys.len() != n {
46 return Err(RustyQLibError::invalid_input("cubic_spline", "need at least two knots with matching y values"));
47 }
48 if xs.windows(2).any(|w| w[1] <= w[0]) {
49 return Err(RustyQLibError::invalid_input("cubic_spline", "knots must be strictly increasing"));
50 }
51 if n == 2 {
52 return Ok(CubicSpline { xs: xs.to_vec(), ys: ys.to_vec(), m: vec![0.0; 2] });
54 }
55 let bc = match bc {
56 BoundaryCondition::NotAKnot if n < 4 => BoundaryCondition::Natural,
57 other => other,
58 };
59
60 let h: Vec<f64> = xs.windows(2).map(|w| w[1] - w[0]).collect();
63 let mut a = vec![vec![0.0; n]; n];
64 let mut b = vec![0.0; n];
65 for i in 1..n - 1 {
66 a[i][i - 1] = h[i - 1] / 6.0;
67 a[i][i] = (h[i - 1] + h[i]) / 3.0;
68 a[i][i + 1] = h[i] / 6.0;
69 b[i] = (ys[i + 1] - ys[i]) / h[i] - (ys[i] - ys[i - 1]) / h[i - 1];
70 }
71 match bc {
72 BoundaryCondition::Natural => {
73 a[0][0] = 1.0;
74 a[n - 1][n - 1] = 1.0;
75 }
76 BoundaryCondition::Clamped { start_slope, end_slope } => {
77 a[0][0] = h[0] / 3.0;
78 a[0][1] = h[0] / 6.0;
79 b[0] = (ys[1] - ys[0]) / h[0] - start_slope;
80 a[n - 1][n - 2] = h[n - 2] / 6.0;
81 a[n - 1][n - 1] = h[n - 2] / 3.0;
82 b[n - 1] = end_slope - (ys[n - 1] - ys[n - 2]) / h[n - 2];
83 }
84 BoundaryCondition::NotAKnot => {
85 a[0][0] = h[1];
88 a[0][1] = -(h[0] + h[1]);
89 a[0][2] = h[0];
90 a[n - 1][n - 3] = h[n - 2];
91 a[n - 1][n - 2] = -(h[n - 3] + h[n - 2]);
92 a[n - 1][n - 1] = h[n - 3];
93 }
94 }
95 let m = solve_dense(&mut a, &mut b).ok_or(RustyQLibError::invalid_input("cubic_spline", "singular spline system"))?;
96 Ok(CubicSpline { xs: xs.to_vec(), ys: ys.to_vec(), m })
97 }
98
99 fn segment(&self, x: f64) -> usize {
100 let n = self.xs.len();
101 self.xs[1..n - 1].partition_point(|&xi| xi < x)
102 }
103
104 pub fn eval(&self, x: f64) -> f64 {
106 let n = self.xs.len();
107 if x <= self.xs[0] {
108 return self.ys[0] + self.derivative(self.xs[0]) * (x - self.xs[0]);
109 }
110 if x >= self.xs[n - 1] {
111 return self.ys[n - 1] + self.derivative(self.xs[n - 1]) * (x - self.xs[n - 1]);
112 }
113 let i = self.segment(x);
114 let h = self.xs[i + 1] - self.xs[i];
115 let (dl, dr) = (self.xs[i + 1] - x, x - self.xs[i]);
116 self.m[i] * dl * dl * dl / (6.0 * h)
117 + self.m[i + 1] * dr * dr * dr / (6.0 * h)
118 + (self.ys[i] / h - self.m[i] * h / 6.0) * dl
119 + (self.ys[i + 1] / h - self.m[i + 1] * h / 6.0) * dr
120 }
121
122 pub fn derivative(&self, x: f64) -> f64 {
124 let n = self.xs.len();
125 let x = x.clamp(self.xs[0], self.xs[n - 1]);
126 let i = self.segment(x).min(n - 2);
127 let h = self.xs[i + 1] - self.xs[i];
128 let (dl, dr) = (self.xs[i + 1] - x, x - self.xs[i]);
129 -self.m[i] * dl * dl / (2.0 * h)
130 + self.m[i + 1] * dr * dr / (2.0 * h)
131 + (self.ys[i + 1] - self.ys[i]) / h
132 - (self.m[i + 1] - self.m[i]) * h / 6.0
133 }
134
135 pub fn second_derivative(&self, x: f64) -> f64 {
137 let n = self.xs.len();
138 if x < self.xs[0] || x > self.xs[n - 1] {
139 return 0.0;
140 }
141 let i = self.segment(x).min(n - 2);
142 let h = self.xs[i + 1] - self.xs[i];
143 self.m[i] * (self.xs[i + 1] - x) / h + self.m[i + 1] * (x - self.xs[i]) / h
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn knots() -> (Vec<f64>, Vec<f64>) {
152 let xs: Vec<f64> = (0..7).map(|i| i as f64 * 0.5).collect();
153 let ys: Vec<f64> = xs.iter().map(|x| x.sin()).collect();
154 (xs, ys)
155 }
156
157 #[test]
158 fn all_boundary_conditions_interpolate_the_knots() {
159 let (xs, ys) = knots();
160 for bc in [
161 BoundaryCondition::Natural,
162 BoundaryCondition::Clamped { start_slope: 1.0, end_slope: 3.0_f64.cos() },
163 BoundaryCondition::NotAKnot,
164 ] {
165 let s = CubicSpline::new(&xs, &ys, bc).unwrap();
166 for (x, y) in xs.iter().zip(&ys) {
167 assert!((s.eval(*x) - y).abs() < 1e-12, "{bc:?} at {x}");
168 }
169 }
170 }
171
172 #[test]
173 fn not_a_knot_reproduces_a_cubic_exactly() {
174 let p = |x: f64| 2.0 - x + 3.0 * x * x - 0.5 * x * x * x;
177 let xs: Vec<f64> = (0..6).map(|i| i as f64).collect();
178 let ys: Vec<f64> = xs.iter().map(|&x| p(x)).collect();
179 let s = CubicSpline::new(&xs, &ys, BoundaryCondition::NotAKnot).unwrap();
180 for i in 0..=50 {
181 let x = i as f64 * 0.1;
182 assert!((s.eval(x) - p(x)).abs() < 1e-9, "x = {x}");
183 }
184 }
185
186 #[test]
187 fn natural_ends_have_zero_curvature() {
188 let (xs, ys) = knots();
189 let s = CubicSpline::new(&xs, &ys, BoundaryCondition::Natural).unwrap();
190 assert!(s.second_derivative(xs[0]).abs() < 1e-10);
191 assert!(s.second_derivative(*xs.last().unwrap()).abs() < 1e-10);
192 }
193
194 #[test]
195 fn clamped_ends_match_the_prescribed_slopes() {
196 let (xs, ys) = knots();
197 let bc = BoundaryCondition::Clamped { start_slope: 1.0, end_slope: 3.0_f64.cos() };
198 let s = CubicSpline::new(&xs, &ys, bc).unwrap();
199 assert!((s.derivative(0.0) - 1.0).abs() < 1e-10);
200 assert!((s.derivative(3.0) - 3.0_f64.cos()).abs() < 1e-10);
201 let natural = CubicSpline::new(&xs, &ys, BoundaryCondition::Natural).unwrap();
203 let x = 0.1;
204 assert!((s.eval(x) - x.sin()).abs() < (natural.eval(x) - x.sin()).abs());
205 }
206
207 #[test]
208 fn rejects_bad_knots() {
209 assert!(CubicSpline::new(&[0.0, 0.0, 1.0], &[1.0, 2.0, 3.0], BoundaryCondition::Natural)
210 .is_err());
211 assert!(CubicSpline::new(&[0.0], &[1.0], BoundaryCondition::Natural).is_err());
212 }
213}