use crate::error::StaticError;
use petektools::{gridding::grid_min_curvature_seeded, Lattice};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy)]
pub struct Control {
pub ip: usize,
pub jp: usize,
pub z: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SolveOpts {
pub tension: f64,
pub omega: f64,
pub tol: f64,
pub max_iter: usize,
}
impl Default for SolveOpts {
fn default() -> Self {
Self {
tension: 0.25,
omega: 1.5,
tol: 1e-6,
max_iter: 20_000,
}
}
}
#[derive(Debug, Clone)]
pub struct Surface {
nx: usize, ny: usize, z: Vec<f64>,
}
impl Surface {
#[must_use]
pub fn constant(nx: usize, ny: usize, z: f64) -> Self {
assert!(nx >= 2 && ny >= 2, "surface lattice must be at least 2x2");
Self {
nx,
ny,
z: vec![z; nx * ny],
}
}
#[must_use]
pub fn offset_by(&self, dz: f64) -> Self {
Self {
nx: self.nx,
ny: self.ny,
z: self.z.iter().map(|v| v + dz).collect(),
}
}
pub fn offset_by_field(&self, dz_m: &[f64]) -> Result<Self, StaticError> {
if dz_m.len() != self.nx * self.ny {
return Err(StaticError::InvalidInput(format!(
"offset field has {} values, expected {} ({}x{})",
dz_m.len(),
self.nx * self.ny,
self.nx,
self.ny
)));
}
if let Some(bad) = dz_m.iter().find(|v| !v.is_finite()) {
return Err(StaticError::InvalidInput(format!(
"offset field must be finite, got {bad}"
)));
}
Ok(Self {
nx: self.nx,
ny: self.ny,
z: self.z.iter().zip(dz_m).map(|(v, d)| v + d).collect(),
})
}
#[must_use]
pub fn nx(&self) -> usize {
self.nx
}
#[must_use]
pub fn ny(&self) -> usize {
self.ny
}
#[must_use]
pub fn z(&self, ip: usize, jp: usize) -> f64 {
self.z[jp * self.nx + ip]
}
pub fn guard_below(&self, top: &Surface, clamp: bool) -> Result<Surface, StaticError> {
if self.nx != top.nx || self.ny != top.ny {
return Err(StaticError::InvalidInput(format!(
"base lattice {}x{} does not match top {}x{}",
self.nx, self.ny, top.nx, top.ny
)));
}
let mut nodes = 0usize;
let mut worst = 0.0_f64;
for (b, t) in self.z.iter().zip(&top.z) {
let sep = b - t;
if sep < 0.0 {
nodes += 1;
worst = worst.min(sep);
}
}
if nodes == 0 {
return Ok(self.clone());
}
if !clamp {
return Err(StaticError::CrossedSurfaces {
nodes,
worst_m: worst,
});
}
Ok(Surface {
nx: self.nx,
ny: self.ny,
z: self.z.iter().zip(&top.z).map(|(b, t)| b.max(*t)).collect(),
})
}
pub fn repair_min_thickness(
&self,
top: &Surface,
min_thickness_m: f64,
) -> Result<(Surface, usize, f64), StaticError> {
if self.nx != top.nx || self.ny != top.ny {
return Err(StaticError::InvalidInput(format!(
"base lattice {}x{} does not match top {}x{}",
self.nx, self.ny, top.nx, top.ny
)));
}
if !(min_thickness_m.is_finite() && min_thickness_m >= 0.0) {
return Err(StaticError::InvalidInput(format!(
"min_thickness_m must be finite and >= 0, got {min_thickness_m}"
)));
}
let mut repaired = 0usize;
let mut worst = 0.0_f64;
let z: Vec<f64> = self
.z
.iter()
.zip(&top.z)
.map(|(b, t)| {
let floor = t + min_thickness_m;
if *b < floor {
repaired += 1;
worst = worst.min(b - t);
floor
} else {
*b
}
})
.collect();
Ok((
Surface {
nx: self.nx,
ny: self.ny,
z,
},
repaired,
worst,
))
}
pub fn guard_above(&self, lower: &Surface, clamp: bool) -> Result<Surface, StaticError> {
if self.nx != lower.nx || self.ny != lower.ny {
return Err(StaticError::InvalidInput(format!(
"upper lattice {}x{} does not match lower {}x{}",
self.nx, self.ny, lower.nx, lower.ny
)));
}
let mut nodes = 0usize;
let mut worst = 0.0_f64;
for (u, l) in self.z.iter().zip(&lower.z) {
let sep = l - u;
if sep < 0.0 {
nodes += 1;
worst = worst.min(sep);
}
}
if nodes == 0 {
return Ok(self.clone());
}
if !clamp {
return Err(StaticError::CrossedSurfaces {
nodes,
worst_m: worst,
});
}
Ok(Surface {
nx: self.nx,
ny: self.ny,
z: self
.z
.iter()
.zip(&lower.z)
.map(|(u, l)| u.min(*l))
.collect(),
})
}
pub fn repair_min_thickness_from_below(
&self,
lower: &Surface,
min_thickness_m: f64,
) -> Result<(Surface, usize, f64), StaticError> {
if self.nx != lower.nx || self.ny != lower.ny {
return Err(StaticError::InvalidInput(format!(
"upper lattice {}x{} does not match lower {}x{}",
self.nx, self.ny, lower.nx, lower.ny
)));
}
if !(min_thickness_m.is_finite() && min_thickness_m >= 0.0) {
return Err(StaticError::InvalidInput(format!(
"min_thickness_m must be finite and >= 0, got {min_thickness_m}"
)));
}
let mut repaired = 0usize;
let mut worst = 0.0_f64;
let z: Vec<f64> = self
.z
.iter()
.zip(&lower.z)
.map(|(u, l)| {
let ceil = l - min_thickness_m;
if *u > ceil {
repaired += 1;
worst = worst.min(l - u);
ceil
} else {
*u
}
})
.collect();
Ok((
Surface {
nx: self.nx,
ny: self.ny,
z,
},
repaired,
worst,
))
}
#[must_use]
pub fn taper_beyond_data(&self, controls: &[Control], policy: ExtrapolationPolicy) -> Surface {
let ExtrapolationPolicy::DecayToData {
start_cells,
decay_cells,
} = policy
else {
return self.clone();
};
if controls.is_empty() {
return self.clone();
}
let (nx, ny) = (self.nx, self.ny);
let mut source: Vec<usize> = vec![usize::MAX; nx * ny];
let mut dist2: Vec<f64> = vec![f64::INFINITY; nx * ny];
for (c_idx, c) in controls.iter().enumerate() {
let idx = c.jp * nx + c.ip;
source[idx] = c_idx;
dist2[idx] = 0.0;
}
let d2 = |c: &Control, ip: usize, jp: usize| -> f64 {
let dx = ip as f64 - c.ip as f64;
let dy = jp as f64 - c.jp as f64;
dx * dx + dy * dy
};
let relax = |ip: usize,
jp: usize,
nip: usize,
njp: usize,
src: &mut Vec<usize>,
dst: &mut Vec<f64>| {
let n_idx = njp * nx + nip;
let s = src[n_idx];
if s == usize::MAX {
return;
}
let cand = d2(&controls[s], ip, jp);
let idx = jp * nx + ip;
if cand < dst[idx] {
dst[idx] = cand;
src[idx] = s;
}
};
for jp in 0..ny {
for ip in 0..nx {
if ip > 0 {
relax(ip, jp, ip - 1, jp, &mut source, &mut dist2);
}
if jp > 0 {
relax(ip, jp, ip, jp - 1, &mut source, &mut dist2);
if ip > 0 {
relax(ip, jp, ip - 1, jp - 1, &mut source, &mut dist2);
}
if ip + 1 < nx {
relax(ip, jp, ip + 1, jp - 1, &mut source, &mut dist2);
}
}
}
}
for jp in (0..ny).rev() {
for ip in (0..nx).rev() {
if ip + 1 < nx {
relax(ip, jp, ip + 1, jp, &mut source, &mut dist2);
}
if jp + 1 < ny {
relax(ip, jp, ip, jp + 1, &mut source, &mut dist2);
if ip + 1 < nx {
relax(ip, jp, ip + 1, jp + 1, &mut source, &mut dist2);
}
if ip > 0 {
relax(ip, jp, ip - 1, jp + 1, &mut source, &mut dist2);
}
}
}
}
let start = start_cells.max(0.0);
let z: Vec<f64> = (0..nx * ny)
.map(|idx| {
let s = source[idx];
let d = dist2[idx].sqrt();
let w = if decay_cells > 0.0 {
((d - start) / decay_cells).clamp(0.0, 1.0)
} else if d > start {
1.0
} else {
0.0
};
if w == 0.0 {
self.z[idx]
} else {
self.z[idx] * (1.0 - w) + controls[s].z * w
}
})
.collect();
Surface { nx, ny, z }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ExtrapolationPolicy {
NaturalDip,
DecayToData {
start_cells: f64,
decay_cells: f64,
},
}
impl Default for ExtrapolationPolicy {
fn default() -> Self {
ExtrapolationPolicy::DecayToData {
start_cells: 2.0,
decay_cells: 4.0,
}
}
}
pub fn solve_surface(
nx: usize,
ny: usize,
controls: &[Control],
opts: SolveOpts,
) -> Result<Surface, StaticError> {
if nx < 2 || ny < 2 {
return Err(StaticError::Grid(format!(
"surface lattice must be at least 2x2 (ni,nj >= 1), got {nx}x{ny}"
)));
}
if controls.is_empty() {
return Err(StaticError::InvalidInput(
"minimum-curvature surface needs at least one control point".into(),
));
}
if !(opts.tension >= 0.0 && opts.tension <= 1.0) {
return Err(StaticError::OutOfRange(format!(
"tension must be in [0,1], got {}",
opts.tension
)));
}
if !(opts.omega > 0.0 && opts.omega < 2.0) {
return Err(StaticError::OutOfRange(format!(
"omega must be in (0,2), got {}",
opts.omega
)));
}
let n = nx * ny;
let mut fixed = vec![false; n];
let mut z = vec![0.0; n];
let mut sum = 0.0;
for c in controls {
if c.ip >= nx || c.jp >= ny {
return Err(StaticError::Grid(format!(
"control ({},{}) outside lattice {nx}x{ny}",
c.ip, c.jp
)));
}
let idx = c.jp * nx + c.ip;
z[idx] = c.z;
fixed[idx] = true;
sum += c.z;
}
let seed = sum / controls.len() as f64;
for (idx, zi) in z.iter_mut().enumerate() {
if !fixed[idx] {
*zi = seed;
}
}
let t = opts.tension;
let denom = 20.0 * (1.0 - t) + 4.0 * t;
for _ in 0..opts.max_iter {
let mut max_change = 0.0_f64;
for jp in 0..ny {
for ip in 0..nx {
let idx = jp * nx + ip;
if fixed[idx] {
continue;
}
let target = update_node(&z, nx, ny, ip, jp, t, denom);
let old = z[idx];
let new = old + opts.omega * (target - old);
z[idx] = new;
max_change = max_change.max((new - old).abs());
}
}
if max_change < opts.tol {
break;
}
}
Ok(Surface { nx, ny, z })
}
#[derive(Debug, Clone)]
pub struct KernelSurface(Surface);
impl KernelSurface {
#[must_use]
pub fn flat(nx: usize, ny: usize, z: f64) -> Self {
Self(Surface::constant(nx, ny, z))
}
#[must_use]
pub fn surface(&self) -> &Surface {
&self.0
}
#[must_use]
pub fn nx(&self) -> usize {
self.0.nx
}
#[must_use]
pub fn ny(&self) -> usize {
self.0.ny
}
#[must_use]
pub fn z(&self, ip: usize, jp: usize) -> f64 {
self.0.z(ip, jp)
}
}
pub fn solve_surface_seeded(
seed: &KernelSurface,
controls: &[Control],
) -> Result<KernelSurface, StaticError> {
let (nx, ny) = (seed.0.nx, seed.0.ny);
if nx < 2 || ny < 2 {
return Err(StaticError::Grid(format!(
"seed lattice must be at least 2x2, got {nx}x{ny}"
)));
}
if controls.is_empty() {
return Err(StaticError::InvalidInput(
"seeded surface needs at least one control point".into(),
));
}
let mut coords = Vec::with_capacity(controls.len());
for c in controls {
if c.ip >= nx || c.jp >= ny {
return Err(StaticError::Grid(format!(
"control ({},{}) outside lattice {nx}x{ny}",
c.ip, c.jp
)));
}
coords.push([c.ip as f64, c.jp as f64, c.z]);
}
let lattice = Lattice::regular(0.0, 0.0, 1.0, 1.0, nx, ny);
let mut seed_arr = ndarray::Array2::<f64>::zeros((nx, ny));
for jp in 0..ny {
for ip in 0..nx {
seed_arr[[ip, jp]] = seed.0.z(ip, jp);
}
}
let field = grid_min_curvature_seeded(&coords, &lattice, Some(&seed_arr))
.map_err(|e| StaticError::Grid(format!("petektools seeded grid failed: {e}")))?;
let mut z = vec![0.0; nx * ny];
for jp in 0..ny {
for ip in 0..nx {
z[jp * nx + ip] = field[[ip, jp]];
}
}
Ok(KernelSurface(Surface { nx, ny, z }))
}
pub fn solve_surface_converged(
nx: usize,
ny: usize,
controls: &[Control],
) -> Result<KernelSurface, StaticError> {
if controls.is_empty() {
return Err(StaticError::InvalidInput(
"structural solve needs at least one control point".into(),
));
}
let n = controls.len() as f64;
let (mut sx, mut sy, mut sz) = (0.0, 0.0, 0.0);
let (mut sxx, mut sxy, mut syy, mut sxz, mut syz) = (0.0, 0.0, 0.0, 0.0, 0.0);
for c in controls {
let (x, y, z) = (c.ip as f64, c.jp as f64, c.z);
sx += x;
sy += y;
sz += z;
sxx += x * x;
sxy += x * y;
syy += y * y;
sxz += x * z;
syz += y * z;
}
let (mx, my, mz) = (sx / n, sy / n, sz / n);
let (cxx, cxy, cyy) = (sxx / n - mx * mx, sxy / n - mx * my, syy / n - my * my);
let (cxz, cyz) = (sxz / n - mx * mz, syz / n - my * mz);
let det = cxx * cyy - cxy * cxy;
let (b, c_coef) = if det.abs() > 1e-9 {
((cxz * cyy - cyz * cxy) / det, (cyz * cxx - cxz * cxy) / det)
} else {
(0.0, 0.0)
};
let a = mz - b * mx - c_coef * my;
let plane = |ip: usize, jp: usize| a + b * ip as f64 + c_coef * jp as f64;
let detrended: Vec<Control> = controls
.iter()
.map(|c| Control {
ip: c.ip,
jp: c.jp,
z: c.z - plane(c.ip, c.jp),
})
.collect();
let mean_r = detrended.iter().map(|c| c.z).sum::<f64>() / n;
let _prof = std::env::var_os("SRS_PROFILE").is_some();
let _t0 = std::time::Instant::now();
let mut surf = solve_surface_seeded(&KernelSurface::flat(nx, ny, mean_r), &detrended)?;
const FIXED_POINT_TOL_M: f64 = 1e-3;
const MAX_RESTARTS: usize = 16;
let mut _restarts = 0usize;
let mut _last_moved = f64::NAN;
if _prof {
eprintln!(
"[SRS_PROFILE] solve_converged START nx={nx} ny={ny} controls={} first_solve_ms={:.1}",
controls.len(),
_t0.elapsed().as_secs_f64() * 1e3,
);
}
for _ in 0..MAX_RESTARTS {
let _tr = std::time::Instant::now();
let next = solve_surface_seeded(&surf, &detrended)?;
let mut moved = 0.0_f64;
for jp in 0..ny {
for ip in 0..nx {
moved = moved.max((next.z(ip, jp) - surf.z(ip, jp)).abs());
}
}
surf = next;
_restarts += 1;
_last_moved = moved;
if _prof {
eprintln!(
"[SRS_PROFILE] restart {_restarts}/{MAX_RESTARTS} moved={moved:.4}m restart_ms={:.1} cum_ms={:.1}",
_tr.elapsed().as_secs_f64() * 1e3,
_t0.elapsed().as_secs_f64() * 1e3,
);
}
if moved < FIXED_POINT_TOL_M {
break;
}
}
if _prof {
eprintln!(
"[SRS_PROFILE] solve_converged DONE controls={} restarts={_restarts} maxed={} last_moved={_last_moved:.4} ms={:.1}",
controls.len(),
_restarts == MAX_RESTARTS,
_t0.elapsed().as_secs_f64() * 1e3,
);
}
let mut z = vec![0.0; nx * ny];
for jp in 0..ny {
for ip in 0..nx {
z[jp * nx + ip] = surf.z(ip, jp) + plane(ip, jp);
}
}
Ok(KernelSurface(Surface { nx, ny, z }))
}
fn update_node(z: &[f64], nx: usize, ny: usize, ip: usize, jp: usize, t: f64, denom: f64) -> f64 {
let i = ip as isize;
let j = jp as isize;
let at = |di: isize, dj: isize| z_at(z, nx, ny, i + di, j + dj);
let e1 = at(-1, 0) + at(1, 0) + at(0, -1) + at(0, 1);
let d = at(-1, -1) + at(1, -1) + at(-1, 1) + at(1, 1);
let w2 = at(-2, 0) + at(2, 0) + at(0, -2) + at(0, 2);
((1.0 - t) * (8.0 * e1 - 2.0 * d - w2) + t * e1) / denom
}
fn z_at(z: &[f64], nx: usize, ny: usize, i: isize, j: isize) -> f64 {
let nxi = nx as isize;
let nyi = ny as isize;
if i < 0 {
return 2.0 * z_at(z, nx, ny, i + 1, j) - z_at(z, nx, ny, i + 2, j);
}
if i >= nxi {
return 2.0 * z_at(z, nx, ny, i - 1, j) - z_at(z, nx, ny, i - 2, j);
}
if j < 0 {
return 2.0 * z_at(z, nx, ny, i, j + 1) - z_at(z, nx, ny, i, j + 2);
}
if j >= nyi {
return 2.0 * z_at(z, nx, ny, i, j - 1) - z_at(z, nx, ny, i, j - 2);
}
z[(j * nxi + i) as usize]
}
#[cfg(test)]
mod tests {
use super::*;
fn solve(nx: usize, ny: usize, c: &[Control]) -> Surface {
solve_surface(nx, ny, c, SolveOpts::default()).unwrap()
}
#[test]
fn constant_controls_give_constant_surface() {
let c = [
Control {
ip: 0,
jp: 0,
z: 5000.0,
},
Control {
ip: 9,
jp: 9,
z: 5000.0,
},
];
let s = solve(10, 10, &c);
for jp in 0..10 {
for ip in 0..10 {
assert!(
(s.z(ip, jp) - 5000.0).abs() < 1e-4,
"node ({ip},{jp})={}",
s.z(ip, jp)
);
}
}
}
#[test]
fn repair_min_thickness_floors_thin_columns_and_leaves_the_rest() {
let top = Surface {
nx: 2,
ny: 2,
z: vec![0.0; 4],
};
let base = Surface {
nx: 2,
ny: 2,
z: vec![-5.0, 0.5, 3.0, 10.0],
};
let (rep, columns, worst_m) = base.repair_min_thickness(&top, 2.0).unwrap();
assert_eq!(columns, 2, "two nodes sit below the 2 m floor");
assert!(
(worst_m - -5.0).abs() < 1e-12,
"worst = most-negative original sep: {worst_m}"
);
assert!(
(rep.z(0, 0) - 2.0).abs() < 1e-12,
"crossing floored to top+2"
);
assert!((rep.z(1, 0) - 2.0).abs() < 1e-12, "thin floored to top+2");
assert_eq!(rep.z(0, 1), 3.0, "OK node bit-unchanged");
assert_eq!(rep.z(1, 1), 10.0, "OK node bit-unchanged");
let clean = Surface {
nx: 2,
ny: 2,
z: vec![5.0; 4],
};
let (_, n, w) = clean.repair_min_thickness(&top, 2.0).unwrap();
assert_eq!((n, w), (0, 0.0), "nothing repaired -> zero count/worst");
let wrong = Surface {
nx: 3,
ny: 1,
z: vec![0.0; 3],
};
assert!(base.repair_min_thickness(&wrong, 2.0).is_err());
assert!(base.repair_min_thickness(&top, -1.0).is_err());
assert!(base.repair_min_thickness(&top, f64::NAN).is_err());
}
#[test]
fn repair_from_below_moves_the_upper_up_preserving_the_mapped_lower() {
let lower = Surface {
nx: 2,
ny: 2,
z: vec![10.0; 4],
};
let upper = Surface {
nx: 2,
ny: 2,
z: vec![15.0, 9.5, 7.0, 3.0],
};
let (rep, columns, worst_m) = upper.repair_min_thickness_from_below(&lower, 2.0).unwrap();
assert_eq!(
columns, 2,
"two nodes sit within the 2 m floor of the lower"
);
assert!(
(worst_m - -5.0).abs() < 1e-12,
"worst = most-negative original lower-upper: {worst_m}"
);
assert!(
(rep.z(0, 0) - 8.0).abs() < 1e-12,
"crossing lifted to lower-2"
);
assert!((rep.z(1, 0) - 8.0).abs() < 1e-12, "thin lifted to lower-2");
assert_eq!(rep.z(0, 1), 7.0, "OK node bit-unchanged");
assert_eq!(rep.z(1, 1), 3.0, "OK node bit-unchanged");
assert!(lower.z.iter().all(|&z| z == 10.0));
}
#[test]
fn guard_above_clamps_or_errors_a_derived_upper_crossing_a_mapped_lower() {
let lower = Surface {
nx: 2,
ny: 2,
z: vec![10.0; 4],
};
let upper = Surface {
nx: 2,
ny: 2,
z: vec![15.0, 5.0, 12.0, 3.0], };
let clamped = upper.guard_above(&lower, true).unwrap();
assert_eq!(clamped.z(0, 0), 10.0, "crossing clamped to lower");
assert_eq!(clamped.z(0, 1), 10.0, "crossing clamped to lower");
assert_eq!(clamped.z(1, 0), 5.0, "non-crossing untouched");
assert_eq!(clamped.z(1, 1), 3.0, "non-crossing untouched");
assert!(matches!(
upper.guard_above(&lower, false),
Err(StaticError::CrossedSurfaces { nodes: 2, .. })
));
let ok = Surface {
nx: 2,
ny: 2,
z: vec![1.0, 2.0, 3.0, 4.0],
};
assert!(ok.guard_above(&lower, false).is_ok());
}
#[test]
fn reproduces_a_plane() {
let plane = |ip: usize, jp: usize| 5000.0 + 2.0 * ip as f64 + 3.0 * jp as f64;
let mut c = Vec::new();
for &(ip, jp) in &[(0, 0), (12, 0), (0, 12), (12, 12), (6, 6)] {
c.push(Control {
ip,
jp,
z: plane(ip, jp),
});
}
let s = solve_surface(
13,
13,
&c,
SolveOpts {
tol: 1e-9,
max_iter: 60_000,
..SolveOpts::default()
},
)
.unwrap();
for jp in 0..13 {
for ip in 0..13 {
let got = s.z(ip, jp);
assert!(
(got - plane(ip, jp)).abs() < 0.05,
"({ip},{jp}) {got} != {}",
plane(ip, jp)
);
}
}
}
#[test]
fn honors_control_points_exactly() {
let c = [
Control {
ip: 0,
jp: 0,
z: 5000.0,
},
Control {
ip: 5,
jp: 5,
z: 5200.0,
},
Control {
ip: 9,
jp: 9,
z: 5050.0,
},
];
let s = solve(10, 10, &c);
assert!((s.z(0, 0) - 5000.0).abs() < 1e-9);
assert!((s.z(5, 5) - 5200.0).abs() < 1e-9);
assert!((s.z(9, 9) - 5050.0).abs() < 1e-9);
assert!(s.z(5, 5) > s.z(2, 2));
}
#[test]
fn rejects_bad_input() {
assert!(solve_surface(0, 5, &[], SolveOpts::default()).is_err());
assert!(solve_surface(5, 5, &[], SolveOpts::default()).is_err());
let c = [Control {
ip: 9,
jp: 0,
z: 1.0,
}];
assert!(solve_surface(5, 5, &c, SolveOpts::default()).is_err()); }
#[test]
fn seeded_honors_controls_as_hard_constraints() {
let seed = KernelSurface::flat(12, 10, 5000.0);
let c = [Control {
ip: 6,
jp: 7,
z: 4900.0,
}];
let s = solve_surface_seeded(&seed, &c).unwrap();
assert!(
(s.z(6, 7) - 4900.0).abs() < 1e-6,
"control node = {}",
s.z(6, 7)
);
assert_eq!((s.nx(), s.ny()), (12, 10));
}
#[test]
fn seeded_warm_equals_cold_of_same_kernel() {
let flat = KernelSurface::flat(12, 10, 5000.0);
let controls: Vec<Control> = [
(0, 0, 5000.0),
(11, 0, 5010.0),
(0, 9, 4990.0),
(11, 9, 5005.0),
(5, 5, 5030.0),
]
.iter()
.map(|&(ip, jp, z)| Control { ip, jp, z })
.collect();
let cold = solve_surface_seeded(&flat, &controls).unwrap();
let warm = solve_surface_seeded(&cold, &controls).unwrap();
for jp in 0..10 {
for ip in 0..12 {
assert!(
(warm.z(ip, jp) - cold.z(ip, jp)).abs() < 1e-6,
"({ip},{jp}) warm {} vs cold {}",
warm.z(ip, jp),
cold.z(ip, jp)
);
}
}
}
#[test]
fn seeded_rejects_bad_input() {
let seed = KernelSurface::flat(5, 5, 0.0);
assert!(solve_surface_seeded(&seed, &[]).is_err()); let off = [Control {
ip: 9,
jp: 0,
z: 1.0,
}];
assert!(solve_surface_seeded(&seed, &off).is_err()); let tiny = KernelSurface::flat(2, 2, 0.0);
let c = [Control {
ip: 0,
jp: 0,
z: 1.0,
}];
assert!(solve_surface_seeded(&tiny, &c).is_ok()); }
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(150))]
#[test]
fn prop_repair_min_thickness_enforces_floor_and_is_idempotent(
offsets in proptest::collection::vec(-60.0f64..80.0, 9),
min_t in 0.0f64..30.0,
) {
let top = Surface::constant(3, 3, 5000.0);
let base = top.offset_by_field(&offsets).unwrap();
let (repaired, count, worst) = base.repair_min_thickness(&top, min_t).unwrap();
for jp in 0..3 {
for ip in 0..3 {
let sep = repaired.z(ip, jp) - top.z(ip, jp);
proptest::prop_assert!(
sep >= min_t - 1e-6,
"node ({ip},{jp}) thickness {sep} < min {min_t}"
);
}
}
let expected = offsets.iter().filter(|&&o| o < min_t).count();
proptest::prop_assert_eq!(count, expected);
proptest::prop_assert!(worst <= 1e-9);
let (again, count2, _) = repaired.repair_min_thickness(&top, min_t).unwrap();
proptest::prop_assert_eq!(count2, 0);
for jp in 0..3 {
for ip in 0..3 {
proptest::prop_assert!((again.z(ip, jp) - repaired.z(ip, jp)).abs() < 1e-9);
}
}
}
}
#[test]
fn repair_min_thickness_rejects_nonfinite_min() {
let top = Surface::constant(3, 3, 5000.0);
let base = top.offset_by(20.0);
assert!(base.repair_min_thickness(&top, f64::NAN).is_err());
assert!(base.repair_min_thickness(&top, -1.0).is_err());
}
}