Skip to main content

oxigdal_analytics/interpolation/
kriging.rs

1//! Kriging Interpolation
2//!
3//! Kriging is a geostatistical interpolation method that uses variogram models
4//! to provide Best Linear Unbiased Predictions (BLUP).
5
6use crate::error::{AnalyticsError, Result};
7use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
8
9/// Kriging types
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum KrigingType {
12    /// Ordinary Kriging (constant mean)
13    Ordinary,
14    /// Universal Kriging (trend surface)
15    Universal,
16}
17
18/// Variogram models
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum VariogramModel {
21    /// Spherical variogram
22    Spherical,
23    /// Exponential variogram
24    Exponential,
25    /// Gaussian variogram
26    Gaussian,
27    /// Linear variogram
28    Linear,
29}
30
31/// Variogram parameters
32#[derive(Debug, Clone, Copy)]
33pub struct Variogram {
34    /// Nugget effect
35    pub nugget: f64,
36    /// Sill (total variance)
37    pub sill: f64,
38    /// Range parameter
39    pub range: f64,
40    /// Model type
41    pub model: VariogramModel,
42}
43
44impl Variogram {
45    /// Create a new variogram
46    pub fn new(model: VariogramModel, nugget: f64, sill: f64, range: f64) -> Self {
47        Self {
48            nugget,
49            sill,
50            range,
51            model,
52        }
53    }
54
55    /// Evaluate variogram at distance h
56    pub fn evaluate(&self, h: f64) -> f64 {
57        if h < f64::EPSILON {
58            return 0.0;
59        }
60
61        let partial_sill = self.sill - self.nugget;
62
63        match self.model {
64            VariogramModel::Spherical => {
65                if h >= self.range {
66                    self.sill
67                } else {
68                    let h_r = h / self.range;
69                    self.nugget + partial_sill * (1.5 * h_r - 0.5 * h_r.powi(3))
70                }
71            }
72            VariogramModel::Exponential => {
73                self.nugget + partial_sill * (1.0 - (-h / self.range).exp())
74            }
75            VariogramModel::Gaussian => {
76                self.nugget + partial_sill * (1.0 - (-(h * h) / (self.range * self.range)).exp())
77            }
78            VariogramModel::Linear => {
79                let slope = self.sill / self.range;
80                self.nugget + slope * h.min(self.range)
81            }
82        }
83    }
84}
85
86/// Kriging result
87#[derive(Debug, Clone)]
88pub struct KrigingResult {
89    /// Interpolated values
90    pub values: Array1<f64>,
91    /// Prediction variances
92    pub variances: Array1<f64>,
93    /// Target coordinates
94    pub coordinates: Array2<f64>,
95}
96
97/// Kriging interpolator
98pub struct KrigingInterpolator {
99    kriging_type: KrigingType,
100    variogram: Variogram,
101}
102
103impl KrigingInterpolator {
104    /// Create a new Kriging interpolator
105    ///
106    /// # Arguments
107    /// * `kriging_type` - Type of kriging
108    /// * `variogram` - Variogram model
109    pub fn new(kriging_type: KrigingType, variogram: Variogram) -> Self {
110        Self {
111            kriging_type,
112            variogram,
113        }
114    }
115
116    /// Interpolate values at target locations
117    ///
118    /// # Arguments
119    /// * `points` - Known point coordinates (n_points × n_dim)
120    /// * `values` - Known values (n_points)
121    /// * `targets` - Target coordinates (n_targets × n_dim)
122    ///
123    /// # Errors
124    /// Returns error if interpolation fails
125    pub fn interpolate(
126        &self,
127        points: &Array2<f64>,
128        values: &ArrayView1<f64>,
129        targets: &Array2<f64>,
130    ) -> Result<KrigingResult> {
131        let n_points = points.nrows();
132        let n_targets = targets.nrows();
133
134        if values.len() != n_points {
135            return Err(AnalyticsError::dimension_mismatch(
136                format!("{}", n_points),
137                format!("{}", values.len()),
138            ));
139        }
140
141        if targets.ncols() != points.ncols() {
142            return Err(AnalyticsError::dimension_mismatch(
143                format!("{}", points.ncols()),
144                format!("{}", targets.ncols()),
145            ));
146        }
147
148        // Build covariance matrix
149        let cov_matrix = self.build_covariance_matrix(points)?;
150
151        // Solve kriging system once for efficiency
152        let weights_matrix = self.solve_kriging_system(&cov_matrix)?;
153
154        let mut interpolated = Array1::zeros(n_targets);
155        let mut variances = Array1::zeros(n_targets);
156
157        for i in 0..n_targets {
158            let target = targets.row(i);
159            let (value, variance) =
160                self.interpolate_point(&target, points, values, &weights_matrix)?;
161            interpolated[i] = value;
162            variances[i] = variance;
163        }
164
165        Ok(KrigingResult {
166            values: interpolated,
167            variances,
168            coordinates: targets.clone(),
169        })
170    }
171
172    /// Build covariance matrix from variogram
173    fn build_covariance_matrix(&self, points: &Array2<f64>) -> Result<Array2<f64>> {
174        let n = points.nrows();
175        let size = match self.kriging_type {
176            KrigingType::Ordinary => n + 1,  // Add Lagrange multiplier
177            KrigingType::Universal => n + 4, // Add trend terms (constant + x + y + xy)
178        };
179
180        let mut matrix = Array2::zeros((size, size));
181
182        // Fill in covariances
183        for i in 0..n {
184            for j in 0..n {
185                let dist = self.calculate_distance(&points.row(i), &points.row(j))?;
186                let covariance = self.variogram.sill - self.variogram.evaluate(dist);
187                matrix[[i, j]] = covariance;
188            }
189        }
190
191        // Add constraint equations
192        match self.kriging_type {
193            KrigingType::Ordinary => {
194                // Sum of weights = 1
195                for i in 0..n {
196                    matrix[[i, n]] = 1.0;
197                    matrix[[n, i]] = 1.0;
198                }
199            }
200            KrigingType::Universal => {
201                // Trend surface constraints
202                for i in 0..n {
203                    let x = points[[i, 0]];
204                    let y = points[[i, 1]];
205                    matrix[[i, n]] = 1.0;
206                    matrix[[n, i]] = 1.0;
207                    matrix[[i, n + 1]] = x;
208                    matrix[[n + 1, i]] = x;
209                    matrix[[i, n + 2]] = y;
210                    matrix[[n + 2, i]] = y;
211                    matrix[[i, n + 3]] = x * y;
212                    matrix[[n + 3, i]] = x * y;
213                }
214            }
215        }
216
217        Ok(matrix)
218    }
219
220    /// Solve kriging system using matrix inversion
221    fn solve_kriging_system(&self, cov_matrix: &Array2<f64>) -> Result<Array2<f64>> {
222        // For simplicity, use Gaussian elimination
223        // In production, would use proper linear algebra library
224        self.matrix_inverse(cov_matrix)
225    }
226
227    /// Simple matrix inversion using Gauss-Jordan elimination
228    fn matrix_inverse(&self, matrix: &Array2<f64>) -> Result<Array2<f64>> {
229        let n = matrix.nrows();
230        if n != matrix.ncols() {
231            return Err(AnalyticsError::matrix_error("Matrix must be square"));
232        }
233
234        // Create augmented matrix [A | I]
235        let mut aug = Array2::zeros((n, 2 * n));
236        for i in 0..n {
237            for j in 0..n {
238                aug[[i, j]] = matrix[[i, j]];
239            }
240            aug[[i, n + i]] = 1.0;
241        }
242
243        // Gauss-Jordan elimination
244        for i in 0..n {
245            // Find pivot
246            let mut max_row = i;
247            let mut max_val = aug[[i, i]].abs();
248            for k in (i + 1)..n {
249                if aug[[k, i]].abs() > max_val {
250                    max_val = aug[[k, i]].abs();
251                    max_row = k;
252                }
253            }
254
255            if max_val < f64::EPSILON {
256                return Err(AnalyticsError::matrix_error("Matrix is singular"));
257            }
258
259            // Swap rows
260            if max_row != i {
261                for j in 0..(2 * n) {
262                    let tmp = aug[[i, j]];
263                    aug[[i, j]] = aug[[max_row, j]];
264                    aug[[max_row, j]] = tmp;
265                }
266            }
267
268            // Eliminate column
269            let pivot = aug[[i, i]];
270            for j in 0..(2 * n) {
271                aug[[i, j]] /= pivot;
272            }
273
274            for k in 0..n {
275                if k != i {
276                    let factor = aug[[k, i]];
277                    for j in 0..(2 * n) {
278                        aug[[k, j]] -= factor * aug[[i, j]];
279                    }
280                }
281            }
282        }
283
284        // Extract inverse matrix
285        let mut inverse = Array2::zeros((n, n));
286        for i in 0..n {
287            for j in 0..n {
288                inverse[[i, j]] = aug[[i, n + j]];
289            }
290        }
291
292        Ok(inverse)
293    }
294
295    /// Interpolate at a single point
296    fn interpolate_point(
297        &self,
298        target: &scirs2_core::ndarray::ArrayView1<f64>,
299        points: &Array2<f64>,
300        values: &ArrayView1<f64>,
301        weights_matrix: &Array2<f64>,
302    ) -> Result<(f64, f64)> {
303        let n = points.nrows();
304
305        // Build right-hand side vector
306        let rhs_size = match self.kriging_type {
307            KrigingType::Ordinary => n + 1,
308            KrigingType::Universal => n + 4,
309        };
310
311        let mut rhs = Array1::zeros(rhs_size);
312
313        // Fill in covariances to target
314        for i in 0..n {
315            let dist = self.calculate_distance(&points.row(i), target)?;
316            rhs[i] = self.variogram.sill - self.variogram.evaluate(dist);
317        }
318
319        // Add constraints
320        match self.kriging_type {
321            KrigingType::Ordinary => {
322                rhs[n] = 1.0;
323            }
324            KrigingType::Universal => {
325                rhs[n] = 1.0;
326                rhs[n + 1] = target[0];
327                rhs[n + 2] = target[1];
328                rhs[n + 3] = target[0] * target[1];
329            }
330        }
331
332        // Solve for weights
333        let mut weights: Array1<f64> = Array1::zeros(rhs_size);
334        for i in 0..rhs_size {
335            for j in 0..rhs_size {
336                weights[i] += weights_matrix[[i, j]] * rhs[j];
337            }
338        }
339
340        // Calculate interpolated value
341        let mut value: f64 = 0.0;
342        for i in 0..n {
343            value += weights[i] * values[i];
344        }
345
346        // Calculate kriging variance
347        let mut variance = self.variogram.sill;
348        for i in 0..rhs_size {
349            variance -= weights[i] * rhs[i];
350        }
351
352        Ok((value, variance.max(0.0)))
353    }
354
355    /// Calculate distance between two points
356    fn calculate_distance(
357        &self,
358        p1: &scirs2_core::ndarray::ArrayView1<f64>,
359        p2: &scirs2_core::ndarray::ArrayView1<f64>,
360    ) -> Result<f64> {
361        if p1.len() != p2.len() {
362            return Err(AnalyticsError::dimension_mismatch(
363                format!("{}", p1.len()),
364                format!("{}", p2.len()),
365            ));
366        }
367
368        let dist_sq: f64 = p1.iter().zip(p2.iter()).map(|(a, b)| (a - b).powi(2)).sum();
369        Ok(dist_sq.sqrt())
370    }
371}
372
373/// Semivariogram calculator
374pub struct SemivariogramCalculator;
375
376impl SemivariogramCalculator {
377    /// Calculate experimental semivariogram
378    ///
379    /// # Arguments
380    /// * `points` - Point coordinates
381    /// * `values` - Values at points
382    /// * `n_bins` - Number of distance bins
383    ///
384    /// # Errors
385    /// Returns error if calculation fails
386    pub fn calculate(
387        points: &Array2<f64>,
388        values: &ArrayView1<f64>,
389        n_bins: usize,
390    ) -> Result<(Array1<f64>, Array1<f64>)> {
391        let n = points.nrows();
392        if values.len() != n {
393            return Err(AnalyticsError::dimension_mismatch(
394                format!("{}", n),
395                format!("{}", values.len()),
396            ));
397        }
398
399        // Calculate all pairwise distances and semivariances
400        let mut pairs = Vec::new();
401        for i in 0..n {
402            for j in (i + 1)..n {
403                let mut dist_sq = 0.0;
404                for k in 0..points.ncols() {
405                    let diff = points[[i, k]] - points[[j, k]];
406                    dist_sq += diff * diff;
407                }
408                let dist = dist_sq.sqrt();
409                let semivar = 0.5 * (values[i] - values[j]).powi(2);
410                pairs.push((dist, semivar));
411            }
412        }
413
414        if pairs.is_empty() {
415            return Err(AnalyticsError::insufficient_data("Need at least 2 points"));
416        }
417
418        // Find max distance for binning
419        let max_dist = pairs
420            .iter()
421            .map(|(d, _)| *d)
422            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
423            .ok_or_else(|| AnalyticsError::insufficient_data("No valid distances"))?;
424
425        let bin_width = max_dist / (n_bins as f64);
426
427        // Bin semivariances
428        let mut bin_sums = vec![0.0; n_bins];
429        let mut bin_counts = vec![0usize; n_bins];
430
431        for (dist, semivar) in pairs {
432            let bin = ((dist / bin_width).floor() as usize).min(n_bins - 1);
433            bin_sums[bin] += semivar;
434            bin_counts[bin] += 1;
435        }
436
437        // Calculate average semivariance for each bin
438        let mut distances = Vec::new();
439        let mut semivariances = Vec::new();
440
441        for i in 0..n_bins {
442            if bin_counts[i] > 0 {
443                distances.push((i as f64 + 0.5) * bin_width);
444                semivariances.push(bin_sums[i] / (bin_counts[i] as f64));
445            }
446        }
447
448        Ok((Array1::from_vec(distances), Array1::from_vec(semivariances)))
449    }
450}
451
452// ------------------------------------------------------------------
453// Universal Kriging with External Drift (Wackernagel 2003 §16,
454// Cressie 1993 §3.4.2). Free-function API operating on shaped views;
455// reuses the same Gauss-Jordan inversion style as KrigingInterpolator.
456// ------------------------------------------------------------------
457
458use scirs2_core::ndarray::ArrayView2;
459
460/// Drift basis function for Universal Kriging with external drift.
461///
462/// Each variant contributes one or more columns to the design matrix `F`
463/// (samples × drift). The composite drift is the concatenation of all
464/// variants, in the order supplied to [`UniversalKrigingOptions`].
465#[derive(Debug, Clone)]
466pub enum DriftBasis {
467    /// `f(x) = 1` — intercept only (1 column).
468    Constant,
469    /// `f(x) = (1, x, y)` — affine surface drift (3 columns).
470    Linear,
471    /// `f(x) = (1, x, y, x², xy, y²)` — second-order polynomial drift (6 columns).
472    Quadratic,
473    /// `f(x) = caller-supplied per-sample values` (1 column). The length of
474    /// the vector must equal the number of samples.
475    External(Vec<f64>),
476}
477
478/// Options for Universal Kriging with external drift.
479///
480/// The total drift column count `p` is the sum of each basis' contribution:
481/// `Constant` = 1, `Linear` = 3, `Quadratic` = 6, `External(v)` = 1.
482#[derive(Debug, Clone)]
483pub struct UniversalKrigingOptions {
484    /// Ordered list of drift bases composing the trend model.
485    pub drift_bases: Vec<DriftBasis>,
486    /// Variogram model used to populate `Γ` and `γ_0`.
487    pub variogram: Variogram,
488    /// Tikhonov regularisation added to the diagonal of `Γ`.
489    pub regularization: f64,
490}
491
492impl Default for UniversalKrigingOptions {
493    fn default() -> Self {
494        Self {
495            drift_bases: vec![DriftBasis::Constant],
496            variogram: Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 1.0),
497            regularization: 1e-10,
498        }
499    }
500}
501
502/// Result of universal kriging prediction.
503#[derive(Debug, Clone)]
504pub struct UniversalKrigingResult {
505    /// Predicted values at each query point (length `m`).
506    pub predicted: Vec<f64>,
507    /// Kriging variance at each query point (length `m`, clamped to ≥ 0).
508    pub variance: Vec<f64>,
509    /// Drift coefficients (Lagrange multipliers) from the final query
510    /// point's solve (length `p`).
511    pub drift_coefficients: Vec<f64>,
512}
513
514/// Number of drift columns contributed by a single basis.
515fn drift_basis_columns(basis: &DriftBasis) -> usize {
516    match basis {
517        DriftBasis::Constant => 1,
518        DriftBasis::Linear => 3,
519        DriftBasis::Quadratic => 6,
520        DriftBasis::External(_) => 1,
521    }
522}
523
524/// Fill row `i` of `F` for sample at `(x, y)` using `bases`.
525fn fill_drift_row(bases: &[DriftBasis], sample_index: usize, x: f64, y: f64, row: &mut [f64]) {
526    let mut col = 0usize;
527    for basis in bases {
528        match basis {
529            DriftBasis::Constant => {
530                row[col] = 1.0;
531                col += 1;
532            }
533            DriftBasis::Linear => {
534                row[col] = 1.0;
535                row[col + 1] = x;
536                row[col + 2] = y;
537                col += 3;
538            }
539            DriftBasis::Quadratic => {
540                row[col] = 1.0;
541                row[col + 1] = x;
542                row[col + 2] = y;
543                row[col + 3] = x * x;
544                row[col + 4] = x * y;
545                row[col + 5] = y * y;
546                col += 6;
547            }
548            DriftBasis::External(values) => {
549                row[col] = values[sample_index];
550                col += 1;
551            }
552        }
553    }
554}
555
556/// Fill `f_0` for query point at `(x, y)` using `bases` and optional
557/// per-query external drift row.
558fn fill_query_drift_row(
559    bases: &[DriftBasis],
560    x: f64,
561    y: f64,
562    external_row: Option<&[f64]>,
563    f0: &mut [f64],
564) -> Result<()> {
565    let mut col = 0usize;
566    let mut external_used = 0usize;
567    for basis in bases {
568        match basis {
569            DriftBasis::Constant => {
570                f0[col] = 1.0;
571                col += 1;
572            }
573            DriftBasis::Linear => {
574                f0[col] = 1.0;
575                f0[col + 1] = x;
576                f0[col + 2] = y;
577                col += 3;
578            }
579            DriftBasis::Quadratic => {
580                f0[col] = 1.0;
581                f0[col + 1] = x;
582                f0[col + 2] = y;
583                f0[col + 3] = x * x;
584                f0[col + 4] = x * y;
585                f0[col + 5] = y * y;
586                col += 6;
587            }
588            DriftBasis::External(_) => {
589                let row = external_row.ok_or_else(|| {
590                    AnalyticsError::invalid_input(
591                        "query_external_drift required for DriftBasis::External",
592                    )
593                })?;
594                if external_used >= row.len() {
595                    return Err(AnalyticsError::dimension_mismatch(
596                        format!(">= {}", external_used + 1),
597                        format!("{}", row.len()),
598                    ));
599                }
600                f0[col] = row[external_used];
601                external_used += 1;
602                col += 1;
603            }
604        }
605    }
606    Ok(())
607}
608
609/// Squared Euclidean distance between two `(x, y)` points.
610fn squared_distance_2d(ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
611    let dx = ax - bx;
612    let dy = ay - by;
613    dx * dx + dy * dy
614}
615
616/// Gauss-Jordan inversion mirroring the style used by
617/// `KrigingInterpolator::matrix_inverse`. Returns `None` if rank-deficient.
618fn gauss_jordan_invert_uked(matrix: &Array2<f64>) -> Option<Array2<f64>> {
619    let n = matrix.nrows();
620    if n != matrix.ncols() || n == 0 {
621        return None;
622    }
623
624    let mut aug = Array2::<f64>::zeros((n, 2 * n));
625    for i in 0..n {
626        for j in 0..n {
627            aug[[i, j]] = matrix[[i, j]];
628        }
629        aug[[i, n + i]] = 1.0;
630    }
631
632    for i in 0..n {
633        let mut max_row = i;
634        let mut max_val = aug[[i, i]].abs();
635        for k in (i + 1)..n {
636            let v = aug[[k, i]].abs();
637            if v > max_val {
638                max_val = v;
639                max_row = k;
640            }
641        }
642
643        if max_val < f64::EPSILON {
644            return None;
645        }
646
647        if max_row != i {
648            for j in 0..(2 * n) {
649                let tmp = aug[[i, j]];
650                aug[[i, j]] = aug[[max_row, j]];
651                aug[[max_row, j]] = tmp;
652            }
653        }
654
655        let pivot = aug[[i, i]];
656        for j in 0..(2 * n) {
657            aug[[i, j]] /= pivot;
658        }
659
660        for k in 0..n {
661            if k != i {
662                let factor = aug[[k, i]];
663                if factor != 0.0 {
664                    for j in 0..(2 * n) {
665                        aug[[k, j]] -= factor * aug[[i, j]];
666                    }
667                }
668            }
669        }
670    }
671
672    let mut inverse = Array2::<f64>::zeros((n, n));
673    for i in 0..n {
674        for j in 0..n {
675            inverse[[i, j]] = aug[[i, n + j]];
676        }
677    }
678    Some(inverse)
679}
680
681/// Fit and predict using Universal Kriging with external drift.
682///
683/// Solves the augmented system
684///
685/// ```text
686///     | Γ   F | | λ |   | γ_0 |
687///     | Fᵀ  0 | | μ | = | f_0 |
688/// ```
689///
690/// once per query point by inverting the shared `(n+p) × (n+p)` design
691/// matrix and multiplying by each RHS.
692///
693/// # Arguments
694/// * `coords` — sample coordinates of shape `(n, 2)`.
695/// * `values` — sample observations of length `n`.
696/// * `options` — variogram, regularisation, and drift bases.
697/// * `query_points` — prediction coordinates of shape `(m, 2)`.
698/// * `query_external_drift` — optional per-query external drift values of
699///   shape `(m, k_ext)`; required iff any [`DriftBasis::External`] is
700///   present. `k_ext` must equal the count of `External` bases.
701///
702/// # Errors
703/// * Mismatched array dimensions.
704/// * `DriftBasis::External(v)` length differing from sample count.
705/// * Singular augmented matrix (insufficient samples vs. drift columns).
706pub fn universal_kriging_fit(
707    coords: ArrayView2<f64>,
708    values: ArrayView1<f64>,
709    options: &UniversalKrigingOptions,
710    query_points: ArrayView2<f64>,
711    query_external_drift: Option<ArrayView2<f64>>,
712) -> Result<UniversalKrigingResult> {
713    let n = values.len();
714    if coords.nrows() != n {
715        return Err(AnalyticsError::dimension_mismatch(
716            format!("{}", n),
717            format!("{}", coords.nrows()),
718        ));
719    }
720    if coords.ncols() != 2 {
721        return Err(AnalyticsError::dimension_mismatch(
722            "2",
723            format!("{}", coords.ncols()),
724        ));
725    }
726    if query_points.ncols() != 2 {
727        return Err(AnalyticsError::dimension_mismatch(
728            "2",
729            format!("{}", query_points.ncols()),
730        ));
731    }
732    if options.drift_bases.is_empty() {
733        return Err(AnalyticsError::invalid_input(
734            "UniversalKrigingOptions.drift_bases must not be empty",
735        ));
736    }
737    if !options.regularization.is_finite() || options.regularization < 0.0 {
738        return Err(AnalyticsError::invalid_parameter(
739            "regularization",
740            "must be a non-negative finite number",
741        ));
742    }
743
744    // Validate every External basis has length n and count them.
745    let mut external_basis_count = 0usize;
746    for basis in &options.drift_bases {
747        if let DriftBasis::External(v) = basis {
748            if v.len() != n {
749                return Err(AnalyticsError::dimension_mismatch(
750                    format!("{}", n),
751                    format!("{}", v.len()),
752                ));
753            }
754            external_basis_count += 1;
755        }
756    }
757
758    // Compute total drift columns p.
759    let p: usize = options.drift_bases.iter().map(drift_basis_columns).sum();
760    if p == 0 {
761        return Err(AnalyticsError::invalid_input(
762            "drift bases must contribute at least one column",
763        ));
764    }
765    if n < p {
766        return Err(AnalyticsError::insufficient_data(format!(
767            "universal kriging needs at least p={p} samples, got n={n}"
768        )));
769    }
770
771    // Validate query_external_drift shape when External is present.
772    let m = query_points.nrows();
773    if external_basis_count > 0 {
774        let qed = query_external_drift.ok_or_else(|| {
775            AnalyticsError::invalid_input(
776                "query_external_drift required when DriftBasis::External is used",
777            )
778        })?;
779        if qed.nrows() != m {
780            return Err(AnalyticsError::dimension_mismatch(
781                format!("{}", m),
782                format!("{}", qed.nrows()),
783            ));
784        }
785        if qed.ncols() != external_basis_count {
786            return Err(AnalyticsError::dimension_mismatch(
787                format!("{}", external_basis_count),
788                format!("{}", qed.ncols()),
789            ));
790        }
791    }
792
793    // Build Γ (n × n) and F (n × p).
794    let mut gamma = Array2::<f64>::zeros((n, n));
795    for i in 0..n {
796        let xi = coords[[i, 0]];
797        let yi = coords[[i, 1]];
798        for j in 0..n {
799            if i == j {
800                gamma[[i, j]] = 0.0;
801            } else {
802                let xj = coords[[j, 0]];
803                let yj = coords[[j, 1]];
804                let h = squared_distance_2d(xi, yi, xj, yj).sqrt();
805                gamma[[i, j]] = options.variogram.evaluate(h);
806            }
807        }
808    }
809
810    let mut f_matrix = Array2::<f64>::zeros((n, p));
811    for i in 0..n {
812        let xi = coords[[i, 0]];
813        let yi = coords[[i, 1]];
814        let mut row_buf = vec![0.0f64; p];
815        fill_drift_row(&options.drift_bases, i, xi, yi, &mut row_buf);
816        for col in 0..p {
817            f_matrix[[i, col]] = row_buf[col];
818        }
819    }
820
821    // Assemble M = [[Γ + λI, F], [Fᵀ, 0]] of size (n+p) × (n+p).
822    let size = n + p;
823    let mut m_design = Array2::<f64>::zeros((size, size));
824    for i in 0..n {
825        for j in 0..n {
826            m_design[[i, j]] = gamma[[i, j]];
827        }
828        m_design[[i, i]] += options.regularization;
829    }
830    for i in 0..n {
831        for j in 0..p {
832            m_design[[i, n + j]] = f_matrix[[i, j]];
833            m_design[[n + j, i]] = f_matrix[[i, j]];
834        }
835    }
836    // The lower-right p × p block is zero by initialisation.
837
838    let m_inv = gauss_jordan_invert_uked(&m_design).ok_or_else(|| {
839        AnalyticsError::matrix_error("UKED design matrix singular (rank-deficient solve)")
840    })?;
841
842    let mut predicted = Vec::with_capacity(m);
843    let mut variance = Vec::with_capacity(m);
844    let mut drift_coefficients = vec![0.0f64; p];
845
846    for q in 0..m {
847        let qx = query_points[[q, 0]];
848        let qy = query_points[[q, 1]];
849
850        // Build RHS b = [γ_0; f_0].
851        let mut b = vec![0.0f64; size];
852        for i in 0..n {
853            let xi = coords[[i, 0]];
854            let yi = coords[[i, 1]];
855            let h = squared_distance_2d(xi, yi, qx, qy).sqrt();
856            b[i] = options.variogram.evaluate(h);
857        }
858        let ext_row_owned: Option<Vec<f64>> = if external_basis_count > 0 {
859            let qed = query_external_drift.ok_or_else(|| {
860                AnalyticsError::invalid_input(
861                    "query_external_drift required when DriftBasis::External is used",
862                )
863            })?;
864            let mut row = Vec::with_capacity(external_basis_count);
865            for k in 0..external_basis_count {
866                row.push(qed[[q, k]]);
867            }
868            Some(row)
869        } else {
870            None
871        };
872        let mut f0 = vec![0.0f64; p];
873        fill_query_drift_row(
874            &options.drift_bases,
875            qx,
876            qy,
877            ext_row_owned.as_deref(),
878            &mut f0,
879        )?;
880        b[n..n + p].copy_from_slice(&f0[..p]);
881
882        // z = M⁻¹ b.
883        let mut z = vec![0.0f64; size];
884        for i in 0..size {
885            let mut acc = 0.0f64;
886            for j in 0..size {
887                acc += m_inv[[i, j]] * b[j];
888            }
889            z[i] = acc;
890        }
891
892        // Prediction = λᵀ values.
893        let mut value = 0.0f64;
894        for i in 0..n {
895            value += z[i] * values[i];
896        }
897        predicted.push(value);
898
899        // Variance = λᵀ γ_0 + μᵀ f_0.
900        let mut var_acc = 0.0f64;
901        for i in 0..n {
902            var_acc += z[i] * b[i];
903        }
904        for j in 0..p {
905            var_acc += z[n + j] * f0[j];
906        }
907        variance.push(var_acc.max(0.0));
908
909        // Drift coefficients = μ (from last query's solve).
910        drift_coefficients[..p].copy_from_slice(&z[n..n + p]);
911    }
912
913    Ok(UniversalKrigingResult {
914        predicted,
915        variance,
916        drift_coefficients,
917    })
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use approx::assert_abs_diff_eq;
924    use scirs2_core::ndarray::array;
925
926    #[test]
927    fn test_variogram_spherical() {
928        let var = Variogram::new(VariogramModel::Spherical, 0.1, 1.0, 10.0);
929
930        assert_abs_diff_eq!(var.evaluate(0.0), 0.0, epsilon = 1e-10);
931        assert_abs_diff_eq!(var.evaluate(10.0), 1.0, epsilon = 1e-10);
932        assert_abs_diff_eq!(var.evaluate(20.0), 1.0, epsilon = 1e-10);
933    }
934
935    #[test]
936    fn test_kriging_simple() {
937        let points = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
938        let values = array![1.0, 2.0, 3.0, 4.0];
939        let targets = array![[0.5, 0.5]];
940
941        let var = Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 2.0);
942        let interpolator = KrigingInterpolator::new(KrigingType::Ordinary, var);
943
944        let result = interpolator
945            .interpolate(&points, &values.view(), &targets)
946            .expect("Kriging interpolation should succeed for valid data");
947
948        assert_eq!(result.values.len(), 1);
949        assert_eq!(result.variances.len(), 1);
950        assert!(result.values[0] > 2.0 && result.values[0] < 3.0);
951    }
952
953    #[test]
954    fn test_semivariogram_calculation() {
955        let points = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
956        let values = array![1.0, 2.0, 3.0];
957
958        let (distances, semivariances) =
959            SemivariogramCalculator::calculate(&points, &values.view(), 3)
960                .expect("Semivariogram calculation should succeed");
961
962        assert!(!distances.is_empty());
963        assert_eq!(distances.len(), semivariances.len());
964    }
965}