use crate::error::StaticError;
use crate::grid::{CornerPointGeom, Dims, Grid, Pillar, Point3};
use crate::gridder::surface::Surface;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default)]
pub struct LayerScratch {
raw: Vec<f64>,
snap: Vec<f64>,
}
impl LayerScratch {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl Clone for LayerScratch {
fn clone(&self) -> Self {
Self::default()
}
}
#[derive(Debug, Clone)]
pub struct StackLayering {
pub dims: Dims,
pub nk: usize,
pub zones: Vec<StackedZone>,
pub truncated_cells: usize,
pub collapsed_cells: usize,
pub nk_capped: bool,
}
pub const MAX_NK: usize = 200;
const TRUNC_EPS: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Conformity {
Proportional,
FollowTop { dz_m: f64 },
FollowBase { dz_m: f64 },
}
impl Conformity {
fn dz_m(self) -> Option<f64> {
match self {
Conformity::Proportional => None,
Conformity::FollowTop { dz_m } | Conformity::FollowBase { dz_m } => Some(dz_m),
}
}
}
#[derive(Debug, Clone)]
pub struct LayeredGrid {
pub grid: Grid,
pub nk: usize,
pub truncated_cells: usize,
pub nk_capped: bool,
}
fn boundary_depth(conf: Conformity, zt: f64, zb: f64, level: usize, nk: usize) -> f64 {
let (lo, hi) = (zt.min(zb), zt.max(zb));
let z = match conf {
Conformity::Proportional => zt + (level as f64 / nk as f64) * (zb - zt),
Conformity::FollowBase { dz_m } => zb - (nk - level) as f64 * dz_m,
Conformity::FollowTop { dz_m } => zt + level as f64 * dz_m,
};
z.clamp(lo, hi)
}
fn derive_nk(
top: &Surface,
base: &Surface,
conformity: Conformity,
requested_nk: usize,
) -> (usize, bool) {
let Some(dz) = conformity.dz_m() else {
return (requested_nk, false);
};
let mut max_t = 0.0_f64;
for jp in 0..top.ny() {
for ip in 0..top.nx() {
max_t = max_t.max((base.z(ip, jp) - top.z(ip, jp)).abs());
}
}
let n = (max_t / dz).ceil() as usize;
let n = n.max(1);
if n > MAX_NK {
(MAX_NK, true)
} else {
(n, false)
}
}
pub fn layer_grid(
top: &Surface,
base: &Surface,
dx: f64,
dy: f64,
nk: usize,
conformity: Conformity,
) -> Result<LayeredGrid, StaticError> {
let stacked = layer_grid_stack(
&[top, base],
dx,
dy,
&[ZoneLayerSpec {
conformity,
requested_nk: nk,
}],
None,
)?;
Ok(LayeredGrid {
grid: stacked.grid,
nk: stacked.nk,
truncated_cells: stacked.truncated_cells,
nk_capped: stacked.nk_capped,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ZoneLayerSpec {
pub conformity: Conformity,
pub requested_nk: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StackedZone {
pub nk: usize,
pub k_start: usize,
pub conformity: Conformity,
pub truncated_cells: usize,
pub collapsed_cells: usize,
}
impl StackedZone {
#[must_use]
pub fn k_range(&self) -> core::ops::Range<usize> {
self.k_start..self.k_start + self.nk
}
}
#[derive(Debug, Clone)]
pub struct StackedLayeredGrid {
pub grid: Grid,
pub nk: usize,
pub zones: Vec<StackedZone>,
pub truncated_cells: usize,
pub collapsed_cells: usize,
pub nk_capped: bool,
}
pub fn layer_grid_stack(
surfaces: &[&Surface],
dx: f64,
dy: f64,
zone_specs: &[ZoneLayerSpec],
collapse_below_m: Option<f64>,
) -> Result<StackedLayeredGrid, StaticError> {
let mut scratch = LayerScratch::new();
let mut coord = Vec::new();
let mut zcorn = Vec::new();
let lay = layer_grid_stack_into(
surfaces,
dx,
dy,
zone_specs,
collapse_below_m,
&mut scratch,
&mut coord,
&mut zcorn,
)?;
Ok(StackedLayeredGrid {
grid: Grid::new(CornerPointGeom::new(lay.dims, coord, zcorn)),
nk: lay.nk,
zones: lay.zones,
truncated_cells: lay.truncated_cells,
collapsed_cells: lay.collapsed_cells,
nk_capped: lay.nk_capped,
})
}
#[allow(clippy::too_many_arguments)]
pub fn layer_grid_stack_into(
surfaces: &[&Surface],
dx: f64,
dy: f64,
zone_specs: &[ZoneLayerSpec],
collapse_below_m: Option<f64>,
scratch: &mut LayerScratch,
coord: &mut Vec<Pillar>,
zcorn: &mut Vec<f64>,
) -> Result<StackLayering, StaticError> {
if surfaces.len() < 2 {
return Err(StaticError::InvalidInput(format!(
"a horizon stack needs at least 2 surfaces, got {}",
surfaces.len()
)));
}
if zone_specs.len() != surfaces.len() - 1 {
return Err(StaticError::InvalidInput(format!(
"expected {} zone specs for {} surfaces, got {}",
surfaces.len() - 1,
surfaces.len(),
zone_specs.len()
)));
}
let nx = surfaces[0].nx();
let ny = surfaces[0].ny();
for s in surfaces {
if s.nx() != nx || s.ny() != ny {
return Err(StaticError::InvalidInput(
"all stack surfaces must share one lattice".into(),
));
}
}
require_finite_surfaces(surfaces)?;
for spec in zone_specs {
if let Some(dz) = spec.conformity.dz_m() {
if !(dz.is_finite() && dz > 0.0) {
return Err(StaticError::InvalidInput(format!(
"conformity dz_m must be finite and > 0, got {dz}"
)));
}
} else if spec.requested_nk == 0 {
return Err(StaticError::InvalidInput("nk must be >= 1".into()));
}
}
if !(dx.is_finite() && dx > 0.0 && dy.is_finite() && dy > 0.0) {
return Err(StaticError::InvalidInput(format!(
"dx, dy must be finite and > 0, got {dx}, {dy}"
)));
}
if zone_specs.len() > MAX_NK {
return Err(StaticError::InvalidInput(format!(
"{} zones exceed the {MAX_NK}-layer total cap (a zone needs >= 1 layer)",
zone_specs.len()
)));
}
if let Some(t) = collapse_below_m {
if !(t.is_finite() && t > 0.0) {
return Err(StaticError::InvalidInput(format!(
"collapse_below_m must be finite and > 0, got {t}"
)));
}
}
let mut zone_capped = false;
let per_zone: Vec<usize> = zone_specs
.iter()
.zip(surfaces.windows(2))
.map(|(spec, pair)| {
let (n, capped) = derive_nk(pair[0], pair[1], spec.conformity, spec.requested_nk);
zone_capped |= capped;
n
})
.collect();
let (per_zone, total_capped) = cap_total_nk(&per_zone);
let nk_capped = zone_capped || total_capped;
let total_nk: usize = per_zone.iter().sum();
let mut k_start = Vec::with_capacity(per_zone.len());
let mut zone_of_k = Vec::with_capacity(total_nk);
let mut acc = 0usize;
for (z, &n) in per_zone.iter().enumerate() {
k_start.push(acc);
for _ in 0..n {
zone_of_k.push(z);
}
acc += n;
}
let dims = Dims::new(nx - 1, ny - 1, total_nk)?;
let ncorner = total_nk + 1;
let np = nx * ny;
let mut raw = std::mem::take(&mut scratch.raw);
let mut snap = std::mem::take(&mut scratch.snap);
let mut coord_buf = std::mem::take(coord);
let mut zcorn_buf = std::mem::take(zcorn);
size_scratch(&mut raw, np * ncorner);
size_scratch(&mut snap, np * ncorner);
for jp in 0..ny {
for ip in 0..nx {
let base = (jp * nx + ip) * ncorner;
for (z, &znk) in per_zone.iter().enumerate() {
let (zt, zb) = (surfaces[z].z(ip, jp), surfaces[z + 1].z(ip, jp));
for local in 0..=znk {
raw[base + k_start[z] + local] =
boundary_depth(zone_specs[z].conformity, zt, zb, local, znk);
}
}
let col = &mut snap[base..base + ncorner];
col.copy_from_slice(&raw[base..base + ncorner]);
if let Some(threshold) = collapse_below_m {
for (z, &znk) in per_zone.iter().enumerate() {
collapse_zone(col, k_start[z], k_start[z] + znk, threshold);
}
}
}
}
let base = surfaces[surfaces.len() - 1];
coord_buf.clear();
coord_buf.reserve(dims.pillar_count());
for jp in 0..ny {
for ip in 0..nx {
let x = ip as f64 * dx;
let y = jp as f64 * dy;
coord_buf.push(Pillar {
top: Point3::new(x, y, surfaces[0].z(ip, jp)),
bottom: Point3::new(x, y, base.z(ip, jp)),
});
}
}
let corner_z = |arr: &[f64], ip: usize, jp: usize, level: usize| -> f64 {
arr[(jp * nx + ip) * ncorner + level]
};
zcorn_buf.clear();
zcorn_buf.reserve(dims.cell_count() * 8);
let mut trunc_by_zone = vec![0usize; per_zone.len()];
let mut collapse_by_zone = vec![0usize; per_zone.len()];
for c in dims.iter() {
let z = zone_of_k[c.k];
let mut corners = [0.0_f64; 8];
for (corner, slot) in corners.iter_mut().enumerate() {
let di = corner & 1;
let dj = (corner >> 1) & 1;
let dk = (corner >> 2) & 1;
*slot = corner_z(snap.as_slice(), c.i + di, c.j + dj, c.k + dk);
}
let max_dz = (0..4)
.map(|p| corners[4 + p] - corners[p])
.fold(0.0_f64, f64::max);
let max_dz_raw = (0..4)
.map(|p| {
let di = p & 1;
let dj = (p >> 1) & 1;
corner_z(raw.as_slice(), c.i + di, c.j + dj, c.k + 1)
- corner_z(raw.as_slice(), c.i + di, c.j + dj, c.k)
})
.fold(0.0_f64, f64::max);
if max_dz_raw <= TRUNC_EPS {
trunc_by_zone[z] += 1; } else if max_dz <= TRUNC_EPS {
collapse_by_zone[z] += 1; }
zcorn_buf.extend_from_slice(&corners);
}
scratch.raw = raw;
scratch.snap = snap;
*coord = coord_buf;
*zcorn = zcorn_buf;
let truncated_cells: usize = trunc_by_zone.iter().sum();
let zones = per_zone
.iter()
.enumerate()
.map(|(z, &nk)| StackedZone {
nk,
k_start: k_start[z],
conformity: zone_specs[z].conformity,
truncated_cells: trunc_by_zone[z],
collapsed_cells: collapse_by_zone[z],
})
.collect();
Ok(StackLayering {
dims,
nk: total_nk,
zones,
truncated_cells,
collapsed_cells: collapse_by_zone.iter().sum(),
nk_capped,
})
}
#[derive(Debug, Clone)]
pub struct StreamingLayering {
dims: Dims,
nk: usize,
nk_capped: bool,
surfaces: Vec<Surface>,
conformities: Vec<Conformity>,
per_zone: Vec<usize>,
k_start: Vec<usize>,
zone_of_k: Vec<usize>,
nx: usize,
ny: usize,
dx: f64,
dy: f64,
}
impl StreamingLayering {
pub fn prepare(
surfaces: &[&Surface],
dx: f64,
dy: f64,
zone_specs: &[ZoneLayerSpec],
) -> Result<Self, StaticError> {
if surfaces.len() < 2 {
return Err(StaticError::InvalidInput(format!(
"a horizon stack needs at least 2 surfaces, got {}",
surfaces.len()
)));
}
if zone_specs.len() != surfaces.len() - 1 {
return Err(StaticError::InvalidInput(format!(
"expected {} zone specs for {} surfaces, got {}",
surfaces.len() - 1,
surfaces.len(),
zone_specs.len()
)));
}
let nx = surfaces[0].nx();
let ny = surfaces[0].ny();
for s in surfaces {
if s.nx() != nx || s.ny() != ny {
return Err(StaticError::InvalidInput(
"all stack surfaces must share one lattice".into(),
));
}
}
require_finite_surfaces(surfaces)?;
for spec in zone_specs {
if let Some(dz) = spec.conformity.dz_m() {
if !(dz.is_finite() && dz > 0.0) {
return Err(StaticError::InvalidInput(format!(
"conformity dz_m must be finite and > 0, got {dz}"
)));
}
} else if spec.requested_nk == 0 {
return Err(StaticError::InvalidInput("nk must be >= 1".into()));
}
}
if !(dx.is_finite() && dx > 0.0 && dy.is_finite() && dy > 0.0) {
return Err(StaticError::InvalidInput(format!(
"dx, dy must be finite and > 0, got {dx}, {dy}"
)));
}
if zone_specs.len() > MAX_NK {
return Err(StaticError::InvalidInput(format!(
"{} zones exceed the {MAX_NK}-layer total cap (a zone needs >= 1 layer)",
zone_specs.len()
)));
}
let mut zone_capped = false;
let per_zone: Vec<usize> = zone_specs
.iter()
.zip(surfaces.windows(2))
.map(|(spec, pair)| {
let (n, capped) = derive_nk(pair[0], pair[1], spec.conformity, spec.requested_nk);
zone_capped |= capped;
n
})
.collect();
let (per_zone, total_capped) = cap_total_nk(&per_zone);
let nk_capped = zone_capped || total_capped;
let total_nk: usize = per_zone.iter().sum();
let mut k_start = Vec::with_capacity(per_zone.len());
let mut zone_of_k = Vec::with_capacity(total_nk);
let mut acc = 0usize;
for (z, &n) in per_zone.iter().enumerate() {
k_start.push(acc);
for _ in 0..n {
zone_of_k.push(z);
}
acc += n;
}
let dims = Dims::new(nx - 1, ny - 1, total_nk)?;
Ok(Self {
dims,
nk: total_nk,
nk_capped,
surfaces: surfaces.iter().map(|s| (*s).clone()).collect(),
conformities: zone_specs.iter().map(|s| s.conformity).collect(),
per_zone,
k_start,
zone_of_k,
nx,
ny,
dx,
dy,
})
}
#[must_use]
pub fn dims(&self) -> Dims {
self.dims
}
#[must_use]
pub fn nk_capped(&self) -> bool {
self.nk_capped
}
#[must_use]
pub fn zones(&self) -> Vec<StackedZone> {
self.per_zone
.iter()
.enumerate()
.map(|(z, &nk)| StackedZone {
nk,
k_start: self.k_start[z],
conformity: self.conformities[z],
truncated_cells: 0,
collapsed_cells: 0,
})
.collect()
}
pub fn fill_coord(&self, out: &mut Vec<Pillar>) {
out.clear();
out.reserve(self.dims.pillar_count());
let base = &self.surfaces[self.surfaces.len() - 1];
for jp in 0..self.ny {
for ip in 0..self.nx {
out.push(Pillar {
top: Point3::new(
ip as f64 * self.dx,
jp as f64 * self.dy,
self.surfaces[0].z(ip, jp),
),
bottom: Point3::new(ip as f64 * self.dx, jp as f64 * self.dy, base.z(ip, jp)),
});
}
}
}
#[inline]
fn level_depth(&self, ip: usize, jp: usize, level: usize) -> f64 {
let (z, local) = if level >= self.nk {
let z = self.per_zone.len() - 1;
(z, self.per_zone[z])
} else {
let z = self.zone_of_k[level];
(z, level - self.k_start[z])
};
boundary_depth(
self.conformities[z],
self.surfaces[z].z(ip, jp),
self.surfaces[z + 1].z(ip, jp),
local,
self.per_zone[z],
)
}
fn fill_plane(&self, level: usize, plane: &mut [f64]) {
for jp in 0..self.ny {
for ip in 0..self.nx {
plane[jp * self.nx + ip] = self.level_depth(ip, jp, level);
}
}
}
pub fn fill_zcorn_slab(
&self,
k: usize,
plane_top: &mut [f64],
plane_bot: &mut [f64],
out: &mut [f32],
) -> usize {
self.fill_plane(k, plane_top);
self.fill_plane(k + 1, plane_bot);
let (ni, nj) = (self.dims.ni, self.dims.nj);
let mut truncated = 0usize;
for j in 0..nj {
for i in 0..ni {
let local = j * ni + i;
let base = local * 8;
let mut max_dz = 0.0f64;
for corner in 0..8 {
let di = corner & 1;
let dj = (corner >> 1) & 1;
let dk = (corner >> 2) & 1;
let plane: &[f64] = if dk == 0 { plane_top } else { plane_bot };
out[base + corner] = plane[(j + dj) * self.nx + (i + di)] as f32;
}
for p in 0..4 {
let di = p & 1;
let dj = (p >> 1) & 1;
let idx = (j + dj) * self.nx + (i + di);
max_dz = max_dz.max(plane_bot[idx] - plane_top[idx]);
}
if max_dz <= TRUNC_EPS {
truncated += 1;
}
}
}
truncated
}
}
fn require_finite_surfaces(surfaces: &[&Surface]) -> Result<(), StaticError> {
for (s_idx, s) in surfaces.iter().enumerate() {
for jp in 0..s.ny() {
for ip in 0..s.nx() {
let z = s.z(ip, jp);
if !z.is_finite() {
return Err(StaticError::InvalidInput(format!(
"surface {s_idx} has a non-finite depth {z} at node ({ip}, {jp}); \
layering needs finite top/base surfaces"
)));
}
}
}
}
Ok(())
}
fn size_scratch(v: &mut Vec<f64>, n: usize) {
if v.len() < n {
v.resize(n, 0.0);
} else {
v.truncate(n);
}
}
fn collapse_zone(col: &mut [f64], lo: usize, hi: usize, threshold: f64) {
if hi <= lo + 1 {
return; }
let (top, base) = (col[lo], col[hi]);
let total = base - top;
if total > TRUNC_EPS && total < threshold {
let mut survivor = lo;
let mut thickest = col[lo + 1] - col[lo];
for k in (lo + 1)..hi {
let t = col[k + 1] - col[k];
if t > thickest {
thickest = t;
survivor = k;
}
}
for c in col.iter_mut().take(survivor + 1).skip(lo + 1) {
*c = top;
}
for c in col.iter_mut().take(hi).skip(survivor + 1) {
*c = base;
}
return;
}
loop {
let mut target: Option<usize> = None;
let mut thinnest = f64::INFINITY;
for k in lo..hi {
let t = col[k + 1] - col[k];
if t > TRUNC_EPS && t < threshold && t < thinnest {
thinnest = t;
target = Some(k);
}
}
let Some(k) = target else { break };
let left = (lo..k).rev().find(|&l| col[l + 1] - col[l] > TRUNC_EPS);
let right = ((k + 1)..hi).find(|&r| col[r + 1] - col[r] > TRUNC_EPS);
let merge_left = match (left, right) {
(Some(l), Some(r)) => (col[l + 1] - col[l]) >= (col[r + 1] - col[r]),
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => break, };
if merge_left {
let l = left.unwrap();
let anchor = col[k + 1];
for c in col.iter_mut().take(k + 1).skip(l + 1) {
*c = anchor;
}
} else {
let r = right.unwrap();
let anchor = col[k];
for c in col.iter_mut().take(r + 1).skip(k + 1) {
*c = anchor;
}
}
}
}
fn cap_total_nk(counts: &[usize]) -> (Vec<usize>, bool) {
let total: usize = counts.iter().sum();
if total <= MAX_NK {
return (counts.to_vec(), false);
}
let scale = MAX_NK as f64 / total as f64;
let mut scaled: Vec<usize> = counts
.iter()
.map(|&n| ((n as f64 * scale).floor() as usize).max(1))
.collect();
let mut over = scaled.iter().sum::<usize>().saturating_sub(MAX_NK);
while over > 0 {
let Some(idx) = scaled
.iter()
.enumerate()
.filter(|(_, &n)| n > 1)
.max_by_key(|(_, &n)| n)
.map(|(i, _)| i)
else {
break; };
scaled[idx] -= 1;
over -= 1;
}
(scaled, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gridder::surface::{solve_surface, Control, SolveOpts};
fn flat(nx: usize, ny: usize, z: f64) -> Surface {
let c: Vec<Control> = (0..nx)
.flat_map(|ip| (0..ny).map(move |jp| Control { ip, jp, z }))
.collect();
solve_surface(nx, ny, &c, SolveOpts::default()).unwrap()
}
#[test]
fn flat_surfaces_proportional_recover_a_box() {
let top = flat(11, 11, 5000.0);
let base = flat(11, 11, 5050.0);
let lg = layer_grid(&top, &base, 100.0, 80.0, 5, Conformity::Proportional).unwrap();
let grid = &lg.grid;
assert_eq!(lg.nk, 5);
assert_eq!(lg.truncated_cells, 0);
assert!(!lg.nk_capped);
assert_eq!(grid.cell_count(), 10 * 10 * 5);
let expected = (10.0 * 100.0) * (10.0 * 80.0) * 50.0;
assert!((grid.bulk_volume() - expected).abs() / expected < 1e-9);
assert!((grid.cell(crate::grid::Ijk::new(0, 0, 0)).dz() - 10.0).abs() < 1e-6);
}
#[test]
fn proportional_layers_split_thickness_evenly() {
let top = flat(6, 6, 4000.0);
let base = flat(6, 6, 4100.0);
let lg = layer_grid(&top, &base, 50.0, 50.0, 4, Conformity::Proportional).unwrap();
for k in 0..4 {
let dz = lg.grid.cell(crate::grid::Ijk::new(0, 0, k)).dz();
assert!((dz - 25.0).abs() < 1e-6, "layer {k} dz={dz}");
}
}
fn wedge(nx: usize) -> (Surface, Surface) {
let top = flat(nx, nx, 5000.0);
let ctrl: Vec<Control> = (0..nx)
.flat_map(|ip| {
(0..nx).map(move |jp| Control {
ip,
jp,
z: 5000.0 + (ip as f64 + 1.0) * 10.0,
})
})
.collect();
let base = solve_surface(
nx,
nx,
&ctrl,
SolveOpts {
tol: 1e-9,
max_iter: 60_000,
..SolveOpts::default()
},
)
.unwrap();
(top, base)
}
#[test]
fn follow_top_derives_nk_and_layers_parallel_to_the_top() {
let (top, base) = wedge(11);
let lg = layer_grid(
&top,
&base,
10.0,
10.0,
999,
Conformity::FollowTop { dz_m: 10.0 },
)
.unwrap();
assert_eq!(lg.nk, 11, "nk derived from max thickness / dz");
assert!(!lg.nk_capped);
for k in 0..1 {
for i in [0usize, 5, 9] {
let dz = lg.grid.cell(crate::grid::Ijk::new(i, 0, k)).dz();
assert!((dz - 10.0).abs() < 1e-6, "cell ({i},0,{k}) dz={dz} != 10");
}
}
}
#[test]
fn follow_top_truncation_count_is_exact() {
let (top, base) = wedge(11);
let lg = layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowTop { dz_m: 10.0 },
)
.unwrap();
let nk = lg.nk as i64;
let nj = 10i64;
let expected: i64 = (0..10).map(|i| (nk - (i + 2).min(nk)).max(0)).sum::<i64>() * nj;
assert_eq!(expected, 450, "analytic truncated count");
assert_eq!(lg.truncated_cells as i64, expected);
}
#[test]
fn volume_conserved_across_conformity_styles() {
let (top, base) = wedge(11);
let prop = layer_grid(&top, &base, 10.0, 10.0, 7, Conformity::Proportional)
.unwrap()
.grid
.bulk_volume();
let ftop = layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowTop { dz_m: 10.0 },
)
.unwrap()
.grid
.bulk_volume();
let fbase = layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowBase { dz_m: 10.0 },
)
.unwrap()
.grid
.bulk_volume();
assert!(
(ftop - prop).abs() / prop < 1e-9,
"FollowTop {ftop} != {prop}"
);
assert!(
(fbase - prop).abs() / prop < 1e-9,
"FollowBase {fbase} != {prop}"
);
}
#[test]
fn follow_base_mirrors_follow_top() {
let (top, base) = wedge(11);
let lg = layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowBase { dz_m: 10.0 },
)
.unwrap();
assert_eq!(lg.nk, 11);
let dz = lg.grid.cell(crate::grid::Ijk::new(9, 0, lg.nk - 1)).dz();
assert!((dz - 10.0).abs() < 1e-6, "deepest layer dz={dz} != 10");
assert_eq!(lg.truncated_cells, 450);
}
#[test]
fn follow_top_caps_nk_and_flags_it() {
let top = flat(4, 4, 5000.0);
let base = flat(4, 4, 5000.0 + (MAX_NK as f64 + 50.0)); let lg = layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowTop { dz_m: 1.0 },
)
.unwrap();
assert_eq!(lg.nk, MAX_NK);
assert!(lg.nk_capped, "cap flag set");
}
#[test]
fn rejects_nonpositive_dz() {
let top = flat(4, 4, 5000.0);
let base = flat(4, 4, 5050.0);
assert!(layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowTop { dz_m: 0.0 }
)
.is_err());
assert!(layer_grid(
&top,
&base,
10.0,
10.0,
0,
Conformity::FollowBase { dz_m: f64::NAN }
)
.is_err());
}
#[test]
fn tilted_top_follows_structure() {
let plane = |ip: usize, jp: usize| 5000.0 + 2.0 * ip as f64 + 0.0 * jp as f64;
let ctrl: Vec<Control> = [(0usize, 0usize), (10, 0), (0, 10), (10, 10), (5, 5)]
.iter()
.map(|&(ip, jp)| Control {
ip,
jp,
z: plane(ip, jp),
})
.collect();
let top = solve_surface(
11,
11,
&ctrl,
SolveOpts {
tol: 1e-9,
max_iter: 60_000,
..SolveOpts::default()
},
)
.unwrap();
let base = flat(11, 11, 5100.0);
let grid = layer_grid(&top, &base, 100.0, 100.0, 1, Conformity::Proportional)
.unwrap()
.grid;
let updip = grid.cell(crate::grid::Ijk::new(0, 0, 0)).dz();
let downdip = grid.cell(crate::grid::Ijk::new(9, 0, 0)).dz();
assert!(
updip > downdip,
"updip {updip} should be thicker than downdip {downdip}"
);
}
#[test]
fn follow_top_pinches_out_flat_column() {
let top = flat(4, 4, 5000.0);
let base = flat(4, 4, 5030.0);
let lg = layer_grid(
&top,
&base,
50.0,
50.0,
0,
Conformity::FollowTop { dz_m: 20.0 },
)
.unwrap();
assert_eq!(lg.nk, 2);
let l0 = lg.grid.cell(crate::grid::Ijk::new(0, 0, 0)).dz();
let l1 = lg.grid.cell(crate::grid::Ijk::new(0, 0, 1)).dz();
assert!((l0 - 20.0).abs() < 1e-6, "layer0 {l0}");
assert!((l1 - 10.0).abs() < 1e-6, "layer1 {l1} (clipped at base)");
assert_eq!(lg.truncated_cells, 0);
}
#[test]
fn rejects_mismatched_lattice() {
let top = flat(5, 5, 1.0);
let base = flat(6, 6, 2.0);
assert!(layer_grid(&top, &base, 10.0, 10.0, 2, Conformity::Proportional).is_err());
}
fn prop(nk: usize) -> ZoneLayerSpec {
ZoneLayerSpec {
conformity: Conformity::Proportional,
requested_nk: nk,
}
}
#[test]
fn stack_concatenates_zones_and_partitions_k() {
let s0 = flat(6, 6, 5000.0);
let s1 = flat(6, 6, 5050.0);
let s2 = flat(6, 6, 5090.0);
let sg = layer_grid_stack(&[&s0, &s1, &s2], 50.0, 50.0, &[prop(4), prop(3)], None).unwrap();
assert_eq!(sg.nk, 7, "total nk = 4 + 3");
assert_eq!(sg.zones.len(), 2);
assert_eq!(sg.zones[0].k_range(), 0..4);
assert_eq!(sg.zones[1].k_range(), 4..7);
assert_eq!(sg.truncated_cells, 0);
assert!((sg.grid.cell(crate::grid::Ijk::new(0, 0, 0)).dz() - 12.5).abs() < 1e-6);
assert!((sg.grid.cell(crate::grid::Ijk::new(0, 0, 4)).dz() - 40.0 / 3.0).abs() < 1e-6);
let expected = 250.0 * 250.0 * 90.0;
assert!((sg.grid.bulk_volume() - expected).abs() / expected < 1e-9);
}
#[test]
fn stack_two_surface_matches_single_layer_grid() {
let (top, base) = wedge(11);
let a = layer_grid(&top, &base, 10.0, 10.0, 7, Conformity::Proportional).unwrap();
let b = layer_grid_stack(&[&top, &base], 10.0, 10.0, &[prop(7)], None).unwrap();
assert_eq!(a.nk, b.nk);
assert_eq!(a.truncated_cells, b.truncated_cells);
assert_eq!(a.grid.bulk_volume(), b.grid.bulk_volume());
}
#[test]
fn stack_mixes_conformity_per_zone() {
let s0 = flat(6, 6, 5000.0);
let s1 = flat(6, 6, 5030.0);
let s2 = flat(6, 6, 5090.0); let sg = layer_grid_stack(
&[&s0, &s1, &s2],
50.0,
50.0,
&[
prop(2),
ZoneLayerSpec {
conformity: Conformity::FollowTop { dz_m: 20.0 },
requested_nk: 0,
},
],
None,
)
.unwrap();
assert_eq!(sg.zones[0].nk, 2);
assert_eq!(sg.zones[1].nk, 3, "60 m / 20 m dz");
assert_eq!(sg.nk, 5);
}
#[test]
fn stack_total_cap_scales_zones_down() {
let s0 = flat(4, 4, 0.0);
let s1 = flat(4, 4, 100.0);
let s2 = flat(4, 4, 200.0);
let sg =
layer_grid_stack(&[&s0, &s1, &s2], 10.0, 10.0, &[prop(150), prop(150)], None).unwrap();
assert!(sg.nk_capped);
assert!(sg.nk <= MAX_NK, "total capped to <= MAX_NK, got {}", sg.nk);
assert!(sg.zones.iter().all(|z| z.nk >= 1));
}
#[test]
fn stack_rejects_bad_arity() {
let s0 = flat(4, 4, 0.0);
let s1 = flat(4, 4, 10.0);
assert!(layer_grid_stack(&[&s0], 10.0, 10.0, &[], None).is_err()); assert!(layer_grid_stack(&[&s0, &s1], 10.0, 10.0, &[prop(2), prop(2)], None).is_err());
}
#[test]
fn collapse_conserves_volume_and_stays_within_zones() {
let s0 = flat(4, 4, 0.0);
let s1 = flat(4, 4, 30.0);
let s2 = flat(4, 4, 70.0);
let no = layer_grid_stack(&[&s0, &s1, &s2], 10.0, 10.0, &[prop(6), prop(4)], None).unwrap();
let yes =
layer_grid_stack(&[&s0, &s1, &s2], 10.0, 10.0, &[prop(6), prop(4)], Some(8.0)).unwrap();
let (vn, vy) = (no.grid.bulk_volume(), yes.grid.bulk_volume());
assert!(
(vn - vy).abs() / vn < 1e-12,
"collapse conserved volume: {vn} vs {vy}"
);
let ncell_zone = 3 * 3; assert!(yes.zones[0].collapsed_cells > 0);
assert_eq!(
yes.zones[1].collapsed_cells, 0,
"zone1 layers exceed threshold"
);
for k in yes.zones[1].k_range() {
for j in 0..3 {
for i in 0..3 {
let dz = yes.grid.cell(crate::grid::Ijk::new(i, j, k)).dz();
assert!((dz - 10.0).abs() < 1e-9, "zone1 cell dz {dz} != 10");
}
}
}
assert_eq!(yes.zones[0].collapsed_cells % ncell_zone, 0);
}
#[test]
fn collapse_subthreshold_zone_terminates_and_conserves() {
use std::sync::mpsc;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut col = [0.0f64, 0.3, 0.4];
collapse_zone(&mut col, 0, 2, 0.5);
tx.send(col).ok();
});
let col = rx
.recv_timeout(Duration::from_secs(5))
.expect("collapse_zone must terminate on a sub-threshold zone column");
let total = col[2] - col[0];
assert!(
(total - 0.4).abs() < 1e-12,
"total thickness {total} != 0.4"
);
let l0 = col[1] - col[0];
let l1 = col[2] - col[1];
assert!(l0 >= 0.0 && l1 >= 0.0, "no negative layer: {l0}, {l1}");
assert!(
((l0 - 0.4).abs() < 1e-12 && l1 < TRUNC_EPS)
|| ((l1 - 0.4).abs() < 1e-12 && l0 < TRUNC_EPS),
"one layer holds the full thickness: {l0}, {l1}"
);
}
#[test]
fn collapse_subthreshold_zone_multilayer_terminates() {
use std::sync::mpsc;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let nk = 30usize;
let mut col: Vec<f64> = (0..=nk).map(|k| 0.4 * k as f64 / nk as f64).collect();
collapse_zone(&mut col, 0, nk, 0.5);
tx.send(col).ok();
});
let col = rx
.recv_timeout(Duration::from_secs(5))
.expect("multi-layer sub-threshold collapse must terminate");
let total = col[col.len() - 1] - col[0];
assert!((total - 0.4).abs() < 1e-12, "total {total} != 0.4");
let nonzero = col.windows(2).filter(|w| w[1] - w[0] > TRUNC_EPS).count();
assert_eq!(nonzero, 1, "degenerate zone snaps to a single layer");
}
#[test]
fn collapse_single_layer_zone_is_untouched() {
let s0 = flat(4, 4, 0.0);
let s1 = flat(4, 4, 2.0); let sg = layer_grid_stack(&[&s0, &s1], 10.0, 10.0, &[prop(1)], Some(5.0)).unwrap();
assert_eq!(sg.collapsed_cells, 0, "single-layer zone cannot collapse");
assert_eq!(sg.nk, 1);
}
#[test]
fn nan_surface_is_a_typed_error_not_a_silent_nan_grid() {
let top = flat(6, 6, 5000.0);
let base = top.offset_by(f64::NAN);
let err = layer_grid(&top, &base, 50.0, 50.0, 4, Conformity::Proportional)
.expect_err("a non-finite surface must be a typed error");
assert!(
matches!(err, StaticError::InvalidInput(ref m) if m.contains("non-finite")),
"message must name the non-finite surface, got: {err}"
);
}
#[test]
fn nan_surface_in_stack_names_the_offending_surface() {
let s0 = flat(4, 4, 5000.0);
let s1 = flat(4, 4, 5050.0).offset_by(f64::NAN);
let s2 = flat(4, 4, 5090.0);
let err = layer_grid_stack(&[&s0, &s1, &s2], 50.0, 50.0, &[prop(2), prop(2)], None)
.expect_err("a non-finite stack surface must be a typed error");
assert!(
matches!(err, StaticError::InvalidInput(ref m) if m.contains("surface 1") && m.contains("non-finite")),
"message must name surface 1, got: {err}"
);
}
fn bounded<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
use std::sync::mpsc;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(Duration::from_secs(secs))
.expect("kernel must terminate within the hard timeout (livelock guard)")
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(200))]
#[test]
fn prop_collapse_zone_terminates_and_conserves(
gaps in proptest::collection::vec(0.0f64..1.5, 1..=10),
threshold in 0.05f64..1.2,
) {
let n = gaps.len();
let mut col = Vec::with_capacity(n + 1);
let mut acc = 4000.0f64;
col.push(acc);
for g in &gaps {
acc += *g;
col.push(acc);
}
let total_before = col[n] - col[0];
let (top, base) = (col[0], col[n]);
let out = bounded(5, move || {
let mut c = col;
collapse_zone(&mut c, 0, n, threshold);
c
});
proptest::prop_assert!((out[0] - top).abs() < 1e-9);
proptest::prop_assert!((out[n] - base).abs() < 1e-9);
proptest::prop_assert!((( out[n] - out[0]) - total_before).abs() < 1e-9);
for k in 0..n {
proptest::prop_assert!(
out[k + 1] - out[k] >= -1e-9,
"inverted layer {k}: {} -> {}", out[k], out[k + 1]
);
}
}
#[test]
fn prop_layer_grid_degenerate_offsets_are_finite_or_typed(
offset in -80.0f64..250.0,
nk in 1usize..8,
follow in proptest::bool::ANY,
dz in 0.5f64..40.0,
) {
let conf = if follow { Conformity::FollowTop { dz_m: dz } } else { Conformity::Proportional };
let res = bounded(5, move || {
let top = flat(5, 5, 5000.0);
let base = top.offset_by(offset);
layer_grid(&top, &base, 50.0, 50.0, nk, conf).map(|lg| lg.grid.bulk_volume())
});
if let Ok(v) = res {
proptest::prop_assert!(v.is_finite() && v >= -1e-6, "bulk volume {v}");
}
}
}
#[test]
fn single_cell_single_layer_grid_is_built() {
let top = flat(2, 2, 5000.0);
let base = flat(2, 2, 5020.0);
let lg = layer_grid(&top, &base, 25.0, 25.0, 1, Conformity::Proportional).unwrap();
assert_eq!(lg.grid.cell_count(), 1);
assert_eq!(lg.nk, 1);
assert!(lg.grid.bulk_volume() > 0.0);
}
}