use crate::foundation::Lattice;
use ndarray::Array2;
use super::idw::grid_idw;
use super::mincurv_operator::MinCurvatureOperator;
use super::Conditioning;
pub(super) const ON_NODE_EPS: f64 = 1e-9;
pub(super) const DATA_WEIGHT: f64 = 1.0e5;
pub(super) const TENSION: f64 = 0.25;
pub(crate) fn grid_min_curvature(
coords: &[[f64; 3]],
lattice: &Lattice,
seed: Option<&Array2<f64>>,
conditioning: Conditioning,
) -> Array2<f64> {
let sample_xy: Vec<[f64; 2]> = coords.iter().map(|c| [c[0], c[1]]).collect();
if let Ok(op) = MinCurvatureOperator::factor(lattice, &sample_xy, conditioning) {
let z: Vec<f64> = coords.iter().map(|c| c[2]).collect();
if let Ok(field) = op.solve(&z) {
return field;
}
}
grid_min_curvature_sor(coords, lattice, seed, conditioning)
}
pub(super) enum SampleKind {
OnNode(usize, usize),
Off {
nodes: [(usize, usize); 4],
w: [f64; 4],
},
Skip,
}
pub(super) fn classify_sample(
lattice: &Lattice,
x: f64,
y: f64,
conditioning: Conditioning,
) -> SampleKind {
let (nc, nr) = (lattice.ncol, lattice.nrow);
let Some((fi, fj)) = lattice.xy_to_ij(x, y) else {
return SampleKind::Skip;
};
let off_node = matches!(conditioning, Conditioning::Bilinear)
&& ((fi - fi.round()).abs() > ON_NODE_EPS || (fj - fj.round()).abs() > ON_NODE_EPS);
if off_node {
if fi < 0.0 || fj < 0.0 || fi > (nc as f64 - 1.0) || fj > (nr as f64 - 1.0) {
return SampleKind::Skip;
}
let (i0, j0) = (fi.floor() as usize, fj.floor() as usize);
let (i1, j1) = ((i0 + 1).min(nc - 1), (j0 + 1).min(nr - 1));
let (tx, ty) = (fi - i0 as f64, fj - j0 as f64);
SampleKind::Off {
nodes: [(i0, j0), (i1, j0), (i0, j1), (i1, j1)],
w: [
(1.0 - tx) * (1.0 - ty),
tx * (1.0 - ty),
(1.0 - tx) * ty,
tx * ty,
],
}
} else {
let i = fi.round();
let j = fj.round();
if i < 0.0 || j < 0.0 {
return SampleKind::Skip;
}
let (i, j) = (i as usize, j as usize);
if i < nc && j < nr {
SampleKind::OnNode(i, j)
} else {
SampleKind::Skip
}
}
}
pub(super) fn grid_min_curvature_sor(
coords: &[[f64; 3]],
lattice: &Lattice,
seed: Option<&Array2<f64>>,
conditioning: Conditioning,
) -> Array2<f64> {
let (nc, nr) = (lattice.ncol, lattice.nrow);
let mut z = match seed {
Some(s) if s.dim() == (nc, nr) => s.clone(),
_ => grid_idw(coords, lattice),
};
let mut fixed = Array2::from_elem((nc, nr), false);
let mut acc: std::collections::HashMap<(usize, usize), (f64, usize)> =
std::collections::HashMap::new();
let mut off: Vec<OffSample> = Vec::new();
for c in coords {
match classify_sample(lattice, c[0], c[1], conditioning) {
SampleKind::OnNode(i, j) => {
let e = acc.entry((i, j)).or_insert((0.0, 0));
e.0 += c[2];
e.1 += 1;
}
SampleKind::Off { nodes, w } => off.push(OffSample { nodes, w, d: c[2] }),
SampleKind::Skip => {}
}
}
for ((i, j), (sum, n)) in acc {
z[[i, j]] = sum / n as f64;
fixed[[i, j]] = true;
}
let mut incidence: std::collections::HashMap<(usize, usize), Vec<(usize, u8)>> =
std::collections::HashMap::new();
let mut a_diag: std::collections::HashMap<(usize, usize), f64> =
std::collections::HashMap::new();
for (s_idx, s) in off.iter().enumerate() {
for (k, &(i, j)) in s.nodes.iter().enumerate() {
if s.w[k] == 0.0 || fixed[[i, j]] {
continue;
}
incidence.entry((i, j)).or_default().push((s_idx, k as u8));
*a_diag.entry((i, j)).or_insert(0.0) += s.w[k] * s.w[k];
}
}
if nc < 2 || nr < 2 {
return z;
}
const MAX_ITERS: usize = 20_000;
const TOL: f64 = 1e-6;
const OMEGA: f64 = 1.5;
let denom = 20.0 * (1.0 - TENSION) + 4.0 * TENSION;
for _ in 0..MAX_ITERS {
let mut max_delta = 0.0_f64;
for j in 0..nr {
for i in 0..nc {
if fixed[[i, j]] {
continue;
}
let target = relaxation_target(&z, nc, nr, i, j, denom);
let new = match incidence.get(&(i, j)) {
Some(inc) => {
let s_p = denom * target;
let a_p = a_diag[&(i, j)];
let mut num_data = 0.0;
for &(s_idx, k) in inc {
let s = &off[s_idx];
let est: f64 = (0..4)
.map(|m| s.w[m] * z[[s.nodes[m].0, s.nodes[m].1]])
.sum();
let r_excl = est - s.w[k as usize] * z[[i, j]];
num_data += s.w[k as usize] * (s.d - r_excl);
}
(s_p + DATA_WEIGHT * num_data) / (denom + DATA_WEIGHT * a_p)
}
None => target,
};
let old = z[[i, j]];
let updated = old + OMEGA * (new - old);
z[[i, j]] = updated;
max_delta = max_delta.max((updated - old).abs());
}
}
if max_delta < TOL {
break;
}
}
z
}
struct OffSample {
nodes: [(usize, usize); 4],
w: [f64; 4],
d: f64,
}
fn relaxation_target(z: &Array2<f64>, nc: usize, nr: usize, i: usize, j: usize, denom: f64) -> f64 {
let (ii, jj) = (i as isize, j as isize);
let at = |di: isize, dj: isize| z_at(z, nc, nr, ii + di, jj + dj);
let e1 = at(0, 1) + at(0, -1) + at(1, 0) + at(-1, 0);
let d = at(1, 1) + at(-1, 1) + at(1, -1) + at(-1, -1);
let w2 = at(0, 2) + at(0, -2) + at(2, 0) + at(-2, 0);
((1.0 - TENSION) * (8.0 * e1 - 2.0 * d - w2) + TENSION * e1) / denom
}
fn z_at(z: &Array2<f64>, nc: usize, nr: usize, i: isize, j: isize) -> f64 {
let (nci, nri) = (nc as isize, nr as isize);
if i < 0 {
return 2.0 * z_at(z, nc, nr, i + 1, j) - z_at(z, nc, nr, i + 2, j);
}
if i >= nci {
return 2.0 * z_at(z, nc, nr, i - 1, j) - z_at(z, nc, nr, i - 2, j);
}
if j < 0 {
return 2.0 * z_at(z, nc, nr, i, j + 1) - z_at(z, nc, nr, i, j + 2);
}
if j >= nri {
return 2.0 * z_at(z, nc, nr, i, j - 1) - z_at(z, nc, nr, i, j - 2);
}
z[[i as usize, j as usize]]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn off_node_sample_is_honoured_bilinearly() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 9, 9);
let (sx, sy, sz) = (4.3, 5.7, 100.0);
let mut coords = vec![
[0.0, 0.0, 10.0],
[8.0, 0.0, 20.0],
[0.0, 8.0, 15.0],
[8.0, 8.0, 25.0],
[sx, sy, sz],
];
let bilinear_field = grid_min_curvature(&coords, &lattice, None, Conditioning::Bilinear);
let (i0, j0) = (4usize, 5usize);
let (tx, ty) = (0.3, 0.7);
let read = |f: &Array2<f64>| {
f[[i0, j0]] * (1.0 - tx) * (1.0 - ty)
+ f[[i0 + 1, j0]] * tx * (1.0 - ty)
+ f[[i0, j0 + 1]] * (1.0 - tx) * ty
+ f[[i0 + 1, j0 + 1]] * tx * ty
};
let bilinear_read = read(&bilinear_field);
assert!(
(bilinear_read - sz).abs() < 0.05,
"Bilinear: interpolated surface must pass through the off-node datum, got {bilinear_read}"
);
coords.truncate(5);
let nearest_field = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
let nearest_read = read(&nearest_field);
assert!(
(nearest_read - sz).abs() > (bilinear_read - sz).abs(),
"NearestNode must miss the off-node datum by more than Bilinear: near {nearest_read} vs bil {bilinear_read}"
);
}
#[test]
fn linear_trend_reproduced_exactly() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 7, 7);
let (a, b, c) = (2.0, -3.0, 5.0);
let mut coords = Vec::new();
for j in 0..7 {
for i in 0..7 {
let (x, y) = lattice.node_xy(i, j);
coords.push([x, y, a * x + b * y + c]);
}
}
let out = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
for j in 0..7 {
for i in 0..7 {
let (x, y) = lattice.node_xy(i, j);
let expected = a * x + b * y + c;
assert!(
(out[[i, j]] - expected).abs() < 1e-6,
"node ({i},{j}): got {}, want {expected}",
out[[i, j]]
);
}
}
}
#[test]
fn sparse_linear_samples_recover_plane() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 9, 9);
let (a, b, c) = (1.0, 0.5, -2.0);
let mut coords = Vec::new();
for j in 0..9 {
for i in 0..9 {
if i == 0 || i == 8 || j == 0 || j == 8 {
let (x, y) = lattice.node_xy(i, j);
coords.push([x, y, a * x + b * y + c]);
}
}
}
let out = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
let (x, y) = lattice.node_xy(4, 4);
let expected = a * x + b * y + c;
assert!(
(out[[4, 4]] - expected).abs() < 1e-3,
"interior node: got {}, want {expected}",
out[[4, 4]]
);
}
#[test]
fn plane_reference_natural_dip_boundary() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 13, 13);
let (a, b, c) = (5000.0, 2.0, 3.0);
let plane = |x: f64, y: f64| a + b * x + c * y;
let mut coords = Vec::new();
for &(i, j) in &[(0usize, 0usize), (12, 0), (0, 12), (12, 12), (6, 6)] {
let (x, y) = lattice.node_xy(i, j);
coords.push([x, y, plane(x, y)]);
}
let out = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
let mut max_drift = 0.0_f64;
for j in 0..13 {
for i in 0..13 {
let (x, y) = lattice.node_xy(i, j);
max_drift = max_drift.max((out[[i, j]] - plane(x, y)).abs());
}
}
assert!(
max_drift < 1e-3,
"plane-reference max per-node drift {max_drift} ft (interior sag not eliminated)"
);
}
#[test]
fn anchors_are_honoured() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 5, 5);
let coords = [[0.0, 0.0, 0.0], [4.0, 4.0, 100.0], [2.0, 2.0, 50.0]];
let out = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
assert!((out[[0, 0]] - 0.0).abs() < 1e-9);
assert!((out[[4, 4]] - 100.0).abs() < 1e-9);
assert!((out[[2, 2]] - 50.0).abs() < 1e-9);
}
#[test]
fn degenerate_single_row_does_not_panic() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 5, 1);
let coords = [[0.0, 0.0, 10.0], [4.0, 0.0, 20.0]];
let out = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
assert_eq!(out.dim(), (5, 1));
assert!(out.iter().all(|v| v.is_finite()));
}
#[test]
fn warm_start_matches_cold_to_tolerance() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 12, 10);
let coords = [
[1.0, 1.0, 10.0],
[9.0, 2.0, 25.0],
[3.0, 8.0, 5.0],
[10.0, 8.0, 40.0],
[5.0, 5.0, 18.0],
];
let cold = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
let warm = grid_min_curvature(&coords, &lattice, Some(&cold), Conditioning::NearestNode);
for (w, c) in warm.iter().zip(cold.iter()) {
assert!((w - c).abs() < 1e-3, "warm {w} vs cold {c}");
}
}
#[test]
fn wrong_shape_seed_falls_back_to_cold() {
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, 6, 6);
let coords = [[0.0, 0.0, 0.0], [5.0, 5.0, 50.0], [2.0, 3.0, 20.0]];
let cold = grid_min_curvature(&coords, &lattice, None, Conditioning::NearestNode);
let bogus = Array2::from_elem((3, 3), 999.0); let out = grid_min_curvature(&coords, &lattice, Some(&bogus), Conditioning::NearestNode);
for (o, c) in out.iter().zip(cold.iter()) {
assert_eq!(o, c);
}
}
}