use super::frame::GridFrame;
use super::SCHEMA_VERSION;
use crate::error::StaticError;
use crate::grid::{Cell, Ijk};
use crate::model::model::StaticModel;
use crate::model::pipeline::areal_lattice;
use petektools::Lattice;
use serde::{Deserialize, Serialize};
const LAYER_ACTIVE_EPS_M: f64 = 1e-9;
#[derive(Debug, Clone, PartialEq)]
pub enum SectionSpec {
Polyline(Vec<[f64; 2]>),
AlongBore { trajectory: Vec<[f64; 3]> },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionColumn {
pub distance_m: f64,
pub i: usize,
pub j: usize,
pub x: f64,
pub y: f64,
pub layer_tops: Vec<f64>,
pub layer_bases: Vec<f64>,
pub layer_tops_l: Vec<f64>,
pub layer_tops_r: Vec<f64>,
pub layer_bases_l: Vec<f64>,
pub layer_bases_r: Vec<f64>,
pub values: Vec<f64>,
#[serde(default)]
pub zone_ids: Vec<u16>,
pub path_z: Option<f64>,
}
impl SectionColumn {
pub const NO_ZONE: u16 = u16::MAX;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionZone {
pub name: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionContact {
pub kind: String,
pub depth_m: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HorizonTrace {
pub name: String,
pub depths: Vec<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntersectionBundle {
pub schema_version: u32,
pub inputs_ref: String,
pub sugar_cube: bool,
pub property: Option<String>,
pub top_name: String,
pub base_name: String,
pub columns: Vec<SectionColumn>,
pub horizon_traces: Vec<HorizonTrace>,
#[serde(default)]
pub zones: Vec<SectionZone>,
pub contacts: Vec<SectionContact>,
}
impl IntersectionBundle {
pub fn write_json<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
super::wire::write_json(self, w)
}
}
struct Vert {
x: f64,
y: f64,
z: Option<f64>,
}
impl SectionSpec {
fn verts(&self) -> Vec<Vert> {
match self {
SectionSpec::Polyline(pts) => pts
.iter()
.map(|p| Vert {
x: p[0],
y: p[1],
z: None,
})
.collect(),
SectionSpec::AlongBore { trajectory } => trajectory
.iter()
.map(|p| Vert {
x: p[0],
y: p[1],
z: Some(p[2]),
})
.collect(),
}
}
}
impl StaticModel {
pub fn intersection_bundle(
&self,
spec: &SectionSpec,
property: Option<&str>,
) -> Result<IntersectionBundle, StaticError> {
let grid = self.view_grid()?;
let frame = GridFrame::of_grid(&grid, self.georef())?;
let w2l = WorldToLattice::new(&frame, &areal_lattice(&grid)?);
let dims = grid.dims();
let (ni, nj, nk) = (dims.ni, dims.nj, dims.nk);
let sugar_cube = self.provenance().sugar_cube;
let verts = spec.verts();
if verts.len() < 2 {
return Err(StaticError::InvalidInput(
"intersection_bundle: a section trace needs at least two vertices".into(),
));
}
let prop = property
.map(|name| {
grid.properties().get(name).ok_or_else(|| {
StaticError::InvalidInput(format!("intersection_bundle: no property '{name}'"))
})
})
.transpose()?;
let interior: Vec<(String, usize)> = self
.zones()
.zones()
.iter()
.skip(1)
.map(|z| (z.top_horizon.clone(), z.k_range.start))
.collect();
let mut trace_depths: Vec<Vec<f64>> = vec![Vec::new(); interior.len()];
let section_zones: Vec<SectionZone> = self
.zones()
.zones()
.iter()
.map(|z| SectionZone {
name: z.name.clone(),
color: z.color.clone(),
})
.collect();
let mut zone_of_k = vec![SectionColumn::NO_ZONE; nk];
for (zi, z) in self.zones().zones().iter().enumerate() {
let id = u16::try_from(zi).unwrap_or(SectionColumn::NO_ZONE);
let lo = z.k_range.start.min(nk);
let hi = z.k_range.end.min(nk);
for slot in &mut zone_of_k[lo..hi] {
*slot = id;
}
}
let step = 0.5 * frame.spacing_x.min(frame.spacing_y);
let mut columns: Vec<SectionColumn> = Vec::new();
let mut last_cell: Option<(usize, usize)> = None;
let mut cum = 0.0f64;
for w in verts.windows(2) {
let (a, b) = (&w[0], &w[1]);
let seg = (b.x - a.x).hypot(b.y - a.y);
let n_steps = (seg / step).ceil().max(1.0) as usize;
for s in 0..=n_steps {
let t = s as f64 / n_steps as f64;
let (x, y) = (a.x + t * (b.x - a.x), a.y + t * (b.y - a.y));
let d = cum + t * seg;
let Some((fi, fj)) = frame_xy_to_ij(&frame, x, y) else {
continue;
};
let (ri, rj) = (fi.round(), fj.round());
if ri < 0.0 || rj < 0.0 {
continue;
}
let (i, j) = (ri as usize, rj as usize);
if i >= ni || j >= nj || last_cell == Some((i, j)) {
continue;
}
last_cell = Some((i, j));
let path_z = match (a.z, b.z) {
(Some(za), Some(zb)) => Some(za + t * (zb - za)),
_ => None,
};
let mut layer_tops = Vec::with_capacity(nk);
let mut layer_bases = Vec::with_capacity(nk);
let mut layer_tops_l = Vec::with_capacity(nk);
let mut layer_tops_r = Vec::with_capacity(nk);
let mut layer_bases_l = Vec::with_capacity(nk);
let mut layer_bases_r = Vec::with_capacity(nk);
let mut values = Vec::new();
let mut zone_ids = Vec::with_capacity(nk);
let (segdx, segdy) = (b.x - a.x, b.y - a.y);
let (lpx, lpy) = w2l.point(x, y);
let (ldx, ldy) = w2l.dir(segdx, segdy);
for k in 0..nk {
let cell = grid.cell(Ijk::new(i, j, k));
let (top_d, base_d, active) = if cell.dz() <= LAYER_ACTIVE_EPS_M {
(f64::NAN, f64::NAN, false)
} else {
(cell.top_depth(), cell.bottom_depth(), true)
};
layer_tops.push(top_d);
layer_bases.push(base_d);
let (tl, bl, tr, br) = if !active {
(f64::NAN, f64::NAN, f64::NAN, f64::NAN)
} else if sugar_cube {
(top_d, base_d, top_d, base_d)
} else {
fence_edge_depths(&cell, lpx, lpy, ldx, ldy)
};
layer_tops_l.push(tl);
layer_bases_l.push(bl);
layer_tops_r.push(tr);
layer_bases_r.push(br);
if let Some(p) = &prop {
values.push(if active {
p.values[(k * nj + j) * ni + i]
} else {
f64::NAN
});
}
zone_ids.push(if active {
zone_of_k.get(k).copied().unwrap_or(SectionColumn::NO_ZONE)
} else {
SectionColumn::NO_ZONE
});
}
for (t, (_, k_start)) in trace_depths.iter_mut().zip(&interior) {
t.push(grid.cell(Ijk::new(i, j, *k_start)).top_depth());
}
columns.push(SectionColumn {
distance_m: d,
i,
j,
x,
y,
layer_tops,
layer_bases,
layer_tops_l,
layer_tops_r,
layer_bases_l,
layer_bases_r,
values,
zone_ids,
path_z,
});
}
cum += seg;
}
if matches!(spec, SectionSpec::AlongBore { .. }) && !sugar_cube {
retangent_alongbore_edges(&grid, &w2l, nk, &mut columns);
}
let horizon_traces: Vec<HorizonTrace> = interior
.into_iter()
.zip(trace_depths)
.map(|((name, _), depths)| HorizonTrace { name, depths })
.collect();
let contacts = self
.contacts()
.iter()
.map(|c| SectionContact {
kind: format!("{:?}", c.kind).to_uppercase(),
depth_m: c.depth_m,
})
.collect();
let name_for = |role: crate::wireframe::HorizonRole, fallback: &str| -> String {
self.framework()
.horizons
.iter()
.find(|h| h.role == role)
.map_or_else(|| fallback.to_string(), |h| h.name.clone())
};
Ok(IntersectionBundle {
schema_version: SCHEMA_VERSION,
inputs_ref: self.provenance().inputs_ref.clone(),
sugar_cube,
property: property.map(String::from),
top_name: name_for(crate::wireframe::HorizonRole::Top, "TOP"),
base_name: name_for(crate::wireframe::HorizonRole::Base, "BASE"),
columns,
horizon_traces,
zones: section_zones,
contacts,
})
}
}
const TANGENT_EPS_M: f64 = 1e-9;
fn retangent_alongbore_edges(
grid: &crate::grid::Grid,
w2l: &WorldToLattice,
nk: usize,
columns: &mut [SectionColumn],
) {
let n = columns.len();
if n < 2 {
return; }
let (ax, ay) = (
columns[n - 1].x - columns[0].x,
columns[n - 1].y - columns[0].y,
);
for c in 0..n {
let (px, py) = (columns[c].x, columns[c].y);
let (i, j) = (columns[c].i, columns[c].j);
let prev = &columns[c.saturating_sub(1)];
let next = &columns[(c + 1).min(n - 1)];
let (mut dx, mut dy) = (next.x - prev.x, next.y - prev.y);
if dx.hypot(dy) < TANGENT_EPS_M {
(dx, dy) = (ax, ay); }
if dx.hypot(dy) < TANGENT_EPS_M {
continue; }
let (lpx, lpy) = w2l.point(px, py);
let (ldx, ldy) = w2l.dir(dx, dy);
for k in 0..nk {
if !columns[c].layer_tops[k].is_finite() {
continue; }
let cell = grid.cell(Ijk::new(i, j, k));
let (tl, bl, tr, br) = fence_edge_depths(&cell, lpx, lpy, ldx, ldy);
columns[c].layer_tops_l[k] = tl;
columns[c].layer_bases_l[k] = bl;
columns[c].layer_tops_r[k] = tr;
columns[c].layer_bases_r[k] = br;
}
}
}
fn fence_edge_depths(cell: &Cell, px: f64, py: f64, dx: f64, dy: f64) -> (f64, f64, f64, f64) {
let centroid = || {
let (t, b) = (cell.top_depth(), cell.bottom_depth());
(t, b, t, b)
};
if dx.hypot(dy) < TANGENT_EPS_M {
return centroid();
}
let c = &cell.corners;
let (x0, x1) = (c[0].x, c[1].x);
let (y0, y1) = (c[0].y, c[2].y);
let (mut smin, mut smax) = (f64::NEG_INFINITY, f64::INFINITY);
for &(p, d, lo, hi) in &[
(px, dx, x0.min(x1), x0.max(x1)),
(py, dy, y0.min(y1), y0.max(y1)),
] {
if d.abs() < 1e-12 {
continue; }
let (t0, t1) = ((lo - p) / d, (hi - p) / d);
let (t0, t1) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
smin = smin.max(t0);
smax = smax.min(t1);
}
let interp = |xx: f64, yy: f64, face: &[crate::grid::Point3]| -> f64 {
let frac = |q: f64, a: f64, b: f64| {
let d = b - a;
if d.abs() > 1e-12 {
((q - a) / d).clamp(0.0, 1.0)
} else {
0.5
}
};
let (u, v) = (frac(xx, x0, x1), frac(yy, y0, y1));
(1.0 - u) * (1.0 - v) * face[0].z
+ u * (1.0 - v) * face[1].z
+ (1.0 - u) * v * face[2].z
+ u * v * face[3].z
};
if !(smin.is_finite() && smax.is_finite()) || smin > smax {
debug_assert!(
false,
"fence_edge_depths: line misses cell rectangle — frame bug (not the local \
lattice frame?): p=({px}, {py}) dir=({dx}, {dy}) rect x[{x0}, {x1}] y[{y0}, {y1}]"
);
eprintln!(
"warning: fence_edge_depths clip miss (frame mismatch?) — section edge fell \
back to the centroid: p=({px}, {py}) dir=({dx}, {dy}) cell x[{x0}, {x1}] y[{y0}, {y1}]"
);
return centroid();
}
let (lx, ly) = (px + smin * dx, py + smin * dy);
let (rx, ry) = (px + smax * dx, py + smax * dy);
(
interp(lx, ly, &c[0..4]),
interp(lx, ly, &c[4..8]),
interp(rx, ry, &c[0..4]),
interp(rx, ry, &c[4..8]),
)
}
struct WorldToLattice {
frame_ox: f64,
frame_oy: f64,
lat_ox: f64,
lat_oy: f64,
scale_x: f64,
scale_y: f64,
}
impl WorldToLattice {
fn new(frame: &GridFrame, lat: &Lattice) -> Self {
let scale = |lat_inc: f64, frame_inc: f64| {
if frame_inc != 0.0 {
lat_inc / frame_inc
} else {
1.0
}
};
Self {
frame_ox: frame.origin_x,
frame_oy: frame.origin_y,
lat_ox: lat.xori,
lat_oy: lat.yori,
scale_x: scale(lat.xinc, frame.spacing_x),
scale_y: scale(lat.yinc, frame.spacing_y),
}
}
fn point(&self, x: f64, y: f64) -> (f64, f64) {
(
self.lat_ox + (x - self.frame_ox) * self.scale_x,
self.lat_oy + (y - self.frame_oy) * self.scale_y,
)
}
fn dir(&self, dx: f64, dy: f64) -> (f64, f64) {
(dx * self.scale_x, dy * self.scale_y)
}
}
fn frame_xy_to_ij(frame: &GridFrame, x: f64, y: f64) -> Option<(f64, f64)> {
if frame.spacing_x == 0.0 || frame.spacing_y == 0.0 {
return None;
}
Some((
(x - frame.origin_x) / frame.spacing_x,
(y - frame.origin_y) / frame.spacing_y,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gridder::{Conformity, SolveOpts};
use crate::model::{BuildOpts, ConstantPriors, StaticModelBuilder};
use crate::wireframe::{
Boundary, Contact, ContactKind, GriddedDepth, Hardness, Horizon, HorizonRole, Wireframe,
};
fn opts() -> BuildOpts {
BuildOpts {
area_m2: 100.0,
gross_height_m: 50.0,
nk: 5,
conformity: Conformity::Proportional,
solve_opts: SolveOpts::default(),
priors: ConstantPriors {
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
},
}
}
fn wedge_model(n: usize, thin: f64, thick: f64) -> StaticModel {
StaticModelBuilder::from_wireframe(&wedge_wf(n, thin, thick), opts())
.unwrap()
.build()
.unwrap()
}
fn wedge_wf(n: usize, thin: f64, thick: f64) -> Wireframe {
let top = vec![5000.0; n * n];
let mut base = vec![0.0; n * n];
for r in 0..n {
for c in 0..n {
base[r * n + c] = 5000.0 + thin + (thick - thin) * (c as f64 / (n - 1) as f64);
}
}
Wireframe {
boundary: Boundary {
ring: vec![
[0.0, 0.0],
[10.0, 0.0],
[10.0, 10.0],
[0.0, 10.0],
[0.0, 0.0],
],
hardness: Hardness::Interpolated,
},
horizons: std::sync::Arc::new(vec![
Horizon {
name: "TopRes".into(),
role: HorizonRole::Top,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: top,
is_control: vec![true; n * n],
},
},
Horizon {
name: "BaseRes".into(),
role: HorizonRole::Base,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: base,
is_control: vec![true; n * n],
},
},
]),
contacts: vec![Contact {
kind: ContactKind::Owc,
depth_m: 6000.0,
hardness: Hardness::Hard,
}],
}
}
#[test]
fn section_edge_arrays_follow_dip_bitwise_and_sugar_cube_flattens() {
let spec = SectionSpec::Polyline(vec![[0.5, 0.5], [9.5, 0.5]]);
let m = wedge_model(11, 20.0, 120.0);
let b = m.intersection_bundle(&spec, None).unwrap();
assert!(!b.sugar_cube, "trapezoid mode is the default");
assert_eq!(b.schema_version, SCHEMA_VERSION);
let col = &b.columns[5];
let (i, j, k) = (col.i, col.j, 2usize);
let c = &m.grid().cell(Ijk::new(i, j, k)).corners;
let (top_l, top_r) = (0.5 * c[0].z + 0.5 * c[2].z, 0.5 * c[1].z + 0.5 * c[3].z);
let (base_l, base_r) = (0.5 * c[4].z + 0.5 * c[6].z, 0.5 * c[5].z + 0.5 * c[7].z);
assert_eq!(col.layer_tops_l[k], top_l, "left top edge bit-level");
assert_eq!(col.layer_tops_r[k], top_r, "right top edge bit-level");
assert_eq!(col.layer_bases_l[k], base_l, "left base edge bit-level");
assert_eq!(col.layer_bases_r[k], base_r, "right base edge bit-level");
assert!(
(col.layer_bases_l[k] - col.layer_bases_r[k]).abs() > 1e-3,
"base edge dips across the column"
);
assert!(
(col.layer_tops_l[k] - col.layer_tops_r[k]).abs() > 1e-3,
"interior top edge dips across the column"
);
assert!((col.layer_tops[k] - 0.5 * (top_l + top_r)).abs() < 1e-9);
let flat = wedge_model(11, 50.0, 50.0);
let fb = flat.intersection_bundle(&spec, None).unwrap();
for col in &fb.columns {
for k in 0..col.layer_tops.len() {
if !col.layer_tops[k].is_finite() {
continue;
}
assert_eq!(col.layer_tops_l[k], col.layer_tops[k]);
assert_eq!(col.layer_tops_r[k], col.layer_tops[k]);
assert_eq!(col.layer_bases_l[k], col.layer_bases[k]);
assert_eq!(col.layer_bases_r[k], col.layer_bases[k]);
}
}
let sc = StaticModelBuilder::from_wireframe(&wedge_wf(11, 20.0, 120.0), opts())
.unwrap()
.with_sugar_cube(true)
.build()
.unwrap();
let sb = sc.intersection_bundle(&spec, None).unwrap();
assert!(sb.sugar_cube, "flag flows into the bundle");
for col in &sb.columns {
for k in 0..col.layer_tops.len() {
if !col.layer_tops[k].is_finite() {
continue;
}
assert_eq!(col.layer_tops_l[k], col.layer_tops[k], "sugar: l==centroid");
assert_eq!(col.layer_tops_r[k], col.layer_tops[k], "sugar: r==centroid");
assert_eq!(col.layer_bases_l[k], col.layer_bases[k]);
assert_eq!(col.layer_bases_r[k], col.layer_bases[k]);
}
}
}
#[test]
fn section_columns_are_ordered_and_track_the_wedge() {
let m = wedge_model(11, 20.0, 120.0);
let spec = SectionSpec::Polyline(vec![[0.5, 0.5], [9.5, 0.5]]);
let b = m.intersection_bundle(&spec, Some("PORO")).unwrap();
assert_eq!(b.property.as_deref(), Some("PORO"));
assert_eq!(b.top_name, "TopRes");
assert_eq!(b.base_name, "BaseRes");
assert_eq!(b.columns.len(), 10);
for (n, col) in b.columns.iter().enumerate() {
assert_eq!(col.i, n);
assert_eq!(col.j, 0);
if n > 0 {
assert!(
col.distance_m > b.columns[n - 1].distance_m,
"distance monotone"
);
}
assert_eq!(col.layer_tops.len(), 5);
assert_eq!(col.values.len(), 5);
assert!(col.path_z.is_none());
}
let first = &b.columns[0];
let last = &b.columns[9];
assert!(
(first.layer_tops[0] - 5000.0).abs() < 1e-6,
"flat top updip"
);
assert!(
(last.layer_tops[0] - 5000.0).abs() < 1e-6,
"flat top downdip"
);
let thick_updip = first.layer_bases[4] - first.layer_tops[0];
let thick_downdip = last.layer_bases[4] - last.layer_tops[0];
assert!(
(thick_updip - 24.0).abs() < 6.0,
"updip thickness {thick_updip} ~ 20"
);
assert!(
(thick_downdip - 116.0).abs() < 8.0,
"downdip thickness {thick_downdip} ~ 120"
);
assert!(thick_downdip > thick_updip * 3.0, "wedge thickens downdip");
assert_eq!(b.contacts.len(), 1);
assert_eq!(b.contacts[0].kind, "OWC");
}
#[test]
fn along_bore_overlays_the_path_z() {
let m = wedge_model(11, 20.0, 120.0);
let traj = vec![[0.5, 0.5, 5000.0], [9.5, 0.5, 5100.0]];
let b = m
.intersection_bundle(&SectionSpec::AlongBore { trajectory: traj }, Some("PORO"))
.unwrap();
assert!(
b.columns.iter().all(|c| c.path_z.is_some()),
"bore z overlaid"
);
let z0 = b.columns.first().unwrap().path_z.unwrap();
let z1 = b.columns.last().unwrap().path_z.unwrap();
assert!(z0 < z1, "path descends: {z0} -> {z1}");
assert!((z0 - 5000.0).abs() < 15.0 && (z1 - 5100.0).abs() < 15.0);
}
#[test]
fn along_bore_edges_follow_trace_tangent_and_vertical_convention() {
let m = wedge_model(11, 20.0, 120.0);
let k = 2usize;
let mut kick = vec![[0.5, 0.5, 5000.0], [0.5, 0.5, 5010.0]];
for s in 1..=9 {
kick.push([0.5 + s as f64, 0.5, 5010.0 + s as f64 * 10.0]);
}
let kb = m
.intersection_bundle(&SectionSpec::AlongBore { trajectory: kick }, None)
.unwrap();
let c0 = &kb.columns[0];
assert_eq!(c0.i, 0, "first column is the surface (kickoff) cell");
assert!(
(c0.layer_bases_l[k] - c0.layer_bases_r[k]).abs() > 1e-3,
"kickoff column dips across the trace tangent (was flat pre-fix): \
l={} r={}",
c0.layer_bases_l[k],
c0.layer_bases_r[k]
);
let ab = m
.intersection_bundle(
&SectionSpec::AlongBore {
trajectory: vec![[0.5, 0.5, 5000.0], [9.5, 0.5, 5100.0]],
},
None,
)
.unwrap();
let pl = m
.intersection_bundle(&SectionSpec::Polyline(vec![[0.5, 0.5], [9.5, 0.5]]), None)
.unwrap();
assert_eq!(ab.columns.len(), pl.columns.len());
let mut dipping = 0;
for (a, p) in ab.columns.iter().zip(&pl.columns) {
assert_eq!((a.i, a.j), (p.i, p.j));
for kk in 0..a.layer_tops_l.len() {
if !a.layer_tops_l[kk].is_finite() {
continue;
}
assert_eq!(
a.layer_tops_l[kk], p.layer_tops_l[kk],
"tangent == fence l top"
);
assert_eq!(
a.layer_tops_r[kk], p.layer_tops_r[kk],
"tangent == fence r top"
);
assert_eq!(a.layer_bases_l[kk], p.layer_bases_l[kk]);
assert_eq!(a.layer_bases_r[kk], p.layer_bases_r[kk]);
if (a.layer_bases_l[kk] - a.layer_bases_r[kk]).abs() > 1e-3 {
dipping += 1;
}
}
}
assert!(dipping > 0, "deviated bore dips on dipping cells (l != r)");
let col = &ab.columns[5];
let cc = &m.grid().cell(Ijk::new(col.i, col.j, k)).corners;
let (bl, br) = (0.5 * cc[4].z + 0.5 * cc[6].z, 0.5 * cc[5].z + 0.5 * cc[7].z);
assert_eq!(
col.layer_bases_l[k], bl,
"bore base_l == direct ZCORN interp"
);
assert_eq!(
col.layer_bases_r[k], br,
"bore base_r == direct ZCORN interp"
);
let vb = m
.intersection_bundle(
&SectionSpec::AlongBore {
trajectory: vec![[3.5, 3.5, 5000.0], [3.5, 3.5, 5100.0]],
},
None,
)
.unwrap();
assert_eq!(vb.columns.len(), 1, "single areal point -> one column");
let v = &vb.columns[0];
for kk in 0..v.layer_tops.len() {
if !v.layer_tops[kk].is_finite() {
continue;
}
assert_eq!(
v.layer_tops_l[kk], v.layer_tops[kk],
"vertical: l == centroid"
);
assert_eq!(
v.layer_tops_r[kk], v.layer_tops[kk],
"vertical: r == centroid"
);
assert_eq!(v.layer_bases_l[kk], v.layer_bases[kk]);
assert_eq!(v.layer_bases_r[kk], v.layer_bases[kk]);
}
}
#[test]
fn geometry_only_section_and_error_paths() {
let m = wedge_model(11, 20.0, 120.0);
let spec = SectionSpec::Polyline(vec![[0.5, 0.5], [9.5, 0.5]]);
let b = m.intersection_bundle(&spec, None).unwrap();
assert!(b.property.is_none());
assert!(b.columns.iter().all(|c| c.values.is_empty()));
assert!(m
.intersection_bundle(&SectionSpec::Polyline(vec![[0.5, 0.5]]), None)
.is_err());
assert!(m.intersection_bundle(&spec, Some("NOPE")).is_err());
}
const S_UTM_X0: f64 = 552_000.0;
const S_UTM_Y0: f64 = 6_805_000.0;
const S_UTM_INC: f64 = 30.0;
fn utm_wedge() -> StaticModel {
let n = 11usize;
let top = vec![5000.0; n * n];
let mut base = vec![0.0; n * n];
for r in 0..n {
for c in 0..n {
base[r * n + c] = 5000.0 + 20.0 + 100.0 * (c as f64 / (n - 1) as f64);
}
}
let wf = Wireframe {
boundary: Boundary {
ring: vec![
[S_UTM_X0, S_UTM_Y0],
[S_UTM_X0 + 300.0, S_UTM_Y0],
[S_UTM_X0 + 300.0, S_UTM_Y0 + 300.0],
[S_UTM_X0, S_UTM_Y0 + 300.0],
[S_UTM_X0, S_UTM_Y0],
],
hardness: Hardness::Hard,
},
horizons: std::sync::Arc::new(vec![
Horizon {
name: "TopRes".into(),
role: HorizonRole::Top,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: top,
is_control: vec![true; n * n],
},
},
Horizon {
name: "BaseRes".into(),
role: HorizonRole::Base,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: base,
is_control: vec![true; n * n],
},
},
]),
contacts: vec![Contact {
kind: ContactKind::Owc,
depth_m: 6000.0,
hardness: Hardness::Hard,
}],
};
let mut o = opts();
o.area_m2 = 90_000.0; StaticModelBuilder::from_wireframe(&wf, o)
.unwrap()
.with_georef(
S_UTM_X0 + S_UTM_INC / 2.0,
S_UTM_Y0 + S_UTM_INC / 2.0,
S_UTM_INC,
S_UTM_INC,
)
.build()
.unwrap()
}
#[test]
fn utm_world_fence_yields_ordered_columns() {
let m = utm_wedge();
let f = m.map_bundle(&crate::model::MapSpec::new()).unwrap().frame;
let ymid = f.origin_y + (f.nrow as f64 - 1.0) * f.spacing_y / 2.0;
let x0 = f.origin_x;
let x1 = f.origin_x + (f.ncol as f64 - 1.0) * f.spacing_x;
let line = SectionSpec::Polyline(vec![[x0, ymid], [x1, ymid]]);
let b = m.intersection_bundle(&line, Some("PORO")).unwrap();
assert!(!b.columns.is_empty(), "world fence produced no columns");
assert_eq!(b.columns.len(), 10, "one column per crossed i");
for (n, col) in b.columns.iter().enumerate() {
assert_eq!(col.i, n);
assert!(col.x > 500_000.0, "column x is world: {}", col.x);
if n > 0 {
assert!(col.distance_m > b.columns[n - 1].distance_m);
}
assert_eq!(col.layer_tops.len(), 5);
assert_eq!(col.values.len(), 5);
}
let thin = b.columns[0].layer_bases[4] - b.columns[0].layer_tops[0];
let thick = b.columns[9].layer_bases[4] - b.columns[9].layer_tops[0];
assert!(
thick > thin * 3.0,
"wedge thickens downdip: {thin} -> {thick}"
);
}
const W_X0: f64 = 500_000.0;
const W_Y0: f64 = 6_800_000.0;
fn georef_local_wedge(thin: f64, thick: f64) -> StaticModel {
StaticModelBuilder::from_wireframe(&wedge_wf(11, thin, thick), opts())
.unwrap()
.with_georef(W_X0 + 0.5, W_Y0 + 0.5, 1.0, 1.0)
.build()
.unwrap()
}
#[test]
fn georef_world_fence_and_bore_edges_are_frame_invariant_and_dip() {
let local = wedge_model(11, 20.0, 120.0); let world = georef_local_wedge(20.0, 120.0); let k = 2usize; const FRAME_TOL: f64 = 1e-6;
let l0 = [0.5, 0.5];
let l1 = [9.5, 9.5];
let w0 = [W_X0 + l0[0], W_Y0 + l0[1]];
let w1 = [W_X0 + l1[0], W_Y0 + l1[1]];
let assert_dips_and_consistent = |lb: &IntersectionBundle, wb: &IntersectionBundle| {
assert!(!wb.columns.is_empty(), "world trace produced no columns");
assert_eq!(
lb.columns.len(),
wb.columns.len(),
"same geometry -> same crossed columns"
);
let mut dipping = 0;
for (lc, wc) in lb.columns.iter().zip(&wb.columns) {
assert_eq!((lc.i, lc.j), (wc.i, wc.j), "same (i,j) sequence");
for kk in 0..lc.layer_tops_l.len() {
if !lc.layer_tops_l[kk].is_finite() {
continue;
}
assert!((wc.layer_tops_l[kk] - lc.layer_tops_l[kk]).abs() < FRAME_TOL);
assert!((wc.layer_tops_r[kk] - lc.layer_tops_r[kk]).abs() < FRAME_TOL);
assert!((wc.layer_bases_l[kk] - lc.layer_bases_l[kk]).abs() < FRAME_TOL);
assert!((wc.layer_bases_r[kk] - lc.layer_bases_r[kk]).abs() < FRAME_TOL);
if (wc.layer_bases_l[kk] - wc.layer_bases_r[kk]).abs() > 1e-3 {
dipping += 1;
}
}
}
assert!(
dipping > 0,
"world-georef section must dip (l != r); pre-fix it collapsed to the centroid"
);
};
let lb = local
.intersection_bundle(&SectionSpec::Polyline(vec![l0, l1]), None)
.unwrap();
let wb = world
.intersection_bundle(&SectionSpec::Polyline(vec![w0, w1]), None)
.unwrap();
assert!(
wb.columns[0].x > 400_000.0,
"world sample x: {}",
wb.columns[0].x
);
assert_dips_and_consistent(&lb, &wb);
let lab = local
.intersection_bundle(
&SectionSpec::AlongBore {
trajectory: vec![[l0[0], l0[1], 5000.0], [l1[0], l1[1], 5100.0]],
},
None,
)
.unwrap();
let wab = world
.intersection_bundle(
&SectionSpec::AlongBore {
trajectory: vec![[w0[0], w0[1], 5000.0], [w1[0], w1[1], 5100.0]],
},
None,
)
.unwrap();
assert!(
wab.columns.iter().all(|c| c.path_z.is_some()),
"bore z overlaid"
);
assert_dips_and_consistent(&lab, &wab);
let (bx0, bx1, bymid) = (W_X0 + 0.5, W_X0 + 9.5, W_Y0 + 0.5);
let sb = world
.intersection_bundle(
&SectionSpec::Polyline(vec![[bx0, bymid], [bx1, bymid]]),
None,
)
.unwrap();
let col = &sb.columns[5];
let cc = &world.grid().cell(Ijk::new(col.i, col.j, k)).corners;
let (top_l, top_r) = (0.5 * cc[0].z + 0.5 * cc[2].z, 0.5 * cc[1].z + 0.5 * cc[3].z);
let (base_l, base_r) = (0.5 * cc[4].z + 0.5 * cc[6].z, 0.5 * cc[5].z + 0.5 * cc[7].z);
assert_eq!(
col.layer_tops_l[k], top_l,
"world fence top_l == direct ZCORN interp"
);
assert_eq!(
col.layer_tops_r[k], top_r,
"world fence top_r == direct ZCORN interp"
);
assert_eq!(
col.layer_bases_l[k], base_l,
"world fence base_l == direct ZCORN interp"
);
assert_eq!(
col.layer_bases_r[k], base_r,
"world fence base_r == direct ZCORN interp"
);
}
#[test]
fn utm_along_bore_trajectory_sections() {
let m = utm_wedge();
let f = m.map_bundle(&crate::model::MapSpec::new()).unwrap().frame;
let ymid = f.origin_y + (f.nrow as f64 - 1.0) * f.spacing_y / 2.0;
let x0 = f.origin_x;
let x1 = f.origin_x + (f.ncol as f64 - 1.0) * f.spacing_x;
let traj = vec![[x0, ymid, 5000.0], [x1, ymid, 5100.0]];
let b = m
.intersection_bundle(&SectionSpec::AlongBore { trajectory: traj }, Some("PORO"))
.unwrap();
assert!(!b.columns.is_empty(), "UTM bore produced no columns");
assert!(
b.columns.iter().all(|c| c.path_z.is_some()),
"bore z overlaid"
);
let z0 = b.columns.first().unwrap().path_z.unwrap();
let z1 = b.columns.last().unwrap().path_z.unwrap();
assert!(z0 < z1, "path descends: {z0} -> {z1}");
}
#[test]
fn section_json_round_trips_and_keys_are_stable() {
let m = wedge_model(11, 20.0, 120.0);
let spec = SectionSpec::Polyline(vec![[0.5, 0.5], [9.5, 0.5]]);
let b = m.intersection_bundle(&spec, Some("PORO")).unwrap();
let json = serde_json::to_string(&b).unwrap();
let back: IntersectionBundle = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"base_name",
"columns",
"contacts",
"horizon_traces",
"inputs_ref",
"property",
"schema_version",
"sugar_cube",
"top_name",
"zones",
]
);
let col = v["columns"][0].as_object().unwrap();
let mut ck: Vec<&str> = col.keys().map(String::as_str).collect();
ck.sort_unstable();
assert_eq!(
ck,
[
"distance_m",
"i",
"j",
"layer_bases",
"layer_bases_l",
"layer_bases_r",
"layer_tops",
"layer_tops_l",
"layer_tops_r",
"path_z",
"values",
"x",
"y",
"zone_ids",
]
);
}
fn two_zone_stack_model() -> StaticModel {
use crate::model::{HorizonSource, HorizonStack, StackHorizon, StackZone};
const N: usize = 11;
let flat = |d: f64| GriddedDepth {
ncol: N,
nrow: N,
depth_m: vec![d; N * N],
is_control: vec![true; N * N],
};
let stack = HorizonStack {
horizons: vec![
StackHorizon {
name: "TOP".into(),
source: HorizonSource::Mapped(flat(5000.0)),
},
StackHorizon {
name: "MID".into(),
source: HorizonSource::Mapped(flat(5030.0)),
},
StackHorizon {
name: "BASE".into(),
source: HorizonSource::Mapped(flat(5060.0)),
},
],
zone_layers: vec![
StackZone::new("UPPER", Conformity::Proportional, 4, Vec::new())
.with_color("#ffcc00"),
StackZone::new("LOWER", Conformity::Proportional, 4, Vec::new()),
],
};
StaticModelBuilder::from_horizon_stack(stack, opts())
.unwrap()
.with_boundary(vec![
[0.0, 0.0],
[10.0, 0.0],
[10.0, 10.0],
[0.0, 10.0],
[0.0, 0.0],
])
.build()
.unwrap()
}
#[test]
fn section_carries_zone_ids_and_colour_by_zone_table() {
let m = two_zone_stack_model();
let spec = SectionSpec::Polyline(vec![[0.5, 0.5], [9.5, 0.5]]);
let b = m.intersection_bundle(&spec, Some("PORO")).unwrap();
assert_eq!(
b.zones,
vec![
SectionZone {
name: "UPPER".into(),
color: Some("#ffcc00".into()),
},
SectionZone {
name: "LOWER".into(),
color: None,
},
]
);
assert!(!b.columns.is_empty());
let mut saw_upper = false;
let mut saw_lower = false;
for col in &b.columns {
assert_eq!(
col.zone_ids.len(),
col.layer_tops.len(),
"zone_ids is nk-sized"
);
for k in 0..col.zone_ids.len() {
if col.layer_tops[k].is_nan() {
assert_eq!(
col.zone_ids[k],
SectionColumn::NO_ZONE,
"inactive layer -> NO_ZONE"
);
} else {
let id = col.zone_ids[k];
assert!(
(id as usize) < b.zones.len(),
"active layer -> valid zone id"
);
let expect = if k < 4 { 0 } else { 1 };
assert_eq!(id, expect, "layer {k} zone id");
saw_upper |= id == 0;
saw_lower |= id == 1;
}
}
}
assert!(
saw_upper && saw_lower,
"both zones appear along the section"
);
let json = serde_json::to_string(&b).unwrap();
let back: IntersectionBundle = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
assert_eq!(b.schema_version, 5);
}
}