use crate::foundation::{AlgoError, Lattice, Result};
use crate::geostat::neighbourhood::Neighbourhood;
use crate::gridding::kriging::prep::{dedup_coincident, dist2d};
use crate::gridding::kriging::solve::{lu_factor_in_place, lu_solve_into};
use crate::gridding::kriging::{SpatialVariogram, Variogram};
use ndarray::Array2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LocalKriging {
variogram: Variogram,
max_neighbours: usize,
radius: f64,
}
impl LocalKriging {
pub fn new(variogram: Variogram, max_neighbours: usize, radius: f64) -> Result<LocalKriging> {
if max_neighbours == 0 || !radius.is_finite() || radius <= 0.0 {
return Err(AlgoError::InvalidArgument(
"LocalKriging: need max_neighbours >= 1 and radius > 0".to_string(),
));
}
Ok(LocalKriging {
variogram,
max_neighbours,
radius,
})
}
pub fn variogram(&self) -> &Variogram {
&self.variogram
}
pub fn krige(
&self,
coords: &[[f64; 3]],
lattice: &Lattice,
) -> Result<(Array2<f64>, Array2<f64>)> {
if coords.is_empty() {
return Err(AlgoError::EmptyInput(
"LocalKriging::krige: no points to grid",
));
}
let data = dedup_coincident(coords);
let positions: Vec<[f64; 2]> = data.iter().map(|d| [d[0], d[1]]).collect();
let nb = Neighbourhood::from_points(&positions);
let mut est = Array2::from_elem((lattice.ncol, lattice.nrow), f64::NAN);
let mut var = Array2::from_elem((lattice.ncol, lattice.nrow), f64::NAN);
let mut scratch = OkScratch::default();
for jj in 0..lattice.nrow {
for ii in 0..lattice.ncol {
let (x, y) = lattice.node_xy(ii, jj);
let near = nb.nearest([x, y], self.max_neighbours, self.radius);
if near.is_empty() {
continue;
}
if let Some((z, s2)) = self.local_ok(&data, &near, &mut scratch) {
est[[ii, jj]] = z;
var[[ii, jj]] = s2.max(0.0);
}
}
}
Ok((est, var))
}
fn local_ok(
&self,
data: &[[f64; 3]],
near: &[(usize, f64)],
scratch: &mut OkScratch,
) -> Option<(f64, f64)> {
let n = near.len();
let m = n + 1; scratch.mat.clear();
scratch.mat.resize(m * m, 0.0);
for (row, &(idi, _)) in near.iter().enumerate() {
let pi = [data[idi][0], data[idi][1]];
for (col, &(idj, _)) in near.iter().enumerate() {
let pj = [data[idj][0], data[idj][1]];
scratch.mat[row * m + col] = self.variogram.gamma(dist2d(pi, pj));
}
scratch.mat[row * m + n] = 1.0;
scratch.mat[n * m + row] = 1.0;
}
scratch.mat[n * m + n] = 0.0;
if !lu_factor_in_place(&mut scratch.mat, m, &mut scratch.perm) {
return None; }
scratch.rhs.clear();
scratch.rhs.resize(m, 0.0);
for (row, &(_, di)) in near.iter().enumerate() {
scratch.rhs[row] = self.variogram.gamma(di); }
scratch.rhs[n] = 1.0;
lu_solve_into(
&scratch.mat,
&scratch.perm,
m,
&scratch.rhs,
&mut scratch.sol,
);
let mut z = 0.0;
let mut sigma2 = scratch.sol[n]; for (row, &(idi, _)) in near.iter().enumerate() {
z += scratch.sol[row] * data[idi][2];
sigma2 += scratch.sol[row] * scratch.rhs[row];
}
Some((z, sigma2))
}
}
#[derive(Default)]
struct OkScratch {
mat: Vec<f64>,
rhs: Vec<f64>,
perm: Vec<usize>,
sol: Vec<f64>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SkNeighbour {
pub pos: [f64; 2],
pub value: f64,
pub offset_to_target: [f64; 2],
}
#[cfg(test)]
pub(crate) fn simple_kriging(
neighbours: &[SkNeighbour],
variogram: &Variogram,
collocated: Option<(f64, f64)>,
) -> (f64, f64) {
let mut scratch = SkScratch::default();
let spatial = SpatialVariogram::from(variogram);
simple_kriging_with(neighbours, &spatial, collocated, &mut scratch)
}
#[derive(Default)]
pub(crate) struct SkScratch {
mat: Vec<f64>,
rhs: Vec<f64>,
perm: Vec<usize>,
sol: Vec<f64>,
}
#[allow(clippy::needless_range_loop)]
pub(crate) fn simple_kriging_with(
neighbours: &[SkNeighbour],
variogram: &SpatialVariogram,
collocated: Option<(f64, f64)>,
scratch: &mut SkScratch,
) -> (f64, f64) {
let s = variogram.total_sill();
let n = neighbours.len();
let has_sec = collocated.is_some();
let rho = collocated.map(|(_, r)| r).unwrap_or(0.0);
let sec = collocated.map(|(v, _)| v).unwrap_or(0.0);
let dim = n + usize::from(has_sec);
if dim == 0 {
return (0.0, 1.0);
}
scratch.mat.clear();
scratch.mat.resize(dim * dim, 0.0);
scratch.rhs.clear();
scratch.rhs.resize(dim, 0.0);
for i in 0..n {
for j in 0..n {
scratch.mat[i * dim + j] =
1.0 - variogram.gamma_between_2d(neighbours[i].pos, neighbours[j].pos) / s;
}
scratch.rhs[i] = 1.0
- variogram.gamma_offset_2d(
neighbours[i].offset_to_target[0],
neighbours[i].offset_to_target[1],
) / s;
}
if has_sec {
let s_idx = n; for i in 0..n {
let cross = rho * scratch.rhs[i];
scratch.mat[i * dim + s_idx] = cross;
scratch.mat[s_idx * dim + i] = cross;
}
scratch.mat[s_idx * dim + s_idx] = 1.0; scratch.rhs[s_idx] = rho; }
if !lu_factor_in_place(&mut scratch.mat, dim, &mut scratch.perm) {
return (0.0, 1.0);
}
lu_solve_into(
&scratch.mat,
&scratch.perm,
dim,
&scratch.rhs,
&mut scratch.sol,
);
let mut mean = 0.0;
let mut var = 1.0;
for i in 0..n {
mean += scratch.sol[i] * neighbours[i].value;
var -= scratch.sol[i] * scratch.rhs[i];
}
if has_sec {
mean += scratch.sol[n] * sec;
var -= scratch.sol[n] * scratch.rhs[n];
}
(mean, var.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gridding::kriging::VariogramModel;
use crate::OrdinaryKriging;
use approx::assert_relative_eq;
fn spherical(nugget: f64, sill: f64, range: f64) -> Variogram {
Variogram::new(VariogramModel::Spherical, nugget, sill, range).unwrap()
}
#[test]
fn new_validates() {
assert!(LocalKriging::new(spherical(0.0, 1.0, 10.0), 0, 5.0).is_err());
assert!(LocalKriging::new(spherical(0.0, 1.0, 10.0), 8, 0.0).is_err());
assert!(LocalKriging::new(spherical(0.0, 1.0, 10.0), 8, 5.0).is_ok());
}
#[test]
fn reproduces_global_ok_when_neighbourhood_covers_all_data() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 8, 8);
let coords = [
[0.0, 0.0, 10.0],
[7.0, 0.0, 22.0],
[0.0, 7.0, 7.0],
[7.0, 7.0, 40.0],
[3.0, 4.0, 18.0],
];
let vg = spherical(0.0, 1.0, 20.0);
let global = OrdinaryKriging::new(vg).krige(&coords, &lattice).unwrap();
let local = LocalKriging::new(vg, 10, 1000.0)
.unwrap()
.krige(&coords, &lattice)
.unwrap();
for ((ge, gv), (le, lv)) in global
.0
.iter()
.zip(global.1.iter())
.zip(local.0.iter().zip(local.1.iter()))
{
assert_relative_eq!(ge, le, epsilon = 1e-7);
assert_relative_eq!(gv, lv, epsilon = 1e-7);
}
}
#[test]
fn exact_at_data_nodes() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 6, 6);
let coords = [[0.0, 0.0, 10.0], [5.0, 5.0, 40.0], [2.0, 3.0, 18.0]];
let lk = LocalKriging::new(spherical(0.0, 1.0, 10.0), 8, 100.0).unwrap();
let (est, var) = lk.krige(&coords, &lattice).unwrap();
for c in &coords {
let (fi, fj) = lattice.xy_to_ij(c[0], c[1]).unwrap();
let (i, j) = (fi.round() as usize, fj.round() as usize);
assert_relative_eq!(est[[i, j]], c[2], epsilon = 1e-6);
assert!(var[[i, j]].abs() < 1e-6);
}
}
#[test]
fn node_beyond_radius_is_nan() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 20, 20);
let coords = [[0.0, 0.0, 5.0]];
let lk = LocalKriging::new(spherical(0.0, 1.0, 3.0), 4, 2.0).unwrap();
let (est, _) = lk.krige(&coords, &lattice).unwrap();
assert!(est[[19, 19]].is_nan(), "far corner should be undefined");
assert_relative_eq!(est[[0, 0]], 5.0, epsilon = 1e-9);
}
fn neigh(pos: [f64; 2], value: f64, target: [f64; 2]) -> SkNeighbour {
SkNeighbour {
pos,
value,
offset_to_target: [pos[0] - target[0], pos[1] - target[1]],
}
}
#[test]
fn sk_no_neighbours_is_prior() {
let (m, v) = simple_kriging(&[], &spherical(0.0, 1.0, 10.0), None);
assert_eq!((m, v), (0.0, 1.0));
}
#[test]
fn sk_exact_on_a_datum() {
let vg = spherical(0.0, 1.0, 10.0);
let ns = [neigh([0.0, 0.0], 2.5, [0.0, 0.0])];
let (m, v) = simple_kriging(&ns, &vg, None);
assert_relative_eq!(m, 2.5, epsilon = 1e-9);
assert!(v.abs() < 1e-9);
}
#[test]
fn collocated_rho_zero_bit_matches_plain_sk() {
let vg = spherical(0.2, 1.0, 15.0);
let target = [1.0, 1.0];
let ns = [
neigh([0.0, 0.0], 1.0, target),
neigh([2.0, 0.0], -0.5, target),
neigh([0.0, 2.0], 0.3, target),
];
let plain = simple_kriging(&ns, &vg, None);
let co_zero = simple_kriging(&ns, &vg, Some((99.0, 0.0))); assert_eq!(plain, co_zero, "ρ=0 must exactly reduce to plain SK");
}
#[test]
fn collocated_high_rho_pulls_toward_secondary() {
let vg = spherical(0.0, 1.0, 15.0);
let target = [5.0, 5.0];
let ns = [neigh([0.0, 0.0], 0.0, target)];
let low = simple_kriging(&ns, &vg, Some((2.0, 0.1))).0;
let high = simple_kriging(&ns, &vg, Some((2.0, 0.95))).0;
assert!(high > low, "higher ρ should pull toward the secondary");
assert!(
high > 1.0,
"ρ→1 should pull most of the way to +2 (got {high})"
);
}
#[test]
#[ignore]
fn local_kriging_40k_scale() {
use std::time::Instant;
let mut coords = Vec::with_capacity(40_000);
let mut s: u64 = 0x1234_5678;
let mut next = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
(s >> 11) as f64 / (1u64 << 53) as f64
};
for _ in 0..40_000 {
let x = next() * 1200.0;
let y = next() * 1200.0;
let z = next() * 100.0;
coords.push([x, y, z]);
}
let lattice = Lattice::regular(0.0, 0.0, 10.0, 10.0, 120, 120);
let lk = LocalKriging::new(spherical(0.1, 1.0, 150.0), 24, 200.0).unwrap();
let t0 = Instant::now();
let (est, _var) = lk.krige(&coords, &lattice).unwrap();
let dt = t0.elapsed();
let defined = est.iter().filter(|v| v.is_finite()).count();
eprintln!(
"local_kriging 40k pts -> 120x120 grid ({} nodes, {defined} defined): {:.3?}",
120 * 120,
dt
);
assert_eq!(est.dim(), (120, 120));
#[cfg(not(debug_assertions))]
assert!(
dt.as_secs_f64() < 2.0,
"local kriging 40k scale budget exceeded: {dt:.3?} (> 2 s)"
);
}
}