sklears_gaussian_process/
spatial.rs

1//! Spatial Gaussian Processes and Kriging Methods
2//!
3//! This module implements Gaussian Process methods for spatial data analysis,
4//! including various kriging methods, spatial correlation modeling, and
5//! geostatistical applications.
6//!
7//! # Mathematical Background
8//!
9//! Spatial GPs extend standard GP regression to handle spatial data with:
10//! 1. **Distance-based kernels**: Kernels that depend on spatial distance
11//! 2. **Anisotropic correlation**: Different correlation in different directions
12//! 3. **Kriging methods**: Simple, ordinary, universal, and co-kriging
13//! 4. **Variogram modeling**: Semi-variogram estimation and fitting
14//! 5. **Spatial interpolation**: Optimal spatial prediction with uncertainty
15//!
16//! # Examples
17//!
18//! ```rust
19//! use sklears_gaussian_process::spatial::{SpatialGaussianProcessRegressor, SpatialKernel, KrigingType};
20//! use sklears_core::traits::{Fit, Predict};
21//! use scirs2_core::ndarray::array;
22//!
23//! // Create spatial GP for ordinary kriging
24//! let spatial_gp = SpatialGaussianProcessRegressor::builder()
25//!     .spatial_kernel(SpatialKernel::spherical(1.0, 10.0))
26//!     .kriging_type(KrigingType::Ordinary)
27//!     .build();
28//!
29//! // Spatial coordinates (x, y)
30//! let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
31//! let values = array![1.0, 2.0, 1.5, 2.5];
32//!
33//! let trained_model = spatial_gp.fit(&coords, &values).unwrap();
34//! let predictions = trained_model.predict(&array![[0.5, 0.5]]).unwrap();
35//! ```
36
37use crate::kernels::Kernel;
38use crate::utils;
39use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1, Axis};
40// SciRS2 Policy
41use sklears_core::error::{Result as SklResult, SklearsError};
42use sklears_core::traits::{Estimator, Fit, Predict};
43use std::f64::consts::PI;
44
45/// State marker for untrained spatial GP
46#[derive(Debug, Clone)]
47pub struct Untrained;
48
49/// State marker for trained spatial GP
50#[derive(Debug, Clone)]
51pub struct Trained {
52    pub spatial_kernel: SpatialKernel,
53    pub kriging_type: KrigingType,
54    pub training_data: (Array2<f64>, Array1<f64>),
55    pub alpha: Array1<f64>,
56    pub cholesky: Array2<f64>,
57    pub log_likelihood: f64,
58    pub variogram: Option<Variogram>,
59    pub anisotropy_matrix: Option<Array2<f64>>,
60}
61
62/// Types of kriging methods
63#[derive(Debug, Clone, Copy)]
64pub enum KrigingType {
65    /// Simple kriging (known mean)
66    Simple { mean: f64 },
67    /// Ordinary kriging (unknown constant mean)
68    Ordinary,
69    /// Universal kriging (trend model)
70    Universal,
71    /// Co-kriging (multiple variables)
72    CoKriging,
73}
74
75/// Spatial kernel types for geostatistical modeling
76#[derive(Debug, Clone)]
77pub enum SpatialKernel {
78    /// Spherical model: commonly used in geostatistics
79    Spherical {
80        sill: f64,   // Maximum variance
81        range: f64,  // Correlation range
82        nugget: f64, // Nugget effect (short-range noise)
83    },
84    /// Exponential model: smooth spatial correlation
85    Exponential { sill: f64, range: f64, nugget: f64 },
86    /// Gaussian model: very smooth spatial correlation
87    Gaussian { sill: f64, range: f64, nugget: f64 },
88    /// Matérn model: flexible smoothness parameter
89    Matern {
90        sill: f64,
91        range: f64,
92        nugget: f64,
93        nu: f64, // Smoothness parameter
94    },
95    /// Power model: unbounded variance
96    Power {
97        scale: f64,
98        exponent: f64, // Should be < 2 for valid covariance
99    },
100    /// Linear model: linear increase with distance
101    Linear { slope: f64, nugget: f64 },
102    /// Hole effect model: oscillatory correlation
103    HoleEffect {
104        sill: f64,
105        range: f64,
106        nugget: f64,
107        damping: f64,
108    },
109    /// Anisotropic kernel with directional correlation
110    Anisotropic {
111        base_kernel: Box<SpatialKernel>,
112        anisotropy_matrix: Array2<f64>,
113    },
114}
115
116impl SpatialKernel {
117    /// Create a spherical spatial kernel
118    pub fn spherical(sill: f64, range: f64) -> Self {
119        Self::Spherical {
120            sill,
121            range,
122            nugget: 0.0,
123        }
124    }
125
126    /// Create a spherical kernel with nugget effect
127    pub fn spherical_with_nugget(sill: f64, range: f64, nugget: f64) -> Self {
128        Self::Spherical {
129            sill,
130            range,
131            nugget,
132        }
133    }
134
135    /// Create an exponential spatial kernel
136    pub fn exponential(sill: f64, range: f64) -> Self {
137        Self::Exponential {
138            sill,
139            range,
140            nugget: 0.0,
141        }
142    }
143
144    /// Create a Gaussian spatial kernel
145    pub fn gaussian(sill: f64, range: f64) -> Self {
146        Self::Gaussian {
147            sill,
148            range,
149            nugget: 0.0,
150        }
151    }
152
153    /// Create a Matérn spatial kernel
154    pub fn matern(sill: f64, range: f64, nu: f64) -> Self {
155        Self::Matern {
156            sill,
157            range,
158            nugget: 0.0,
159            nu,
160        }
161    }
162
163    /// Create an anisotropic kernel
164    pub fn anisotropic(base_kernel: SpatialKernel, anisotropy_matrix: Array2<f64>) -> Self {
165        Self::Anisotropic {
166            base_kernel: Box::new(base_kernel),
167            anisotropy_matrix,
168        }
169    }
170
171    /// Compute spatial distance between two points
172    fn compute_distance(&self, x1: &[f64], x2: &[f64]) -> f64 {
173        match self {
174            Self::Anisotropic {
175                anisotropy_matrix, ..
176            } => {
177                // Transform coordinates and compute Mahalanobis distance
178                let diff: Array1<f64> =
179                    Array1::from_vec(x1.iter().zip(x2.iter()).map(|(a, b)| a - b).collect());
180                let transformed = anisotropy_matrix.dot(&diff);
181                transformed.iter().map(|x| x.powi(2)).sum::<f64>().sqrt()
182            }
183            _ => {
184                // Standard Euclidean distance
185                x1.iter()
186                    .zip(x2.iter())
187                    .map(|(a, b)| (a - b).powi(2))
188                    .sum::<f64>()
189                    .sqrt()
190            }
191        }
192    }
193
194    /// Compute the covariance for a given distance
195    pub fn compute_covariance(&self, distance: f64) -> f64 {
196        match self {
197            Self::Spherical {
198                sill,
199                range,
200                nugget,
201            } => {
202                if distance == 0.0 {
203                    sill + nugget
204                } else if distance <= *range {
205                    let h_r = distance / range;
206                    sill * (1.0 - 1.5 * h_r + 0.5 * h_r.powi(3))
207                        + if distance == 0.0 { *nugget } else { 0.0 }
208                } else {
209                    if distance == 0.0 {
210                        *nugget
211                    } else {
212                        0.0
213                    }
214                }
215            }
216            Self::Exponential {
217                sill,
218                range,
219                nugget,
220            } => {
221                if distance == 0.0 {
222                    sill + nugget
223                } else {
224                    sill * (-3.0 * distance / range).exp()
225                }
226            }
227            Self::Gaussian {
228                sill,
229                range,
230                nugget,
231            } => {
232                if distance == 0.0 {
233                    sill + nugget
234                } else {
235                    sill * (-3.0 * (distance / range).powi(2)).exp()
236                }
237            }
238            Self::Matern {
239                sill,
240                range,
241                nugget,
242                nu,
243            } => {
244                if distance == 0.0 {
245                    return sill + nugget;
246                }
247
248                let h = distance / range;
249                match nu {
250                    0.5 => sill * (-h).exp(),
251                    1.5 => sill * (1.0 + h * 3.0_f64.sqrt()) * (-h * 3.0_f64.sqrt()).exp(),
252                    2.5 => {
253                        sill * (1.0 + h * 5.0_f64.sqrt() + 5.0 * h.powi(2) / 3.0)
254                            * (-h * 5.0_f64.sqrt()).exp()
255                    }
256                    _ => {
257                        // General Matérn (simplified approximation)
258                        sill * (1.0 + h).exp() * (-h).exp()
259                    }
260                }
261            }
262            Self::Power { scale, exponent } => {
263                if distance == 0.0 {
264                    0.0
265                } else {
266                    scale * distance.powf(*exponent)
267                }
268            }
269            Self::Linear { slope, nugget } => {
270                if distance == 0.0 {
271                    *nugget
272                } else {
273                    slope * distance
274                }
275            }
276            Self::HoleEffect {
277                sill,
278                range,
279                nugget,
280                damping,
281            } => {
282                if distance == 0.0 {
283                    sill + nugget
284                } else {
285                    let h_r = distance / range;
286                    sill * (1.0 - h_r) * (-damping * h_r).exp().max(0.0)
287                }
288            }
289            Self::Anisotropic { base_kernel, .. } => {
290                // Distance is already transformed, use base kernel
291                base_kernel.compute_covariance(distance)
292            }
293        }
294    }
295
296    /// Compute the semi-variogram for a given distance
297    pub fn compute_variogram(&self, distance: f64) -> f64 {
298        match self {
299            Self::Spherical {
300                sill,
301                range,
302                nugget,
303            } => {
304                if distance == 0.0 {
305                    0.0
306                } else if distance <= *range {
307                    let h_r = distance / range;
308                    nugget + sill * (1.5 * h_r - 0.5 * h_r.powi(3))
309                } else {
310                    nugget + sill
311                }
312            }
313            Self::Exponential {
314                sill,
315                range,
316                nugget,
317            } => {
318                if distance == 0.0 {
319                    0.0
320                } else {
321                    nugget + sill * (1.0 - (-3.0 * distance / range).exp())
322                }
323            }
324            Self::Gaussian {
325                sill,
326                range,
327                nugget,
328            } => {
329                if distance == 0.0 {
330                    0.0
331                } else {
332                    nugget + sill * (1.0 - (-3.0 * (distance / range).powi(2)).exp())
333                }
334            }
335            _ => {
336                // For other kernels, use the relation: γ(h) = C(0) - C(h)
337                let c0 = self.compute_covariance(0.0);
338                let ch = self.compute_covariance(distance);
339                (c0 - ch).max(0.0)
340            }
341        }
342    }
343
344    /// Get the effective range of the kernel
345    pub fn effective_range(&self) -> f64 {
346        match self {
347            Self::Spherical { range, .. }
348            | Self::Exponential { range, .. }
349            | Self::Gaussian { range, .. }
350            | Self::Matern { range, .. }
351            | Self::HoleEffect { range, .. } => *range,
352            Self::Power { .. } => f64::INFINITY,
353            Self::Linear { .. } => f64::INFINITY,
354            Self::Anisotropic { base_kernel, .. } => base_kernel.effective_range(),
355        }
356    }
357
358    /// Get the sill (maximum variance) of the kernel
359    pub fn sill(&self) -> f64 {
360        match self {
361            Self::Spherical { sill, .. }
362            | Self::Exponential { sill, .. }
363            | Self::Gaussian { sill, .. }
364            | Self::Matern { sill, .. }
365            | Self::HoleEffect { sill, .. } => *sill,
366            Self::Power { scale, .. } => *scale,
367            Self::Linear { slope, .. } => *slope,
368            Self::Anisotropic { base_kernel, .. } => base_kernel.sill(),
369        }
370    }
371
372    /// Get the nugget effect
373    pub fn nugget(&self) -> f64 {
374        match self {
375            Self::Spherical { nugget, .. }
376            | Self::Exponential { nugget, .. }
377            | Self::Gaussian { nugget, .. }
378            | Self::Matern { nugget, .. }
379            | Self::HoleEffect { nugget, .. }
380            | Self::Linear { nugget, .. } => *nugget,
381            Self::Power { .. } => 0.0,
382            Self::Anisotropic { base_kernel, .. } => base_kernel.nugget(),
383        }
384    }
385}
386
387impl Kernel for SpatialKernel {
388    fn compute_kernel_matrix(
389        &self,
390        x1: &Array2<f64>,
391        x2: Option<&Array2<f64>>,
392    ) -> SklResult<Array2<f64>> {
393        let x2 = x2.unwrap_or(x1);
394        let n1 = x1.nrows();
395        let n2 = x2.nrows();
396
397        if x1.ncols() < 2 || x2.ncols() < 2 {
398            return Err(SklearsError::InvalidInput(
399                "Spatial kernels require at least 2D coordinates".to_string(),
400            ));
401        }
402
403        let mut K = Array2::zeros((n1, n2));
404
405        for i in 0..n1 {
406            for j in 0..n2 {
407                let x1_coords = x1.row(i).to_vec();
408                let x2_coords = x2.row(j).to_vec();
409                let distance = self.compute_distance(&x1_coords, &x2_coords);
410                K[[i, j]] = self.compute_covariance(distance);
411            }
412        }
413
414        Ok(K)
415    }
416
417    fn clone_box(&self) -> Box<dyn Kernel> {
418        Box::new(self.clone())
419    }
420
421    fn kernel(&self, x1: &ArrayView1<f64>, x2: &ArrayView1<f64>) -> f64 {
422        if x1.len() < 2 || x2.len() < 2 {
423            return 0.0; // Invalid spatial coordinates
424        }
425        let distance = self.compute_distance(&x1.to_vec(), &x2.to_vec());
426        self.compute_covariance(distance)
427    }
428
429    fn get_params(&self) -> Vec<f64> {
430        match self {
431            Self::Spherical {
432                range,
433                sill,
434                nugget,
435            } => vec![*range, *sill, *nugget],
436            Self::Exponential {
437                range,
438                sill,
439                nugget,
440            } => vec![*range, *sill, *nugget],
441            Self::Gaussian {
442                range,
443                sill,
444                nugget,
445            } => vec![*range, *sill, *nugget],
446            Self::Matern {
447                range,
448                sill,
449                nugget,
450                nu,
451            } => vec![*range, *sill, *nugget, *nu],
452            Self::Linear { slope, nugget } => vec![*slope, *nugget],
453            Self::Power { scale, exponent } => vec![*scale, *exponent],
454            Self::HoleEffect {
455                range,
456                sill,
457                nugget,
458                damping,
459            } => vec![*range, *sill, *nugget, *damping],
460            Self::Anisotropic {
461                base_kernel,
462                anisotropy_matrix,
463            } => {
464                let mut params = base_kernel.get_params();
465                // Add anisotropy matrix elements (flattened)
466                for row in anisotropy_matrix.rows() {
467                    for &val in row {
468                        params.push(val);
469                    }
470                }
471                params
472            }
473        }
474    }
475
476    fn set_params(&mut self, params: &[f64]) -> SklResult<()> {
477        match self {
478            Self::Spherical {
479                range,
480                sill,
481                nugget,
482            } => {
483                if params.len() != 3 {
484                    return Err(SklearsError::InvalidInput(
485                        "Spherical kernel requires 3 parameters".to_string(),
486                    ));
487                }
488                *range = params[0];
489                *sill = params[1];
490                *nugget = params[2];
491            }
492            Self::Exponential {
493                range,
494                sill,
495                nugget,
496            } => {
497                if params.len() != 3 {
498                    return Err(SklearsError::InvalidInput(
499                        "Exponential kernel requires 3 parameters".to_string(),
500                    ));
501                }
502                *range = params[0];
503                *sill = params[1];
504                *nugget = params[2];
505            }
506            Self::Gaussian {
507                range,
508                sill,
509                nugget,
510            } => {
511                if params.len() != 3 {
512                    return Err(SklearsError::InvalidInput(
513                        "Gaussian kernel requires 3 parameters".to_string(),
514                    ));
515                }
516                *range = params[0];
517                *sill = params[1];
518                *nugget = params[2];
519            }
520            Self::Matern {
521                range,
522                sill,
523                nugget,
524                nu,
525            } => {
526                if params.len() != 4 {
527                    return Err(SklearsError::InvalidInput(
528                        "Matern kernel requires 4 parameters".to_string(),
529                    ));
530                }
531                *range = params[0];
532                *sill = params[1];
533                *nugget = params[2];
534                *nu = params[3];
535            }
536            Self::Linear { slope, nugget } => {
537                if params.len() != 2 {
538                    return Err(SklearsError::InvalidInput(
539                        "Linear kernel requires 2 parameters".to_string(),
540                    ));
541                }
542                *slope = params[0];
543                *nugget = params[1];
544            }
545            Self::Power { scale, exponent } => {
546                if params.len() != 2 {
547                    return Err(SklearsError::InvalidInput(
548                        "Power kernel requires 2 parameters".to_string(),
549                    ));
550                }
551                *scale = params[0];
552                *exponent = params[1];
553            }
554            Self::HoleEffect {
555                range,
556                sill,
557                nugget,
558                damping,
559            } => {
560                if params.len() != 4 {
561                    return Err(SklearsError::InvalidInput(
562                        "HoleEffect kernel requires 4 parameters".to_string(),
563                    ));
564                }
565                *range = params[0];
566                *sill = params[1];
567                *nugget = params[2];
568                *damping = params[3];
569            }
570            Self::Anisotropic {
571                base_kernel,
572                anisotropy_matrix,
573            } => {
574                let base_params_len = base_kernel.get_params().len();
575                if params.len() < base_params_len {
576                    return Err(SklearsError::InvalidInput(
577                        "Not enough parameters for anisotropic kernel".to_string(),
578                    ));
579                }
580
581                // Set base kernel parameters
582                base_kernel.set_params(&params[..base_params_len])?;
583
584                // Set anisotropy matrix
585                let matrix_size = anisotropy_matrix.nrows() * anisotropy_matrix.ncols();
586                if params.len() != base_params_len + matrix_size {
587                    return Err(SklearsError::InvalidInput(
588                        "Incorrect number of parameters for anisotropy matrix".to_string(),
589                    ));
590                }
591
592                let matrix_params = &params[base_params_len..];
593                let ncols = anisotropy_matrix.ncols();
594                for (i, mut row) in anisotropy_matrix.rows_mut().into_iter().enumerate() {
595                    for (j, val) in row.iter_mut().enumerate() {
596                        *val = matrix_params[i * ncols + j];
597                    }
598                }
599            }
600        }
601        Ok(())
602    }
603}
604
605/// Empirical variogram computation and modeling
606#[derive(Debug, Clone)]
607pub struct Variogram {
608    pub distances: Array1<f64>,
609    pub semivariances: Array1<f64>,
610    pub n_pairs: Array1<usize>,
611    pub fitted_model: Option<SpatialKernel>,
612}
613
614impl Variogram {
615    /// Compute empirical variogram from spatial data
616    pub fn compute_empirical(
617        coords: &Array2<f64>,
618        values: &Array1<f64>,
619        n_bins: usize,
620    ) -> SklResult<Self> {
621        let n_points = coords.nrows();
622        if coords.nrows() != values.len() {
623            return Err(SklearsError::DimensionMismatch {
624                expected: coords.nrows(),
625                actual: values.len(),
626            });
627        }
628
629        // Compute all pairwise distances and semivariances
630        let mut all_distances = Vec::new();
631        let mut all_semivariances = Vec::new();
632
633        for i in 0..n_points {
634            for j in i + 1..n_points {
635                let dist = coords
636                    .row(i)
637                    .iter()
638                    .zip(coords.row(j).iter())
639                    .map(|(a, b)| (a - b).powi(2))
640                    .sum::<f64>()
641                    .sqrt();
642
643                let semivar = 0.5 * (values[i] - values[j]).powi(2);
644
645                all_distances.push(dist);
646                all_semivariances.push(semivar);
647            }
648        }
649
650        if all_distances.is_empty() {
651            return Err(SklearsError::InvalidInput(
652                "Not enough data points for variogram".to_string(),
653            ));
654        }
655
656        // Create distance bins
657        let max_dist = all_distances.iter().fold(0.0f64, |a, &b| a.max(b));
658        let min_dist = all_distances.iter().fold(f64::INFINITY, |a, &b| a.min(b));
659        let bin_width = (max_dist - min_dist) / n_bins as f64;
660
661        let mut bin_distances: Array1<f64> = Array1::zeros(n_bins);
662        let mut bin_semivariances: Array1<f64> = Array1::zeros(n_bins);
663        let mut bin_counts: Array1<f64> = Array1::zeros(n_bins);
664
665        // Assign pairs to bins
666        for (dist, semivar) in all_distances.iter().zip(all_semivariances.iter()) {
667            let bin_idx = ((*dist - min_dist) / bin_width).floor() as usize;
668            let bin_idx = bin_idx.min(n_bins - 1);
669
670            bin_distances[bin_idx] += dist;
671            bin_semivariances[bin_idx] += semivar;
672            bin_counts[bin_idx] += 1.0;
673        }
674
675        // Compute bin averages
676        let mut distances = Vec::new();
677        let mut semivariances = Vec::new();
678        let mut n_pairs = Vec::new();
679
680        for i in 0..n_bins {
681            if bin_counts[i] > 0.0 {
682                distances.push(bin_distances[i] / bin_counts[i] as f64);
683                semivariances.push(bin_semivariances[i] / bin_counts[i] as f64);
684                n_pairs.push(bin_counts[i] as usize);
685            }
686        }
687
688        Ok(Self {
689            distances: Array1::from_vec(distances),
690            semivariances: Array1::from_vec(semivariances),
691            n_pairs: Array1::from_vec(n_pairs),
692            fitted_model: None,
693        })
694    }
695
696    /// Fit a spatial kernel model to the empirical variogram
697    pub fn fit_model(&mut self, model_type: &str) -> SklResult<()> {
698        if self.distances.is_empty() {
699            return Err(SklearsError::InvalidInput(
700                "No variogram data to fit".to_string(),
701            ));
702        }
703
704        // Simple parameter estimation (could be improved with non-linear optimization)
705        let max_semivar = self.semivariances.iter().fold(0.0f64, |a, &b| a.max(b));
706        let max_dist = self.distances.iter().fold(0.0f64, |a, &b| a.max(b));
707
708        // Estimate nugget as the y-intercept
709        let nugget = if self.distances[0] > 0.0 {
710            0.0
711        } else {
712            self.semivariances[0]
713        };
714
715        // Estimate sill as maximum semivariance
716        let sill = max_semivar - nugget;
717
718        // Estimate range as distance where semivariance reaches ~95% of sill
719        let target_semivar = nugget + 0.95 * sill;
720        let mut range = max_dist;
721        for i in 0..self.distances.len() {
722            if self.semivariances[i] >= target_semivar {
723                range = self.distances[i];
724                break;
725            }
726        }
727
728        let fitted_model = match model_type {
729            "spherical" => SpatialKernel::Spherical {
730                sill,
731                range,
732                nugget,
733            },
734            "exponential" => SpatialKernel::Exponential {
735                sill,
736                range,
737                nugget,
738            },
739            "gaussian" => SpatialKernel::Gaussian {
740                sill,
741                range,
742                nugget,
743            },
744            "matern15" => SpatialKernel::Matern {
745                sill,
746                range,
747                nugget,
748                nu: 1.5,
749            },
750            _ => {
751                return Err(SklearsError::InvalidInput(format!(
752                    "Unknown model type: {}",
753                    model_type
754                )))
755            }
756        };
757
758        self.fitted_model = Some(fitted_model);
759        Ok(())
760    }
761
762    /// Compute goodness of fit for the fitted model
763    pub fn goodness_of_fit(&self) -> SklResult<f64> {
764        let model = self
765            .fitted_model
766            .as_ref()
767            .ok_or_else(|| SklearsError::InvalidInput("No model fitted".to_string()))?;
768
769        let mut ss_res = 0.0;
770        let mut ss_tot = 0.0;
771        let mean_semivar = self.semivariances.mean().unwrap_or(0.0);
772
773        for i in 0..self.distances.len() {
774            let predicted = model.compute_variogram(self.distances[i]);
775            let observed = self.semivariances[i];
776
777            ss_res += (observed - predicted).powi(2);
778            ss_tot += (observed - mean_semivar).powi(2);
779        }
780
781        let r_squared = 1.0 - (ss_res / ss_tot.max(1e-12));
782        Ok(r_squared)
783    }
784}
785
786/// Spatial Gaussian Process Regressor
787#[derive(Debug, Clone)]
788pub struct SpatialGaussianProcessRegressor<S = Untrained> {
789    spatial_kernel: Option<SpatialKernel>,
790    kriging_type: KrigingType,
791    estimate_variogram: bool,
792    variogram_bins: usize,
793    anisotropy_matrix: Option<Array2<f64>>,
794    alpha: f64,
795    _state: S,
796}
797
798/// Configuration for spatial GP
799#[derive(Debug, Clone)]
800pub struct SpatialGPConfig {
801    pub kriging_type: KrigingType,
802    pub estimate_variogram: bool,
803    pub variogram_bins: usize,
804    pub regularization: f64,
805}
806
807impl Default for SpatialGPConfig {
808    fn default() -> Self {
809        Self {
810            kriging_type: KrigingType::Ordinary,
811            estimate_variogram: false,
812            variogram_bins: 20,
813            regularization: 1e-6,
814        }
815    }
816}
817
818impl SpatialGaussianProcessRegressor<Untrained> {
819    /// Create a new spatial GP regressor
820    pub fn new() -> Self {
821        Self {
822            spatial_kernel: None,
823            kriging_type: KrigingType::Ordinary,
824            estimate_variogram: false,
825            variogram_bins: 20,
826            anisotropy_matrix: None,
827            alpha: 1e-6,
828            _state: Untrained,
829        }
830    }
831
832    /// Create a builder for spatial GP
833    pub fn builder() -> SpatialGPBuilder {
834        SpatialGPBuilder::new()
835    }
836
837    /// Set the spatial kernel
838    pub fn spatial_kernel(mut self, kernel: SpatialKernel) -> Self {
839        self.spatial_kernel = Some(kernel);
840        self
841    }
842
843    /// Set the kriging type
844    pub fn kriging_type(mut self, kriging_type: KrigingType) -> Self {
845        self.kriging_type = kriging_type;
846        self
847    }
848
849    /// Enable automatic variogram estimation
850    pub fn estimate_variogram(mut self, estimate: bool) -> Self {
851        self.estimate_variogram = estimate;
852        self
853    }
854
855    /// Set anisotropy matrix for directional correlation
856    pub fn anisotropy_matrix(mut self, matrix: Array2<f64>) -> Self {
857        self.anisotropy_matrix = Some(matrix);
858        self
859    }
860
861    /// Set regularization parameter
862    pub fn alpha(mut self, alpha: f64) -> Self {
863        self.alpha = alpha;
864        self
865    }
866}
867
868/// Builder for spatial GP regressor
869#[derive(Debug, Clone)]
870pub struct SpatialGPBuilder {
871    kernel: Option<SpatialKernel>,
872    kriging_type: KrigingType,
873    estimate_variogram: bool,
874    variogram_bins: usize,
875    anisotropy_matrix: Option<Array2<f64>>,
876    alpha: f64,
877}
878
879impl SpatialGPBuilder {
880    pub fn new() -> Self {
881        Self {
882            kernel: None,
883            kriging_type: KrigingType::Ordinary,
884            estimate_variogram: false,
885            variogram_bins: 20,
886            anisotropy_matrix: None,
887            alpha: 1e-6,
888        }
889    }
890
891    pub fn spatial_kernel(mut self, kernel: SpatialKernel) -> Self {
892        self.kernel = Some(kernel);
893        self
894    }
895
896    pub fn kriging_type(mut self, kriging_type: KrigingType) -> Self {
897        self.kriging_type = kriging_type;
898        self
899    }
900
901    pub fn estimate_variogram(mut self, estimate: bool) -> Self {
902        self.estimate_variogram = estimate;
903        self
904    }
905
906    pub fn variogram_bins(mut self, bins: usize) -> Self {
907        self.variogram_bins = bins;
908        self
909    }
910
911    pub fn anisotropy_matrix(mut self, matrix: Array2<f64>) -> Self {
912        self.anisotropy_matrix = Some(matrix);
913        self
914    }
915
916    pub fn alpha(mut self, alpha: f64) -> Self {
917        self.alpha = alpha;
918        self
919    }
920
921    pub fn build(self) -> SpatialGaussianProcessRegressor<Untrained> {
922        SpatialGaussianProcessRegressor {
923            spatial_kernel: self.kernel,
924            kriging_type: self.kriging_type,
925            estimate_variogram: self.estimate_variogram,
926            variogram_bins: self.variogram_bins,
927            anisotropy_matrix: self.anisotropy_matrix.clone(),
928            alpha: self.alpha,
929            _state: Untrained,
930        }
931    }
932}
933
934impl Estimator for SpatialGaussianProcessRegressor<Untrained> {
935    type Config = SpatialGPConfig;
936    type Error = SklearsError;
937    type Float = f64;
938
939    fn config(&self) -> &Self::Config {
940        static DEFAULT_CONFIG: SpatialGPConfig = SpatialGPConfig {
941            kriging_type: KrigingType::Ordinary,
942            estimate_variogram: false,
943            variogram_bins: 20,
944            regularization: 1e-6,
945        };
946        &DEFAULT_CONFIG
947    }
948}
949
950impl Estimator for SpatialGaussianProcessRegressor<Trained> {
951    type Config = SpatialGPConfig;
952    type Error = SklearsError;
953    type Float = f64;
954
955    fn config(&self) -> &Self::Config {
956        static DEFAULT_CONFIG: SpatialGPConfig = SpatialGPConfig {
957            kriging_type: KrigingType::Ordinary,
958            estimate_variogram: false,
959            variogram_bins: 20,
960            regularization: 1e-6,
961        };
962        &DEFAULT_CONFIG
963    }
964}
965
966impl Fit<Array2<f64>, Array1<f64>> for SpatialGaussianProcessRegressor<Untrained> {
967    type Fitted = SpatialGaussianProcessRegressor<Trained>;
968
969    fn fit(self, X: &Array2<f64>, y: &Array1<f64>) -> SklResult<Self::Fitted> {
970        if X.nrows() != y.len() {
971            return Err(SklearsError::DimensionMismatch {
972                expected: X.nrows(),
973                actual: y.len(),
974            });
975        }
976
977        if X.ncols() < 2 {
978            return Err(SklearsError::InvalidInput(
979                "Spatial GP requires at least 2D coordinates".to_string(),
980            ));
981        }
982
983        let X_owned = X.to_owned();
984        let y_owned = y.to_owned();
985
986        // Estimate variogram if requested
987        let variogram = if self.estimate_variogram {
988            Some(Variogram::compute_empirical(
989                &X_owned,
990                &y_owned,
991                self.variogram_bins,
992            )?)
993        } else {
994            None
995        };
996
997        // Get spatial kernel
998        let mut spatial_kernel = self.spatial_kernel.clone().ok_or_else(|| {
999            SklearsError::InvalidInput("Spatial kernel must be specified".to_string())
1000        })?;
1001
1002        // Apply anisotropy if specified
1003        if let Some(anisotropy_matrix) = &self.anisotropy_matrix {
1004            spatial_kernel = SpatialKernel::anisotropic(spatial_kernel, anisotropy_matrix.clone());
1005        }
1006
1007        // Compute kernel matrix
1008        let K = spatial_kernel.compute_kernel_matrix(&X_owned, None)?;
1009
1010        // Handle different kriging types
1011        let (y_for_gp, augmented_matrix) = match self.kriging_type {
1012            KrigingType::Simple { mean } => {
1013                // Subtract known mean
1014                let y_centered = &y_owned - mean;
1015                (y_centered, K.clone())
1016            }
1017            KrigingType::Ordinary => {
1018                // Add constraint for constant mean
1019                let n = K.nrows();
1020                let mut augmented = Array2::zeros((n + 1, n + 1));
1021
1022                // Copy kernel matrix
1023                for i in 0..n {
1024                    for j in 0..n {
1025                        augmented[[i, j]] = K[[i, j]];
1026                    }
1027                }
1028
1029                // Add constraint equations
1030                for i in 0..n {
1031                    augmented[[i, n]] = 1.0;
1032                    augmented[[n, i]] = 1.0;
1033                }
1034
1035                (y_owned.clone(), augmented)
1036            }
1037            KrigingType::Universal => {
1038                // For universal kriging, we would add trend terms
1039                // Simplified implementation: treat as ordinary kriging for now
1040                let n = K.nrows();
1041                let mut augmented = Array2::zeros((n + 1, n + 1));
1042
1043                for i in 0..n {
1044                    for j in 0..n {
1045                        augmented[[i, j]] = K[[i, j]];
1046                    }
1047                }
1048
1049                for i in 0..n {
1050                    augmented[[i, n]] = 1.0;
1051                    augmented[[n, i]] = 1.0;
1052                }
1053
1054                (y_owned.clone(), augmented)
1055            }
1056            KrigingType::CoKriging => {
1057                // Co-kriging would handle multiple variables
1058                // For now, treat as ordinary kriging
1059                let n = K.nrows();
1060                let mut augmented = Array2::zeros((n + 1, n + 1));
1061
1062                for i in 0..n {
1063                    for j in 0..n {
1064                        augmented[[i, j]] = K[[i, j]];
1065                    }
1066                }
1067
1068                for i in 0..n {
1069                    augmented[[i, n]] = 1.0;
1070                    augmented[[n, i]] = 1.0;
1071                }
1072
1073                (y_owned.clone(), augmented)
1074            }
1075        };
1076
1077        // Add regularization to diagonal
1078        let mut K_reg = augmented_matrix.clone();
1079        let matrix_size = K_reg.nrows();
1080
1081        // Regularize the kernel part
1082        for i in 0..K.nrows() {
1083            K_reg[[i, i]] += self.alpha;
1084        }
1085
1086        // For ordinary/universal kriging, add regularization to constraint diagonal
1087        // Note: Saddle-point systems are indefinite, but we can make them positive definite
1088        // by adding a small positive regularization to the constraint diagonal
1089        match self.kriging_type {
1090            KrigingType::Simple { .. } => {}
1091            _ => {
1092                // Add regularization to make the system positive definite
1093                // Use a much larger value to ensure numerical stability
1094                // This is necessary because saddle-point systems are indefinite
1095                if matrix_size > K.nrows() {
1096                    K_reg[[K.nrows(), K.nrows()]] = 0.01;
1097                }
1098            }
1099        }
1100
1101        // Prepare right-hand side for different kriging types
1102        let rhs = match self.kriging_type {
1103            KrigingType::Simple { .. } => y_for_gp,
1104            _ => {
1105                let mut extended_y = Array1::zeros(y_for_gp.len() + 1);
1106                for i in 0..y_for_gp.len() {
1107                    extended_y[i] = y_for_gp[i];
1108                }
1109                // Last element (Lagrange multiplier) is 0
1110                extended_y
1111            }
1112        };
1113
1114        // Solve linear system
1115        let chol_decomp = utils::robust_cholesky(&K_reg)?;
1116        let alpha = utils::triangular_solve(&chol_decomp, &rhs)?;
1117
1118        // Compute log marginal likelihood
1119        let log_det = chol_decomp.diag().iter().map(|x| x.ln()).sum::<f64>() * 2.0;
1120        let data_fit = rhs.dot(&alpha);
1121        let n = rhs.len();
1122        let log_likelihood = -0.5 * (data_fit + log_det + n as f64 * (2.0 * PI).ln());
1123
1124        Ok(SpatialGaussianProcessRegressor {
1125            spatial_kernel: self.spatial_kernel,
1126            kriging_type: self.kriging_type,
1127            estimate_variogram: self.estimate_variogram,
1128            variogram_bins: self.variogram_bins,
1129            anisotropy_matrix: self.anisotropy_matrix.clone(),
1130            alpha: self.alpha,
1131            _state: Trained {
1132                spatial_kernel,
1133                kriging_type: self.kriging_type,
1134                training_data: (X_owned, y_owned),
1135                alpha,
1136                cholesky: chol_decomp,
1137                log_likelihood,
1138                variogram,
1139                anisotropy_matrix: self.anisotropy_matrix.clone(),
1140            },
1141        })
1142    }
1143}
1144
1145impl SpatialGaussianProcessRegressor<Trained> {
1146    /// Access the trained state
1147    pub fn trained_state(&self) -> &Trained {
1148        &self._state
1149    }
1150
1151    /// Get the empirical variogram if computed
1152    pub fn variogram(&self) -> Option<&Variogram> {
1153        self._state.variogram.as_ref()
1154    }
1155
1156    /// Predict with uncertainty (kriging variance)
1157    pub fn predict_with_variance(&self, X: &Array2<f64>) -> SklResult<(Array1<f64>, Array1<f64>)> {
1158        let n_test = X.nrows();
1159        let n_train = self._state.training_data.0.nrows();
1160
1161        // Compute cross-covariance matrix
1162        let K_star = self
1163            ._state
1164            .spatial_kernel
1165            .compute_kernel_matrix(&self._state.training_data.0, Some(X))?;
1166
1167        // Handle different kriging types for prediction
1168        let (predictions, variances) = match self._state.kriging_type {
1169            KrigingType::Simple { mean } => {
1170                // Simple kriging predictions
1171                let pred = K_star.t().dot(&self._state.alpha.slice(s![0..n_train])) + mean;
1172
1173                // Compute prediction variance
1174                let K_star_star = self._state.spatial_kernel.compute_kernel_matrix(X, None)?;
1175                let cholesky_slice = self
1176                    ._state
1177                    .cholesky
1178                    .slice(s![0..n_train, 0..n_train])
1179                    .to_owned();
1180                let v = utils::triangular_solve_matrix(&cholesky_slice, &K_star)?;
1181                let pred_var =
1182                    K_star_star.diag().to_owned() - &v.map(|x| x.powi(2)).sum_axis(Axis(0));
1183
1184                (pred, pred_var)
1185            }
1186            _ => {
1187                // Ordinary/Universal kriging
1188                let mut extended_k_star = Array2::zeros((n_train + 1, n_test));
1189
1190                // Copy cross-covariances
1191                for i in 0..n_train {
1192                    for j in 0..n_test {
1193                        extended_k_star[[i, j]] = K_star[[i, j]];
1194                    }
1195                }
1196
1197                // Add constraint (unitary condition)
1198                for j in 0..n_test {
1199                    extended_k_star[[n_train, j]] = 1.0;
1200                }
1201
1202                let pred = extended_k_star.t().dot(&self._state.alpha);
1203
1204                // Compute prediction variance (simplified)
1205                let K_star_star = self._state.spatial_kernel.compute_kernel_matrix(X, None)?;
1206                let cholesky_slice = self
1207                    ._state
1208                    .cholesky
1209                    .slice(s![0..n_train, 0..n_train])
1210                    .to_owned();
1211                let v = utils::triangular_solve_matrix(&cholesky_slice, &K_star)?;
1212                let pred_var =
1213                    K_star_star.diag().to_owned() - &v.map(|x| x.powi(2)).sum_axis(Axis(0));
1214
1215                (pred, pred_var.map(|x| x.max(0.0)))
1216            }
1217        };
1218
1219        Ok((predictions, variances.map(|x| x.sqrt())))
1220    }
1221
1222    /// Compute spatial correlation structure
1223    pub fn correlation_structure(
1224        &self,
1225        max_distance: f64,
1226        n_points: usize,
1227    ) -> (Array1<f64>, Array1<f64>) {
1228        let distances = Array1::linspace(0.0, max_distance, n_points);
1229        let correlations = distances.map(|&d| self._state.spatial_kernel.compute_covariance(d));
1230        (distances, correlations)
1231    }
1232
1233    /// Detect spatial outliers using kriging residuals
1234    pub fn detect_spatial_outliers(&self, threshold: f64) -> SklResult<Vec<usize>> {
1235        let (predictions, _) = self.predict_with_variance(&self._state.training_data.0)?;
1236        let residuals = &self._state.training_data.1 - &predictions;
1237
1238        // Compute standardized residuals
1239        let residual_std = residuals.std(0.0);
1240        let standardized_residuals = residuals / residual_std;
1241
1242        let mut outliers = Vec::new();
1243        for (i, &residual) in standardized_residuals.iter().enumerate() {
1244            if residual.abs() > threshold {
1245                outliers.push(i);
1246            }
1247        }
1248
1249        Ok(outliers)
1250    }
1251
1252    /// Cross-validation for spatial data (leave-one-out)
1253    pub fn spatial_cross_validation(&self) -> SklResult<f64> {
1254        let n = self._state.training_data.0.nrows();
1255        let mut errors = Vec::new();
1256
1257        for i in 0..n {
1258            // Create training data excluding point i
1259            let mut train_coords = Vec::new();
1260            let mut train_values = Vec::new();
1261
1262            for j in 0..n {
1263                if j != i {
1264                    train_coords.push(self._state.training_data.0.row(j).to_vec());
1265                    train_values.push(self._state.training_data.1[j]);
1266                }
1267            }
1268
1269            // Convert back to arrays
1270            let train_coords_array = Array2::from_shape_vec(
1271                (n - 1, self._state.training_data.0.ncols()),
1272                train_coords.into_iter().flatten().collect(),
1273            )
1274            .map_err(|_| {
1275                SklearsError::InvalidInput("Failed to create training coordinates".to_string())
1276            })?;
1277
1278            let train_values_array = Array1::from_vec(train_values);
1279
1280            // Fit model on reduced data
1281            let reduced_gp = SpatialGaussianProcessRegressor::builder()
1282                .spatial_kernel(self._state.spatial_kernel.clone())
1283                .kriging_type(self._state.kriging_type)
1284                .alpha(self.alpha)
1285                .build();
1286
1287            if let Ok(fitted) = reduced_gp.fit(&train_coords_array, &train_values_array) {
1288                // Predict for the left-out point
1289                let test_coords = self._state.training_data.0.slice(s![i..i + 1, ..]);
1290                if let Ok(pred) = fitted.predict(&test_coords.to_owned()) {
1291                    let error = (pred[0] - self._state.training_data.1[i]).powi(2);
1292                    errors.push(error);
1293                }
1294            }
1295        }
1296
1297        if errors.is_empty() {
1298            return Err(SklearsError::InvalidInput(
1299                "Cross-validation failed".to_string(),
1300            ));
1301        }
1302
1303        let mse = errors.iter().sum::<f64>() / errors.len() as f64;
1304        Ok(mse.sqrt()) // Return RMSE
1305    }
1306}
1307
1308impl Predict<Array2<f64>, Array1<f64>> for SpatialGaussianProcessRegressor<Trained> {
1309    fn predict(&self, X: &Array2<f64>) -> SklResult<Array1<f64>> {
1310        let (predictions, _) = self.predict_with_variance(X)?;
1311        Ok(predictions)
1312    }
1313}
1314
1315#[allow(non_snake_case)]
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319    use approx::assert_abs_diff_eq;
1320    use scirs2_core::ndarray::array;
1321
1322    #[test]
1323    fn test_spatial_kernel_spherical() {
1324        let kernel = SpatialKernel::spherical(1.0, 10.0);
1325
1326        // Test at distance 0
1327        assert_abs_diff_eq!(kernel.compute_covariance(0.0), 1.0, epsilon = 1e-10);
1328
1329        // Test within range
1330        let cov_5 = kernel.compute_covariance(5.0);
1331        assert!(cov_5 > 0.0 && cov_5 < 1.0);
1332
1333        // Test beyond range
1334        let cov_15 = kernel.compute_covariance(15.0);
1335        assert_abs_diff_eq!(cov_15, 0.0, epsilon = 1e-10);
1336    }
1337
1338    #[test]
1339    fn test_spatial_kernel_exponential() {
1340        let kernel = SpatialKernel::exponential(1.0, 5.0);
1341
1342        // Test exponential decay
1343        let cov_0 = kernel.compute_covariance(0.0);
1344        let cov_5 = kernel.compute_covariance(5.0);
1345        let cov_10 = kernel.compute_covariance(10.0);
1346
1347        assert_abs_diff_eq!(cov_0, 1.0, epsilon = 1e-10);
1348        assert!(cov_5 > cov_10);
1349        assert!(cov_10 > 0.0);
1350    }
1351
1352    #[test]
1353    fn test_variogram_computation() {
1354        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
1355        let values = array![1.0, 2.0, 1.5, 2.5];
1356
1357        let variogram = Variogram::compute_empirical(&coords, &values, 5).unwrap();
1358
1359        assert!(variogram.distances.len() > 0);
1360        assert_eq!(variogram.distances.len(), variogram.semivariances.len());
1361        assert_eq!(variogram.distances.len(), variogram.n_pairs.len());
1362    }
1363
1364    #[test]
1365    #[ignore] // TODO: Fix Cholesky decomposition numerical stability for ordinary kriging
1366    fn test_spatial_gp_ordinary_kriging() {
1367        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
1368        let values = array![1.0, 2.0, 1.5, 2.5];
1369
1370        let spatial_gp = SpatialGaussianProcessRegressor::builder()
1371            .spatial_kernel(SpatialKernel::spherical(1.0, 2.0))
1372            .kriging_type(KrigingType::Ordinary)
1373            .build();
1374
1375        let trained = spatial_gp.fit(&coords, &values).unwrap();
1376        let predictions = trained.predict(&coords).unwrap();
1377
1378        assert_eq!(predictions.len(), coords.nrows());
1379    }
1380
1381    #[test]
1382    fn test_spatial_gp_simple_kriging() {
1383        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
1384        let values = array![1.0, 2.0, 1.5, 2.5];
1385
1386        let spatial_gp = SpatialGaussianProcessRegressor::builder()
1387            .spatial_kernel(SpatialKernel::exponential(1.0, 2.0))
1388            .kriging_type(KrigingType::Simple { mean: 1.75 })
1389            .build();
1390
1391        let trained = spatial_gp.fit(&coords, &values).unwrap();
1392        let predictions = trained.predict(&coords).unwrap();
1393
1394        assert_eq!(predictions.len(), coords.nrows());
1395    }
1396
1397    #[test]
1398    #[ignore] // TODO: Fix Cholesky decomposition numerical stability
1399    fn test_spatial_gp_with_variance() {
1400        let coords = array![[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0]];
1401        let values = array![1.0, 3.0, 2.0, 4.0];
1402
1403        let spatial_gp = SpatialGaussianProcessRegressor::builder()
1404            .spatial_kernel(SpatialKernel::gaussian(2.0, 1.0))
1405            .kriging_type(KrigingType::Ordinary)
1406            .build();
1407
1408        let trained = spatial_gp.fit(&coords, &values).unwrap();
1409
1410        let test_coords = array![[1.0, 1.0]]; // Center point
1411        let (predictions, variances) = trained.predict_with_variance(&test_coords).unwrap();
1412
1413        assert_eq!(predictions.len(), 1);
1414        assert_eq!(variances.len(), 1);
1415        assert!(variances[0] >= 0.0);
1416    }
1417
1418    #[test]
1419    fn test_spatial_kernel_with_nugget() {
1420        let kernel = SpatialKernel::spherical_with_nugget(1.0, 5.0, 0.1);
1421
1422        // At distance 0, should include nugget
1423        assert_abs_diff_eq!(kernel.compute_covariance(0.0), 1.1, epsilon = 1e-10);
1424
1425        // At non-zero distance, no nugget contribution
1426        let cov_1 = kernel.compute_covariance(1.0);
1427        assert!(cov_1 > 0.0 && cov_1 < 1.0);
1428    }
1429
1430    #[test]
1431    fn test_anisotropic_kernel() {
1432        let base_kernel = SpatialKernel::exponential(1.0, 2.0);
1433        let anisotropy_matrix = array![[2.0, 0.0], [0.0, 1.0]]; // Stretch in x-direction
1434
1435        let aniso_kernel = SpatialKernel::anisotropic(base_kernel, anisotropy_matrix);
1436
1437        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1438        let K = aniso_kernel.compute_kernel_matrix(&coords, None).unwrap();
1439
1440        assert_eq!(K.shape(), &[3, 3]);
1441        assert_abs_diff_eq!(
1442            K[[0, 0]],
1443            aniso_kernel.compute_covariance(0.0),
1444            epsilon = 1e-10
1445        );
1446    }
1447
1448    #[test]
1449    fn test_variogram_model_fitting() {
1450        let coords = array![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [4.0, 0.0]];
1451        let values = array![1.0, 1.2, 1.8, 2.1, 2.5];
1452
1453        let mut variogram = Variogram::compute_empirical(&coords, &values, 4).unwrap();
1454        variogram.fit_model("spherical").unwrap();
1455
1456        assert!(variogram.fitted_model.is_some());
1457
1458        let goodness = variogram.goodness_of_fit().unwrap();
1459        assert!(goodness >= 0.0 && goodness <= 1.0);
1460    }
1461
1462    #[test]
1463    #[ignore] // TODO: Fix Cholesky decomposition numerical stability
1464    fn test_spatial_outlier_detection() {
1465        let coords = array![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]];
1466        let values = array![1.0, 2.0, 3.0, 10.0]; // Last value is outlier
1467
1468        let spatial_gp = SpatialGaussianProcessRegressor::builder()
1469            .spatial_kernel(SpatialKernel::exponential(1.0, 2.0))
1470            .kriging_type(KrigingType::Ordinary)
1471            .build();
1472
1473        let trained = spatial_gp.fit(&coords, &values).unwrap();
1474        let outliers = trained.detect_spatial_outliers(2.0).unwrap();
1475
1476        // Should detect the outlier (index 3)
1477        assert!(!outliers.is_empty());
1478    }
1479
1480    #[test]
1481    #[ignore] // TODO: Fix Cholesky decomposition numerical stability
1482    fn test_spatial_cross_validation() {
1483        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, 0.5]];
1484        let values = array![1.0, 2.0, 1.5, 2.5, 1.8];
1485
1486        let spatial_gp = SpatialGaussianProcessRegressor::builder()
1487            .spatial_kernel(SpatialKernel::spherical(1.0, 2.0))
1488            .kriging_type(KrigingType::Ordinary)
1489            .build();
1490
1491        let trained = spatial_gp.fit(&coords, &values).unwrap();
1492        let cv_error = trained.spatial_cross_validation().unwrap();
1493
1494        assert!(cv_error >= 0.0);
1495    }
1496
1497    #[test]
1498    fn test_matern_kernel() {
1499        let kernel = SpatialKernel::matern(1.0, 2.0, 1.5);
1500
1501        // Test at distance 0
1502        assert_abs_diff_eq!(kernel.compute_covariance(0.0), 1.0, epsilon = 1e-10);
1503
1504        // Test decay properties
1505        let cov_1 = kernel.compute_covariance(1.0);
1506        let cov_2 = kernel.compute_covariance(2.0);
1507        assert!(cov_1 > cov_2);
1508        assert!(cov_2 > 0.0);
1509    }
1510
1511    #[test]
1512    #[ignore] // TODO: Fix Cholesky decomposition numerical stability
1513    fn test_correlation_structure() {
1514        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1515        let values = array![1.0, 2.0, 1.5];
1516
1517        let spatial_gp = SpatialGaussianProcessRegressor::builder()
1518            .spatial_kernel(SpatialKernel::exponential(1.0, 2.0))
1519            .kriging_type(KrigingType::Ordinary)
1520            .build();
1521
1522        let trained = spatial_gp.fit(&coords, &values).unwrap();
1523        let (distances, correlations) = trained.correlation_structure(5.0, 10);
1524
1525        assert_eq!(distances.len(), 10);
1526        assert_eq!(correlations.len(), 10);
1527        assert_abs_diff_eq!(correlations[0], 1.0, epsilon = 1e-10); // At distance 0
1528    }
1529}