Skip to main content

scirs2_interpolate/kriging/
mod.rs

1//! Kriging / Gaussian Process Interpolation
2//!
3//! This module provides **ordinary kriging** (OK) with pluggable variogram
4//! models.  Kriging is a best linear unbiased estimator (BLUE) for spatial
5//! data; it not only predicts values at unsampled locations but also delivers
6//! an associated prediction variance.
7//!
8//! ## Variogram models
9//!
10//! | Type | Description |
11//! |------|-------------|
12//! | `SphericalVariogram`    | Linear near origin, plateau at `range` |
13//! | `ExponentialVariogram`  | Exponential approach to sill |
14//! | `GaussianVariogram`     | Smooth Gaussian approach to sill |
15//! | `PowerVariogram`        | Unbounded power-law (fractal) model |
16//!
17//! ## Usage
18//!
19//! ```rust,ignore
20//! use scirs2_interpolate::kriging::{OrdinaryKriging, SphericalVariogram};
21//!
22//! let points = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
23//! let values = vec![0.0, 1.0, 0.5, 0.8];
24//! let vgm = SphericalVariogram { nugget: 0.0, sill: 1.0, range: 5.0 };
25//! let ok = OrdinaryKriging::fit(points.clone(), values.clone(), Box::new(vgm)).expect("doc example: should succeed");
26//! let (estimate, variance) = ok.predict(&[1.5]).expect("doc example: should succeed");
27//! ```
28//!
29//! ## References
30//!
31//! - Cressie, N. (1993). *Statistics for Spatial Data* (revised ed.). Wiley.
32//! - Journel, A. G. & Huijbregts, C. J. (1978). *Mining Geostatistics*.
33//!   Academic Press.
34
35use crate::error::{InterpolateError, InterpolateResult};
36
37// ---------------------------------------------------------------------------
38// Variogram trait and concrete models
39// ---------------------------------------------------------------------------
40
41/// Isotropic variogram model γ(h): characterises spatial variance as a
42/// function of separation distance `h ≥ 0`.
43pub trait Variogram: Send + Sync {
44    /// Semi-variance at lag `h`.
45    fn gamma(&self, h: f64) -> f64;
46
47    /// Whether the variogram is bounded (has a finite sill).
48    ///
49    /// Bounded models (spherical, exponential, Gaussian) approach a finite
50    /// sill and can use the covariance formulation `C = c0 − γ`.
51    /// Unbounded models (power/fractal) require the direct γ-formulation.
52    fn is_bounded(&self) -> bool {
53        true
54    }
55
56    /// Clone into a boxed trait object.
57    fn clone_box(&self) -> Box<dyn Variogram>;
58}
59
60/// Spherical variogram.
61///
62/// γ(h) = nugget + sill · \[3h/(2r) − h³/(2r³)\]  for h ≤ r  
63/// γ(h) = nugget + sill                              for h > r
64#[derive(Debug, Clone, Copy)]
65pub struct SphericalVariogram {
66    /// Micro-scale variance (discontinuity at origin).
67    pub nugget: f64,
68    /// Sill: variance at large distances.
69    pub sill: f64,
70    /// Range: distance at which the sill is (practically) reached.
71    pub range: f64,
72}
73
74impl Variogram for SphericalVariogram {
75    fn gamma(&self, h: f64) -> f64 {
76        if h <= 0.0 {
77            return 0.0;
78        }
79        if h >= self.range {
80            return self.nugget + self.sill;
81        }
82        let u = h / self.range;
83        self.nugget + self.sill * (1.5 * u - 0.5 * u * u * u)
84    }
85
86    fn clone_box(&self) -> Box<dyn Variogram> {
87        Box::new(*self)
88    }
89}
90
91/// Exponential variogram.
92///
93/// γ(h) = nugget + sill · (1 − exp(−3h/r))
94#[derive(Debug, Clone, Copy)]
95pub struct ExponentialVariogram {
96    pub nugget: f64,
97    pub sill: f64,
98    pub range: f64,
99}
100
101impl Variogram for ExponentialVariogram {
102    fn gamma(&self, h: f64) -> f64 {
103        if h <= 0.0 {
104            return 0.0;
105        }
106        self.nugget + self.sill * (1.0 - (-3.0 * h / self.range).exp())
107    }
108
109    fn clone_box(&self) -> Box<dyn Variogram> {
110        Box::new(*self)
111    }
112}
113
114/// Gaussian variogram.
115///
116/// γ(h) = nugget + sill · (1 − exp(−3h²/r²))
117#[derive(Debug, Clone, Copy)]
118pub struct GaussianVariogram {
119    pub nugget: f64,
120    pub sill: f64,
121    pub range: f64,
122}
123
124impl Variogram for GaussianVariogram {
125    fn gamma(&self, h: f64) -> f64 {
126        if h <= 0.0 {
127            return 0.0;
128        }
129        let u = h / self.range;
130        self.nugget + self.sill * (1.0 - (-3.0 * u * u).exp())
131    }
132
133    fn clone_box(&self) -> Box<dyn Variogram> {
134        Box::new(*self)
135    }
136}
137
138/// Power (fractal) variogram — unbounded.
139///
140/// γ(h) = nugget + slope · h^power  (power ∈ (0, 2))
141#[derive(Debug, Clone, Copy)]
142pub struct PowerVariogram {
143    pub nugget: f64,
144    pub slope: f64,
145    pub power: f64,
146}
147
148impl Variogram for PowerVariogram {
149    fn gamma(&self, h: f64) -> f64 {
150        if h <= 0.0 {
151            return 0.0;
152        }
153        self.nugget + self.slope * h.powf(self.power)
154    }
155
156    fn is_bounded(&self) -> bool {
157        false
158    }
159
160    fn clone_box(&self) -> Box<dyn Variogram> {
161        Box::new(*self)
162    }
163}
164
165// ---------------------------------------------------------------------------
166// LU factorisation (in-place Doolittle decomposition)
167// ---------------------------------------------------------------------------
168
169/// Doolittle LU factorisation with partial pivoting.
170///
171/// Returns (L·U matrix packed together, pivot indices).
172fn lu_factor(mut a: Vec<f64>, n: usize) -> InterpolateResult<(Vec<f64>, Vec<usize>)> {
173    let mut piv: Vec<usize> = (0..n).collect();
174    for k in 0..n {
175        // Find pivot
176        let mut max_val = a[k * n + k].abs();
177        let mut max_row = k;
178        for i in (k + 1)..n {
179            let v = a[i * n + k].abs();
180            if v > max_val {
181                max_val = v;
182                max_row = i;
183            }
184        }
185        if max_val < 1e-15 {
186            return Err(InterpolateError::ComputationError(
187                "Singular kriging matrix; add nugget > 0 or check data".into(),
188            ));
189        }
190        // Swap rows k and max_row
191        if max_row != k {
192            piv.swap(k, max_row);
193            for j in 0..n {
194                let tmp = a[k * n + j];
195                a[k * n + j] = a[max_row * n + j];
196                a[max_row * n + j] = tmp;
197            }
198        }
199        // Elimination
200        for i in (k + 1)..n {
201            a[i * n + k] /= a[k * n + k];
202            for j in (k + 1)..n {
203                let tmp = a[i * n + k] * a[k * n + j];
204                a[i * n + j] -= tmp;
205            }
206        }
207    }
208    Ok((a, piv))
209}
210
211/// Solve LU·x = b (in-place substitution).
212fn lu_solve(lu: &[f64], piv: &[usize], b: &[f64], n: usize) -> Vec<f64> {
213    // Apply row permutation
214    let mut x: Vec<f64> = (0..n).map(|i| b[piv[i]]).collect();
215    // Forward substitution (L·y = b)
216    for i in 0..n {
217        for j in 0..i {
218            x[i] -= lu[i * n + j] * x[j];
219        }
220    }
221    // Backward substitution (U·x = y)
222    for i in (0..n).rev() {
223        for j in (i + 1)..n {
224            x[i] -= lu[i * n + j] * x[j];
225        }
226        x[i] /= lu[i * n + i];
227    }
228    x
229}
230
231// ---------------------------------------------------------------------------
232// Ordinary Kriging
233// ---------------------------------------------------------------------------
234
235/// Ordinary Kriging interpolant.
236///
237/// The kriging system of size (n+1)×(n+1) is:
238///
239/// ```text
240/// [ C   1 ] [ w  ]   [ c₀ ]
241/// [ 1ᵀ  0 ] [ μ  ] = [ 1  ]
242/// ```
243///
244/// where `C[i,j] = cov(i,j) = (nugget+sill) - γ(||xᵢ−xⱼ||)` and
245/// `c₀[i] = (nugget+sill) - γ(||x−xᵢ||)` for the prediction point `x`.
246/// The nugget + sill value is the *a priori* variance (covariance at h=0⁺).
247pub struct OrdinaryKriging {
248    /// Data locations.
249    pub points: Vec<Vec<f64>>,
250    /// Observed values.
251    pub values: Vec<f64>,
252    /// Variogram model.
253    variogram: Box<dyn Variogram>,
254    /// LU factorisation of the kriging matrix (packed, row-major, size (n+1)²).
255    lu_mat: Vec<f64>,
256    /// Pivot vector for LU.
257    lu_piv: Vec<usize>,
258    /// Sill + nugget = C(0) — used when computing prediction variance.
259    c0: f64,
260    /// Number of data points.
261    n: usize,
262    /// Whether the variogram is bounded (covariance formulation) or not (γ formulation).
263    bounded: bool,
264}
265
266impl Clone for OrdinaryKriging {
267    fn clone(&self) -> Self {
268        Self {
269            points: self.points.clone(),
270            values: self.values.clone(),
271            variogram: self.variogram.clone_box(),
272            lu_mat: self.lu_mat.clone(),
273            lu_piv: self.lu_piv.clone(),
274            c0: self.c0,
275            n: self.n,
276            bounded: self.bounded,
277        }
278    }
279}
280
281impl std::fmt::Debug for OrdinaryKriging {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.debug_struct("OrdinaryKriging")
284            .field("n", &self.n)
285            .finish()
286    }
287}
288
289impl OrdinaryKriging {
290    /// Fit the ordinary kriging interpolant to scattered data.
291    ///
292    /// # Arguments
293    ///
294    /// * `points`    – Data sites (n × d).
295    /// * `values`    – Observed values at each site.
296    /// * `variogram` – Variogram model implementing [`Variogram`].
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the kriging matrix is singular (add nugget > 0).
301    pub fn fit(
302        points: Vec<Vec<f64>>,
303        values: Vec<f64>,
304        variogram: Box<dyn Variogram>,
305    ) -> InterpolateResult<OrdinaryKriging> {
306        let n = points.len();
307        if n == 0 {
308            return Err(InterpolateError::InvalidInput {
309                message: "no data points".into(),
310            });
311        }
312        if values.len() != n {
313            return Err(InterpolateError::ShapeMismatch {
314                expected: format!("{}", n),
315                actual: format!("{}", values.len()),
316                object: "values".into(),
317            });
318        }
319
320        let bounded = variogram.is_bounded();
321
322        // For bounded variograms: covariance formulation C[i,j] = c0 − γ(h)
323        // For unbounded variograms: direct γ-formulation   Γ[i,j] = γ(h)
324        let c0 = if bounded {
325            variogram.gamma(1e12)
326        } else {
327            0.0 // not used in γ-formulation
328        };
329
330        // Build (n+1)×(n+1) kriging matrix
331        let m = n + 1;
332        let mut mat = vec![0.0_f64; m * m];
333        for i in 0..n {
334            for j in 0..n {
335                let h = euclidean_dist(&points[i], &points[j]);
336                let gamma = variogram.gamma(h);
337                mat[i * m + j] = if bounded { c0 - gamma } else { gamma };
338            }
339            // Lagrange multiplier row/col
340            mat[i * m + n] = 1.0;
341            mat[n * m + i] = 1.0;
342        }
343        // Bottom-right: 0
344        mat[n * m + n] = 0.0;
345
346        let (lu_mat, lu_piv) = lu_factor(mat, m)?;
347
348        Ok(OrdinaryKriging {
349            points,
350            values,
351            variogram,
352            lu_mat,
353            lu_piv,
354            c0,
355            n,
356            bounded,
357        })
358    }
359
360    /// Predict at a new point `x`.
361    ///
362    /// Returns `(estimate, variance)`.
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if the dimension of `x` does not match the training data.
367    pub fn predict(&self, x: &[f64]) -> InterpolateResult<(f64, f64)> {
368        if !self.points.is_empty() && x.len() != self.points[0].len() {
369            return Err(InterpolateError::DimensionMismatch(format!(
370                "expected dim {}, got {}",
371                self.points[0].len(),
372                x.len()
373            )));
374        }
375
376        let m = self.n + 1;
377        let mut rhs = vec![0.0_f64; m];
378        for i in 0..self.n {
379            let h = euclidean_dist(x, &self.points[i]);
380            let gamma = self.variogram.gamma(h);
381            rhs[i] = if self.bounded { self.c0 - gamma } else { gamma };
382        }
383        rhs[self.n] = 1.0;
384
385        // Solve kriging system
386        let sol = lu_solve(&self.lu_mat, &self.lu_piv, &rhs, m);
387
388        // Estimate: Σ wᵢ · zᵢ
389        let estimate: f64 = (0..self.n).map(|i| sol[i] * self.values[i]).sum();
390
391        // Kriging variance
392        let rhs_dot_w: f64 = (0..self.n).map(|i| rhs[i] * sol[i]).sum();
393        let variance = if self.bounded {
394            // σ² = c0 − cᵀw − μ
395            (self.c0 - rhs_dot_w - sol[self.n]).max(0.0)
396        } else {
397            // γ-formulation: σ² = γᵀw + μ
398            (rhs_dot_w + sol[self.n]).max(0.0)
399        };
400
401        Ok((estimate, variance))
402    }
403}
404
405// ---------------------------------------------------------------------------
406// Helper
407// ---------------------------------------------------------------------------
408
409fn euclidean_dist(a: &[f64], b: &[f64]) -> f64 {
410    a.iter()
411        .zip(b.iter())
412        .map(|(x, y)| (x - y) * (x - y))
413        .sum::<f64>()
414        .sqrt()
415}
416
417// ---------------------------------------------------------------------------
418// Tests
419// ---------------------------------------------------------------------------
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn make_1d_data() -> (Vec<Vec<f64>>, Vec<f64>) {
426        let xs = vec![0.0_f64, 1.0, 2.0, 3.0, 4.0];
427        let pts: Vec<Vec<f64>> = xs.iter().map(|&x| vec![x]).collect();
428        let vals: Vec<f64> = xs.iter().map(|&x| x * x).collect(); // f(x)=x²
429        (pts, vals)
430    }
431
432    #[test]
433    fn test_spherical_kriging_interpolates_data() {
434        let (pts, vals) = make_1d_data();
435        let vgm = SphericalVariogram {
436            nugget: 0.0,
437            sill: 20.0,
438            range: 10.0,
439        };
440        let ok =
441            OrdinaryKriging::fit(pts.clone(), vals.clone(), Box::new(vgm)).expect("fit failed");
442
443        for (p, &v) in pts.iter().zip(vals.iter()) {
444            let (est, _var) = ok.predict(p).expect("predict failed");
445            assert!(
446                (est - v).abs() < 1e-6,
447                "spherical: at {:?} expected {} got {}",
448                p,
449                v,
450                est
451            );
452        }
453    }
454
455    #[test]
456    fn test_exponential_kriging_interpolates_data() {
457        let (pts, vals) = make_1d_data();
458        let vgm = ExponentialVariogram {
459            nugget: 0.0,
460            sill: 20.0,
461            range: 10.0,
462        };
463        let ok =
464            OrdinaryKriging::fit(pts.clone(), vals.clone(), Box::new(vgm)).expect("fit failed");
465
466        for (p, &v) in pts.iter().zip(vals.iter()) {
467            let (est, _) = ok.predict(p).expect("predict");
468            assert!((est - v).abs() < 1e-6, "exp: {:?} {} {}", p, v, est);
469        }
470    }
471
472    #[test]
473    fn test_gaussian_kriging_interpolates_data() {
474        let (pts, vals) = make_1d_data();
475        let vgm = GaussianVariogram {
476            nugget: 0.0,
477            sill: 20.0,
478            range: 10.0,
479        };
480        let ok =
481            OrdinaryKriging::fit(pts.clone(), vals.clone(), Box::new(vgm)).expect("fit failed");
482
483        for (p, &v) in pts.iter().zip(vals.iter()) {
484            let (est, _) = ok.predict(p).expect("predict");
485            assert!((est - v).abs() < 1e-6, "gauss: {:?} {} {}", p, v, est);
486        }
487    }
488
489    #[test]
490    fn test_power_variogram() {
491        let (pts, vals) = make_1d_data();
492        let vgm = PowerVariogram {
493            nugget: 0.0,
494            slope: 1.0,
495            power: 1.5,
496        };
497        let ok =
498            OrdinaryKriging::fit(pts.clone(), vals.clone(), Box::new(vgm)).expect("fit failed");
499
500        for (p, &v) in pts.iter().zip(vals.iter()) {
501            let (est, _) = ok.predict(p).expect("predict");
502            assert!((est - v).abs() < 1e-4, "power: {:?} {} {}", p, v, est);
503        }
504    }
505
506    #[test]
507    fn test_variance_is_nonnegative() {
508        let (pts, vals) = make_1d_data();
509        let vgm = SphericalVariogram {
510            nugget: 0.01,
511            sill: 20.0,
512            range: 10.0,
513        };
514        let ok = OrdinaryKriging::fit(pts, vals, Box::new(vgm)).expect("fit failed");
515        let test_pts = vec![vec![0.5_f64], vec![1.5], vec![2.5]];
516        for p in &test_pts {
517            let (_est, var) = ok.predict(p).expect("predict");
518            assert!(var >= 0.0, "variance negative at {:?}: {}", p, var);
519        }
520    }
521
522    #[test]
523    fn test_variogram_gamma_at_zero() {
524        let svgm = SphericalVariogram {
525            nugget: 0.1,
526            sill: 1.0,
527            range: 2.0,
528        };
529        assert_eq!(svgm.gamma(0.0), 0.0);
530        let evgm = ExponentialVariogram {
531            nugget: 0.1,
532            sill: 1.0,
533            range: 2.0,
534        };
535        assert_eq!(evgm.gamma(0.0), 0.0);
536        let gvgm = GaussianVariogram {
537            nugget: 0.1,
538            sill: 1.0,
539            range: 2.0,
540        };
541        assert_eq!(gvgm.gamma(0.0), 0.0);
542        let pvgm = PowerVariogram {
543            nugget: 0.1,
544            slope: 1.0,
545            power: 1.5,
546        };
547        assert_eq!(pvgm.gamma(0.0), 0.0);
548    }
549
550    #[test]
551    fn test_spherical_reaches_sill() {
552        let vgm = SphericalVariogram {
553            nugget: 0.0,
554            sill: 5.0,
555            range: 2.0,
556        };
557        let v = vgm.gamma(100.0);
558        assert!((v - 5.0).abs() < 1e-10, "should reach sill: {}", v);
559    }
560
561    #[test]
562    fn test_error_on_empty() {
563        let vgm = SphericalVariogram {
564            nugget: 0.0,
565            sill: 1.0,
566            range: 1.0,
567        };
568        let r = OrdinaryKriging::fit(vec![], vec![], Box::new(vgm));
569        assert!(r.is_err());
570    }
571
572    #[test]
573    fn test_error_on_dim_mismatch_predict() {
574        let pts = vec![vec![0.0_f64, 0.0], vec![1.0, 1.0]];
575        let vals = vec![0.0_f64, 1.0];
576        let vgm = GaussianVariogram {
577            nugget: 0.0,
578            sill: 1.0,
579            range: 5.0,
580        };
581        let ok = OrdinaryKriging::fit(pts, vals, Box::new(vgm)).expect("fit");
582        let r = ok.predict(&[0.5]); // wrong dim
583        assert!(r.is_err());
584    }
585}