Skip to main content

scirs2_interpolate/
polyharmonic.rs

1//! Polyharmonic Spline Interpolation
2//!
3//! Polyharmonic splines are a generalization of thin-plate splines to arbitrary
4//! orders and dimensions. They use radial basis functions of the form:
5//!
6//! - `phi(r) = r^k` for odd `k`
7//! - `phi(r) = r^k log(r)` for even `k`
8//!
9//! The interpolant is:
10//!
11//! ```text
12//! f(x) = sum_i w_i phi(||x - x_i||) + polynomial terms
13//! ```
14//!
15//! The weights and polynomial coefficients are found by solving the augmented
16//! linear system:
17//!
18//! ```text
19//! [Phi + lambda*I   P ] [w]   [y]
20//! [P^T              0 ] [c] = [0]
21//! ```
22//!
23//! ## Special Cases
24//!
25//! - **Order 1**: `phi(r) = r` (piecewise linear, no smoothness)
26//! - **Order 2**: `phi(r) = r^2 log(r)` (thin-plate spline in 2D)
27//! - **Order 3**: `phi(r) = r^3` (cubic polyharmonic)
28//! - **Order 4**: `phi(r) = r^4 log(r)` (biharmonic)
29
30use crate::error::{InterpolateError, InterpolateResult};
31
32/// Evaluate the polyharmonic kernel function `phi(r)` for a given order.
33///
34/// - For odd `k`: `phi(r) = r^k`
35/// - For even `k`: `phi(r) = r^k * log(r)` (with `phi(0) = 0` by continuity)
36#[inline]
37fn phi(r: f64, order: usize) -> f64 {
38    if r < f64::EPSILON {
39        return 0.0;
40    }
41    let rk = r.powi(order as i32);
42    if order % 2 == 0 {
43        rk * r.ln()
44    } else {
45        rk
46    }
47}
48
49/// Compute Euclidean distance between two points stored as slices.
50#[inline]
51fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
52    let mut sq = 0.0;
53    for (ai, bi) in a.iter().zip(b.iter()) {
54        let d = ai - bi;
55        sq += d * d;
56    }
57    sq.sqrt()
58}
59
60/// A polyharmonic spline interpolator for arbitrary-dimension data.
61///
62/// Given `n` data points in `d` dimensions with scalar values, the
63/// polyharmonic spline builds an interpolant (or smoothing approximation)
64/// using radial basis functions augmented with a polynomial of degree `<= order - 1`.
65///
66/// # Examples
67///
68/// ```rust
69/// use scirs2_interpolate::polyharmonic::PolyharmonicSpline;
70///
71/// // 2D scattered data
72/// let points = vec![
73///     vec![0.0, 0.0],
74///     vec![1.0, 0.0],
75///     vec![0.0, 1.0],
76///     vec![1.0, 1.0],
77/// ];
78/// let values = vec![0.0, 1.0, 1.0, 2.0];
79///
80/// let spline = PolyharmonicSpline::fit(&points, &values, 2, 0.0)
81///     .expect("should fit successfully");
82///
83/// // Evaluate at a query point
84/// let result = spline.evaluate(&[0.5, 0.5]).expect("should evaluate");
85/// assert!((result - 1.0).abs() < 1e-8);
86/// ```
87pub struct PolyharmonicSpline {
88    /// Data points (n x d), stored row-major.
89    points: Vec<Vec<f64>>,
90    /// Dimension of the data.
91    dim: usize,
92    /// Order of the polyharmonic kernel.
93    order: usize,
94    /// RBF weights (length n).
95    weights: Vec<f64>,
96    /// Polynomial coefficients. Length = number of polynomial basis terms.
97    poly_coeffs: Vec<f64>,
98}
99
100/// Compute the number of polynomial basis terms for a polynomial of
101/// degree `<= deg` in `d` dimensions (i.e., `C(d + deg, deg)`).
102fn poly_term_count(dim: usize, deg: usize) -> usize {
103    // C(dim + deg, deg) = (dim+deg)! / (dim! * deg!)
104    let mut num = 1usize;
105    let mut den = 1usize;
106    for i in 1..=deg {
107        num *= dim + i;
108        den *= i;
109    }
110    num / den
111}
112
113/// Evaluate the polynomial basis at a point `x` for total degree `<= deg`.
114/// Returns a vector of length `poly_term_count(dim, deg)`.
115fn poly_basis(x: &[f64], deg: usize) -> Vec<f64> {
116    let dim = x.len();
117    let count = poly_term_count(dim, deg);
118    let mut basis = Vec::with_capacity(count);
119
120    // We enumerate monomials in graded lexicographic order.
121    // For simplicity we use a recursive approach via multi-indices.
122    let mut multi_index = vec![0usize; dim];
123    loop {
124        let total_deg: usize = multi_index.iter().sum();
125        if total_deg <= deg {
126            let mut val = 1.0;
127            for (i, &exp) in multi_index.iter().enumerate() {
128                val *= x[i].powi(exp as i32);
129            }
130            basis.push(val);
131        }
132
133        // Increment multi-index (enumerate all with total degree <= deg)
134        if !increment_multi_index(&mut multi_index, deg) {
135            break;
136        }
137    }
138
139    basis
140}
141
142/// Increment a multi-index in graded lexicographic order, returning false
143/// when all indices have been enumerated.
144fn increment_multi_index(idx: &mut [usize], max_total: usize) -> bool {
145    let d = idx.len();
146    if d == 0 {
147        return false;
148    }
149
150    // Try to increment the last component
151    let last = d - 1;
152    idx[last] += 1;
153    if idx.iter().sum::<usize>() <= max_total {
154        return true;
155    }
156
157    // Carry: find rightmost non-zero position that can be shifted
158    idx[last] = 0;
159    for i in (0..last).rev() {
160        idx[i] += 1;
161        if idx.iter().sum::<usize>() <= max_total {
162            return true;
163        }
164        idx[i] = 0;
165    }
166    false
167}
168
169/// Solve a dense linear system `A * x = b` using Gaussian elimination with
170/// partial pivoting. Returns the solution vector `x`.
171fn solve_linear_system(a: &[Vec<f64>], b: &[f64]) -> InterpolateResult<Vec<f64>> {
172    let n = b.len();
173    if a.len() != n {
174        return Err(InterpolateError::DimensionMismatch(
175            "matrix row count must match right-hand side length".to_string(),
176        ));
177    }
178    for row in a.iter() {
179        if row.len() != n {
180            return Err(InterpolateError::DimensionMismatch(
181                "matrix must be square".to_string(),
182            ));
183        }
184    }
185
186    // Augmented matrix [A | b]
187    let mut aug: Vec<Vec<f64>> = Vec::with_capacity(n);
188    for i in 0..n {
189        let mut row = Vec::with_capacity(n + 1);
190        row.extend_from_slice(&a[i]);
191        row.push(b[i]);
192        aug.push(row);
193    }
194
195    // Forward elimination with partial pivoting
196    for col in 0..n {
197        // Find pivot
198        let mut max_abs = aug[col][col].abs();
199        let mut max_row = col;
200        for row in (col + 1)..n {
201            let abs_val = aug[row][col].abs();
202            if abs_val > max_abs {
203                max_abs = abs_val;
204                max_row = row;
205            }
206        }
207
208        if max_abs < 1e-15 {
209            return Err(InterpolateError::LinalgError(
210                "singular or near-singular matrix in polyharmonic spline system".to_string(),
211            ));
212        }
213
214        if max_row != col {
215            aug.swap(col, max_row);
216        }
217
218        let pivot = aug[col][col];
219        for row in (col + 1)..n {
220            let factor = aug[row][col] / pivot;
221            for j in col..=n {
222                let val = aug[col][j];
223                aug[row][j] -= factor * val;
224            }
225        }
226    }
227
228    // Back substitution
229    let mut x = vec![0.0; n];
230    for i in (0..n).rev() {
231        let mut sum = aug[i][n];
232        for j in (i + 1)..n {
233            sum -= aug[i][j] * x[j];
234        }
235        if aug[i][i].abs() < 1e-15 {
236            return Err(InterpolateError::LinalgError(
237                "zero pivot in back substitution".to_string(),
238            ));
239        }
240        x[i] = sum / aug[i][i];
241    }
242
243    Ok(x)
244}
245
246impl PolyharmonicSpline {
247    /// Fit a polyharmonic spline to scattered data.
248    ///
249    /// # Arguments
250    ///
251    /// * `points` - Data point coordinates, `n` points each of dimension `d`.
252    /// * `values` - Function values at the data points (length `n`).
253    /// * `order` - Order of the polyharmonic kernel (`>= 1`).
254    ///   - `order = 1`: `phi(r) = r`
255    ///   - `order = 2`: `phi(r) = r^2 log(r)` (thin-plate spline)
256    ///   - `order = 3`: `phi(r) = r^3`
257    /// * `smoothing` - Regularization parameter (`>= 0`). When `0`, exact
258    ///   interpolation is performed. Larger values produce smoother results.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if:
263    /// - `points` is empty
264    /// - `points` and `values` have different lengths
265    /// - The linear system is singular
266    pub fn fit(
267        points: &[Vec<f64>],
268        values: &[f64],
269        order: usize,
270        smoothing: f64,
271    ) -> InterpolateResult<Self> {
272        let n = points.len();
273        if n == 0 {
274            return Err(InterpolateError::InsufficientData(
275                "at least one data point is required for polyharmonic spline".to_string(),
276            ));
277        }
278        if n != values.len() {
279            return Err(InterpolateError::DimensionMismatch(format!(
280                "points count ({}) must match values count ({})",
281                n,
282                values.len()
283            )));
284        }
285        if order == 0 {
286            return Err(InterpolateError::InvalidValue(
287                "order must be >= 1 for polyharmonic spline".to_string(),
288            ));
289        }
290
291        let dim = points[0].len();
292        if dim == 0 {
293            return Err(InterpolateError::InvalidValue(
294                "point dimension must be >= 1".to_string(),
295            ));
296        }
297        for (i, pt) in points.iter().enumerate() {
298            if pt.len() != dim {
299                return Err(InterpolateError::DimensionMismatch(format!(
300                    "point {} has dimension {} but expected {}",
301                    i,
302                    pt.len(),
303                    dim
304                )));
305            }
306        }
307
308        // Polynomial degree is order - 1
309        let poly_deg = order - 1;
310        let m = poly_term_count(dim, poly_deg);
311        let total = n + m;
312
313        // Build the augmented system matrix
314        // [Phi + lambda*I   P ]
315        // [P^T              0 ]
316        let mut sys: Vec<Vec<f64>> = vec![vec![0.0; total]; total];
317        let mut rhs = vec![0.0; total];
318
319        // Fill Phi block (n x n)
320        for i in 0..n {
321            for j in 0..n {
322                let r = euclidean_distance(&points[i], &points[j]);
323                sys[i][j] = phi(r, order);
324            }
325            // Add regularization to diagonal
326            if smoothing > 0.0 {
327                sys[i][i] += smoothing;
328            }
329        }
330
331        // Fill P block (n x m) and P^T block (m x n)
332        for i in 0..n {
333            let basis = poly_basis(&points[i], poly_deg);
334            for (j, &b) in basis.iter().enumerate() {
335                sys[i][n + j] = b;
336                sys[n + j][i] = b;
337            }
338        }
339
340        // Right-hand side
341        for i in 0..n {
342            rhs[i] = values[i];
343        }
344
345        // Solve the system
346        let solution = solve_linear_system(&sys, &rhs)?;
347
348        let weights = solution[..n].to_vec();
349        let poly_coeffs = solution[n..].to_vec();
350
351        Ok(PolyharmonicSpline {
352            points: points.to_vec(),
353            dim,
354            order,
355            weights,
356            poly_coeffs,
357        })
358    }
359
360    /// Create a thin-plate spline interpolator (order = 2).
361    ///
362    /// This is a convenience method equivalent to `fit(points, values, 2, smoothing)`.
363    pub fn thin_plate_spline(
364        points: &[Vec<f64>],
365        values: &[f64],
366        smoothing: f64,
367    ) -> InterpolateResult<Self> {
368        Self::fit(points, values, 2, smoothing)
369    }
370
371    /// Evaluate the polyharmonic spline at a single query point.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if the query point dimension does not match the
376    /// data point dimension.
377    pub fn evaluate(&self, query: &[f64]) -> InterpolateResult<f64> {
378        if query.len() != self.dim {
379            return Err(InterpolateError::DimensionMismatch(format!(
380                "query dimension ({}) must match data dimension ({})",
381                query.len(),
382                self.dim
383            )));
384        }
385
386        let mut result = 0.0;
387
388        // RBF contribution
389        for (i, pt) in self.points.iter().enumerate() {
390            let r = euclidean_distance(query, pt);
391            result += self.weights[i] * phi(r, self.order);
392        }
393
394        // Polynomial contribution
395        let poly_deg = self.order - 1;
396        let basis = poly_basis(query, poly_deg);
397        for (j, &b) in basis.iter().enumerate() {
398            result += self.poly_coeffs[j] * b;
399        }
400
401        Ok(result)
402    }
403
404    /// Evaluate the polyharmonic spline at multiple query points.
405    ///
406    /// Returns a vector of interpolated values, one per query point.
407    pub fn evaluate_batch(&self, queries: &[Vec<f64>]) -> InterpolateResult<Vec<f64>> {
408        let mut results = Vec::with_capacity(queries.len());
409        for q in queries {
410            results.push(self.evaluate(q)?);
411        }
412        Ok(results)
413    }
414
415    /// Return the order of the polyharmonic kernel.
416    pub fn order(&self) -> usize {
417        self.order
418    }
419
420    /// Return the dimension of the data points.
421    pub fn dim(&self) -> usize {
422        self.dim
423    }
424
425    /// Return the number of data points.
426    pub fn num_points(&self) -> usize {
427        self.points.len()
428    }
429
430    /// Return the RBF weights.
431    pub fn weights(&self) -> &[f64] {
432        &self.weights
433    }
434
435    /// Return the polynomial coefficients.
436    pub fn poly_coefficients(&self) -> &[f64] {
437        &self.poly_coeffs
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_tps_interpolates_exactly() {
447        // 2D thin-plate spline should interpolate exactly (smoothing = 0)
448        let points = vec![
449            vec![0.0, 0.0],
450            vec![1.0, 0.0],
451            vec![0.0, 1.0],
452            vec![1.0, 1.0],
453            vec![0.5, 0.5],
454        ];
455        let values = vec![0.0, 1.0, 2.0, 3.0, 1.5];
456
457        let spline = PolyharmonicSpline::thin_plate_spline(&points, &values, 0.0)
458            .expect("test: fit should succeed");
459
460        for (pt, &expected) in points.iter().zip(values.iter()) {
461            let val = spline.evaluate(pt).expect("test: evaluate should succeed");
462            assert!(
463                (val - expected).abs() < 1e-8,
464                "TPS should interpolate exactly: expected {}, got {} at {:?}",
465                expected,
466                val,
467                pt
468            );
469        }
470    }
471
472    #[test]
473    fn test_tps_smooth_between_points() {
474        let points = vec![
475            vec![0.0, 0.0],
476            vec![1.0, 0.0],
477            vec![0.0, 1.0],
478            vec![1.0, 1.0],
479        ];
480        let values = vec![0.0, 1.0, 1.0, 2.0];
481
482        let spline = PolyharmonicSpline::thin_plate_spline(&points, &values, 0.0)
483            .expect("test: fit should succeed");
484
485        // Evaluate at center: for additive data f(x,y) = x + y, should be ~1.0
486        let center_val = spline
487            .evaluate(&[0.5, 0.5])
488            .expect("test: evaluate should succeed");
489        assert!(
490            (center_val - 1.0).abs() < 1e-6,
491            "TPS at center should be ~1.0, got {}",
492            center_val
493        );
494    }
495
496    #[test]
497    fn test_regularization_produces_smoother_surface() {
498        let points = vec![vec![0.0], vec![0.25], vec![0.5], vec![0.75], vec![1.0]];
499        // Add some noise to a linear function
500        let values_noisy = vec![0.0, 0.3, 0.45, 0.8, 1.0];
501
502        let spline_exact = PolyharmonicSpline::fit(&points, &values_noisy, 2, 0.0)
503            .expect("test: fit should succeed");
504        let spline_smooth = PolyharmonicSpline::fit(&points, &values_noisy, 2, 1.0)
505            .expect("test: fit should succeed");
506
507        // The smoothed spline should deviate from the exact interpolant
508        // at data points (it doesn't interpolate exactly)
509        let mut exact_residual = 0.0;
510        let mut smooth_residual = 0.0;
511        for (pt, &v) in points.iter().zip(values_noisy.iter()) {
512            let e = (spline_exact
513                .evaluate(pt)
514                .expect("test: evaluate should succeed")
515                - v)
516                .abs();
517            let s = (spline_smooth
518                .evaluate(pt)
519                .expect("test: evaluate should succeed")
520                - v)
521                .abs();
522            exact_residual += e;
523            smooth_residual += s;
524        }
525
526        // Exact interpolant should have near-zero residual
527        assert!(
528            exact_residual < 1e-8,
529            "exact spline should interpolate exactly"
530        );
531        // Smooth interpolant should have nonzero residual (smoother)
532        assert!(
533            smooth_residual > 1e-6,
534            "smoothed spline should not interpolate exactly"
535        );
536    }
537
538    #[test]
539    fn test_different_orders_produce_different_results() {
540        // Use 1D data to avoid high polynomial term counts.
541        // Order k in 1D has poly_deg = k-1, giving k polynomial terms.
542        let points: Vec<Vec<f64>> = vec![
543            vec![0.0],
544            vec![0.2],
545            vec![0.4],
546            vec![0.6],
547            vec![0.8],
548            vec![1.0],
549        ];
550        let values = vec![0.0, 0.3, 0.5, 0.45, 0.8, 1.0];
551
552        let spline_o1 = PolyharmonicSpline::fit(&points, &values, 1, 0.0)
553            .expect("test: fit order 1 should succeed");
554        let spline_o2 = PolyharmonicSpline::fit(&points, &values, 2, 0.0)
555            .expect("test: fit order 2 should succeed");
556        let spline_o3 = PolyharmonicSpline::fit(&points, &values, 3, 0.0)
557            .expect("test: fit order 3 should succeed");
558
559        let test_pt = vec![0.35];
560        let v1 = spline_o1
561            .evaluate(&test_pt)
562            .expect("test: evaluate should succeed");
563        let v2 = spline_o2
564            .evaluate(&test_pt)
565            .expect("test: evaluate should succeed");
566        let v3 = spline_o3
567            .evaluate(&test_pt)
568            .expect("test: evaluate should succeed");
569
570        // At least two of the three should differ noticeably
571        let diff_12 = (v1 - v2).abs();
572        let diff_13 = (v1 - v3).abs();
573        let diff_23 = (v2 - v3).abs();
574        let max_diff = diff_12.max(diff_13).max(diff_23);
575        assert!(
576            max_diff > 1e-6,
577            "different orders should produce different results: v1={}, v2={}, v3={}",
578            v1,
579            v2,
580            v3
581        );
582    }
583
584    #[test]
585    fn test_1d_polyharmonic() {
586        // 1D case: should interpolate a simple function
587        let points: Vec<Vec<f64>> = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0], vec![4.0]];
588        let values: Vec<f64> = points.iter().map(|p| p[0] * p[0]).collect();
589
590        let spline =
591            PolyharmonicSpline::fit(&points, &values, 3, 0.0).expect("test: fit should succeed");
592
593        // Should interpolate exactly at data points
594        for (pt, &expected) in points.iter().zip(values.iter()) {
595            let val = spline.evaluate(pt).expect("test: evaluate should succeed");
596            assert!(
597                (val - expected).abs() < 1e-6,
598                "1D polyharmonic should interpolate x^2 exactly at data points"
599            );
600        }
601    }
602
603    #[test]
604    fn test_3d_polyharmonic() {
605        // 3D case
606        let points = vec![
607            vec![0.0, 0.0, 0.0],
608            vec![1.0, 0.0, 0.0],
609            vec![0.0, 1.0, 0.0],
610            vec![0.0, 0.0, 1.0],
611            vec![1.0, 1.0, 1.0],
612        ];
613        // f(x,y,z) = x + y + z
614        let values: Vec<f64> = points.iter().map(|p| p[0] + p[1] + p[2]).collect();
615
616        let spline =
617            PolyharmonicSpline::fit(&points, &values, 1, 0.0).expect("test: fit should succeed");
618
619        for (pt, &expected) in points.iter().zip(values.iter()) {
620            let val = spline.evaluate(pt).expect("test: evaluate should succeed");
621            assert!(
622                (val - expected).abs() < 1e-6,
623                "3D polyharmonic should interpolate linear function exactly"
624            );
625        }
626    }
627
628    #[test]
629    fn test_batch_evaluation() {
630        let points = vec![vec![0.0], vec![1.0], vec![2.0]];
631        let values = vec![0.0, 1.0, 4.0];
632
633        let spline =
634            PolyharmonicSpline::fit(&points, &values, 2, 0.0).expect("test: fit should succeed");
635
636        let queries = vec![vec![0.0], vec![1.0], vec![2.0]];
637        let results = spline
638            .evaluate_batch(&queries)
639            .expect("test: batch evaluate should succeed");
640
641        for (res, &expected) in results.iter().zip(values.iter()) {
642            assert!(
643                (res - expected).abs() < 1e-8,
644                "batch evaluation should match single evaluation"
645            );
646        }
647    }
648
649    #[test]
650    fn test_error_on_empty_data() {
651        let points: Vec<Vec<f64>> = vec![];
652        let values: Vec<f64> = vec![];
653        let result = PolyharmonicSpline::fit(&points, &values, 2, 0.0);
654        assert!(result.is_err());
655    }
656
657    #[test]
658    fn test_error_on_dimension_mismatch() {
659        // Need enough points for order 2 in 2D (poly_deg=1 => 3 poly terms,
660        // so system size = n + 3, need n >= 3 for non-singularity)
661        let points = vec![
662            vec![0.0, 0.0],
663            vec![1.0, 0.0],
664            vec![0.0, 1.0],
665            vec![1.0, 1.0],
666        ];
667        let values = vec![0.0, 1.0, 1.0, 2.0];
668
669        let spline =
670            PolyharmonicSpline::fit(&points, &values, 2, 0.0).expect("test: fit should succeed");
671
672        // Query with wrong dimension
673        let result = spline.evaluate(&[0.5]);
674        assert!(result.is_err());
675    }
676}