Skip to main content

salmon_model/
spline.rs

1//! Cubic spline interpolation — a faithful port of the vendored `tk::spline`
2//! (Tino Kluge) used by salmon's `SimplePosBias`.
3//!
4//! Matches salmon's default construction: **cubic** spline with **natural**
5//! boundary conditions (second derivative 0 at both ends) and **quadratic**
6//! extrapolation outside the knot range. The band-matrix LU solve is ported
7//! verbatim (tridiagonal, one upper + one lower band) so the spline coefficients
8//! match the C++ implementation.
9
10/// A tridiagonal band matrix with one upper and one lower band (plus a saved
11/// diagonal used during LU). Mirrors `tk::band_matrix` for the `n_u = n_l = 1`
12/// case that cubic-spline construction needs.
13struct BandMatrix {
14    dim: usize,
15    // m_upper[0] = main diagonal, m_upper[1] = super-diagonal
16    upper: [Vec<f64>; 2],
17    // m_lower[0] = saved diagonal (set during LU), m_lower[1] = sub-diagonal
18    lower: [Vec<f64>; 2],
19}
20
21impl BandMatrix {
22    fn new(dim: usize) -> Self {
23        Self {
24            dim,
25            upper: [vec![0.0; dim], vec![0.0; dim]],
26            lower: [vec![0.0; dim], vec![0.0; dim]],
27        }
28    }
29
30    /// `A(i, j)` accessor (band index `k = j - i`; `k >= 0` -> upper, else lower).
31    #[inline]
32    fn get(&self, i: usize, j: usize) -> f64 {
33        let k = j as isize - i as isize;
34        if k >= 0 {
35            self.upper[k as usize][i]
36        } else {
37            self.lower[(-k) as usize][i]
38        }
39    }
40
41    #[inline]
42    fn set(&mut self, i: usize, j: usize, v: f64) {
43        let k = j as isize - i as isize;
44        if k >= 0 {
45            self.upper[k as usize][i] = v;
46        } else {
47            self.lower[(-k) as usize][i] = v;
48        }
49    }
50
51    #[inline]
52    fn saved_diag(&self, i: usize) -> f64 {
53        self.lower[0][i]
54    }
55    #[inline]
56    fn set_saved_diag(&mut self, i: usize, v: f64) {
57        self.lower[0][i] = v;
58    }
59
60    /// LR-decomposition of the band matrix (ported from `tk::band_matrix`).
61    fn lu_decompose(&mut self) {
62        let dim = self.dim as isize;
63        // preconditioning: normalize row i so a_ii = 1
64        for i in 0..self.dim {
65            let diag = self.get(i, i);
66            self.set_saved_diag(i, 1.0 / diag);
67            let j_min = (i as isize - 1).max(0) as usize;
68            let j_max = (i + 1).min(self.dim - 1);
69            let s = self.saved_diag(i);
70            for j in j_min..=j_max {
71                self.set(i, j, self.get(i, j) * s);
72            }
73            self.set(i, i, 1.0); // prevents rounding errors
74        }
75        // Gauss LR-decomposition
76        for k in 0..self.dim {
77            let i_max = ((k + 1).min(self.dim - 1)) as isize;
78            let mut i = k as isize + 1;
79            while i <= i_max {
80                let iu = i as usize;
81                let akk = self.get(k, k);
82                let x = -self.get(iu, k) / akk;
83                self.set(iu, k, -x); // assembly part of L
84                let j_max = ((k + 1).min(self.dim - 1)) as isize;
85                let mut j = k as isize + 1;
86                while j <= j_max {
87                    let ju = j as usize;
88                    self.set(iu, ju, self.get(iu, ju) + x * self.get(k, ju));
89                    j += 1;
90                }
91                i += 1;
92            }
93        }
94        let _ = dim;
95    }
96
97    /// Solve `Ly = b`.
98    fn l_solve(&self, b: &[f64]) -> Vec<f64> {
99        let mut x = vec![0.0; self.dim];
100        for i in 0..self.dim {
101            let mut sum = 0.0;
102            let j_start = (i as isize - 1).max(0) as usize;
103            for j in j_start..i {
104                sum += self.get(i, j) * x[j];
105            }
106            x[i] = b[i] * self.saved_diag(i) - sum;
107        }
108        x
109    }
110
111    /// Solve `Rx = y`.
112    fn r_solve(&self, b: &[f64]) -> Vec<f64> {
113        let mut x = vec![0.0; self.dim];
114        for i in (0..self.dim).rev() {
115            let mut sum = 0.0;
116            let j_stop = (i + 1).min(self.dim - 1);
117            for j in (i + 1)..=j_stop {
118                if j > i {
119                    sum += self.get(i, j) * x[j];
120                }
121            }
122            x[i] = (b[i] - sum) / self.get(i, i);
123        }
124        x
125    }
126
127    fn lu_solve(&mut self, b: &[f64]) -> Vec<f64> {
128        self.lu_decompose();
129        let y = self.l_solve(b);
130        self.r_solve(&y)
131    }
132}
133
134/// A cubic spline `f(x) = a·(x−x_i)³ + b·(x−x_i)² + c·(x−x_i) + y_i`.
135#[derive(Debug, Clone, Default)]
136pub struct Spline {
137    x: Vec<f64>,
138    y: Vec<f64>,
139    a: Vec<f64>,
140    b: Vec<f64>,
141    c: Vec<f64>,
142    b0: f64,
143    c0: f64,
144}
145
146impl Spline {
147    /// Build a cubic spline with natural boundary conditions and quadratic
148    /// extrapolation — salmon's default `tk::spline(xs, ys)`.
149    pub fn new(xs: Vec<f64>, ys: Vec<f64>) -> Self {
150        let n = xs.len();
151        assert!(n >= 3, "cubic spline needs >= 3 points");
152        let x = xs;
153        let y = ys;
154
155        let mut mat = BandMatrix::new(n);
156        let mut rhs = vec![0.0; n];
157        for i in 1..n - 1 {
158            mat.set(i, i - 1, (x[i] - x[i - 1]) / 3.0);
159            mat.set(i, i, 2.0 / 3.0 * (x[i + 1] - x[i - 1]));
160            mat.set(i, i + 1, (x[i + 1] - x[i]) / 3.0);
161            rhs[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i]) - (y[i] - y[i - 1]) / (x[i] - x[i - 1]);
162        }
163        // natural (second_deriv = 0) boundary conditions
164        mat.set(0, 0, 2.0);
165        mat.set(0, 1, 0.0);
166        rhs[0] = 0.0;
167        mat.set(n - 1, n - 1, 2.0);
168        mat.set(n - 1, n - 2, 0.0);
169        rhs[n - 1] = 0.0;
170
171        let b = mat.lu_solve(&rhs);
172
173        let mut a = vec![0.0; n];
174        let mut c = vec![0.0; n];
175        for i in 0..n - 1 {
176            a[i] = (b[i + 1] - b[i]) / (x[i + 1] - x[i]) / 3.0;
177            c[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i])
178                - (2.0 * b[i] + b[i + 1]) * (x[i + 1] - x[i]) / 3.0;
179        }
180
181        // quadratic-extrapolation coefficients (default)
182        let b0 = 0.0;
183        let c0 = c[0];
184        let h = x[n - 1] - x[n - 2];
185        a[n - 1] = 0.0;
186        c[n - 1] = 3.0 * a[n - 2] * h * h + 2.0 * b[n - 2] * h + c[n - 2];
187
188        Self {
189            x,
190            y,
191            a,
192            b,
193            c,
194            b0,
195            c0,
196        }
197    }
198
199    /// `m_x[idx] <= x`, with `idx = 0` even when `x < m_x[0]`.
200    #[inline]
201    fn closest_idx_to(&self, x: f64) -> usize {
202        // lower_bound: first element >= x
203        let it = self.x.partition_point(|&v| v < x);
204        if it == 0 {
205            0
206        } else {
207            it - 1
208        }
209    }
210
211    /// Evaluate the spline at `x` (interpolating, or extrapolating quadratically
212    /// outside `[x_0, x_{n-1}]`).
213    pub fn eval(&self, x: f64) -> f64 {
214        let n = self.x.len();
215        let idx = self.closest_idx_to(x);
216        let h = x - self.x[idx];
217        if x < self.x[0] {
218            (self.b0 * h + self.c0) * h + self.y[0]
219        } else if x > self.x[n - 1] {
220            (self.b[n - 1] * h + self.c[n - 1]) * h + self.y[n - 1]
221        } else {
222            ((self.a[idx] * h + self.b[idx]) * h + self.c[idx]) * h + self.y[idx]
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn interpolates_knots_exactly() {
233        let xs = vec![0.0, 1.0, 2.0, 3.0, 4.0];
234        let ys = vec![0.0, 1.0, 4.0, 9.0, 16.0]; // ~ x^2
235        let s = Spline::new(xs.clone(), ys.clone());
236        for (x, y) in xs.iter().zip(&ys) {
237            assert!(
238                (s.eval(*x) - y).abs() < 1e-9,
239                "knot {x}: {} != {y}",
240                s.eval(*x)
241            );
242        }
243    }
244
245    #[test]
246    fn monotone_line_is_reproduced() {
247        // a straight line should be reproduced exactly by a natural cubic spline
248        let xs = vec![0.0, 0.25, 0.5, 0.75, 1.0];
249        let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x + 1.0).collect();
250        let s = Spline::new(xs, ys);
251        for i in 0..=10 {
252            let x = i as f64 / 10.0;
253            assert!((s.eval(x) - (2.0 * x + 1.0)).abs() < 1e-9, "x={x}");
254        }
255    }
256
257    #[test]
258    fn extrapolates_without_panic() {
259        let xs = vec![0.0, 0.5, 1.0];
260        let ys = vec![1.0, 2.0, 1.0];
261        let s = Spline::new(xs, ys);
262        let _ = s.eval(-0.3);
263        let _ = s.eval(1.7);
264    }
265}