use crate::core::facade::grid::{self, Frame, StaticGrid};
use crate::core::facade::project::Project;
use crate::core::facade::spec::LayersSpec;
use crate::core::facade::zonation::{self, ZoneSpec};
use crate::units::SrsError;
use petekio::{GridGeometry, GridMethod, PointSet, Surface};
use petekstatic::gridder::{Conformity, SolveOpts};
use petekstatic::model::{
BuildOpts, ConstantPriors, HorizonSource, HorizonStack, Pick, StackHorizon, StaticModelBuilder,
WellTie, WorldPoint,
};
use petekstatic::wireframe::{Boundary, GriddedDepth, Hardness, Horizon, HorizonRole, Wireframe};
#[derive(Debug, Clone)]
pub struct TieResidual {
pub horizon: String,
pub well_id: String,
pub surface_m: f64,
pub pick_m: f64,
pub residual_m: f64,
pub ok: bool,
pub note: String,
}
pub type WellTieInput = (String, f64, f64, Vec<(String, f64)>);
pub struct Framework {
wireframe: Option<Wireframe>,
tie_to_tops: bool,
outline: Option<String>,
top_geom: GridGeometry,
area_m2: f64,
gross_m: f64,
nk: usize,
conformity: Conformity,
priors: ConstantPriors,
tie: Vec<TieResidual>,
zones_limited: bool,
resolved: Vec<StackHorizon>,
horizon_names: Vec<String>,
zonation: Option<Vec<ZoneSpec>>,
zone_priors: Vec<(String, ConstantPriors)>,
well_ties: Vec<WellTie>,
collapse_below_m: Option<f64>,
min_thickness_m: Option<f64>,
}
impl Framework {
#[allow(clippy::too_many_arguments)]
pub fn build(
proj: &Project,
horizons: &[String],
outline: Option<&str>,
tie_to_tops: bool,
gross_m: f64,
min_thickness_m: Option<f64>,
cell_size_m: Option<f64>,
collapse_below_m: Option<f64>,
) -> Result<Self, SrsError> {
if horizons.is_empty() {
return Err(SrsError::InvalidInput(
"framework needs at least one horizon".into(),
));
}
let top_geom = proj.horizon_geom(&horizons[0], cell_size_m)?;
let mut resolved: Vec<StackHorizon> = Vec::with_capacity(horizons.len());
for name in horizons.iter() {
let source = if proj.geo().surface(name).is_some() {
let s = proj.horizon_surface(name, Some(&top_geom), None)?;
let s = if geom_eq(&s.geom, &top_geom) {
s
} else {
s.resample(&top_geom)?
};
HorizonSource::Mapped(surface_to_gridded(&s))
} else if proj.geo().points(name).is_some() {
HorizonSource::Scatter(scatter_points(proj, name)?)
} else {
let picks = resolve_picks(proj, name, &top_geom);
if picks.is_empty() {
return Err(SrsError::InvalidInput(format!(
"horizon '{name}' is neither a loaded surface nor a loaded \
point-set, and no well carries its pick"
)));
}
HorizonSource::TopsOnly(picks)
};
resolved.push(StackHorizon {
name: name.clone(),
source,
});
}
let (outline, area_m2) = resolve_outline(proj, outline, &top_geom)?;
let gross = stack_gross(&resolved).unwrap_or(gross_m).max(1e-3);
Ok(Self {
wireframe: None,
tie_to_tops,
outline,
top_geom,
area_m2,
gross_m: gross,
nk: default_nk(gross),
conformity: Conformity::Proportional,
priors: ConstantPriors {
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
},
tie: Vec::new(),
zones_limited: false,
resolved,
horizon_names: horizons.to_vec(),
zonation: None,
zone_priors: Vec::new(),
well_ties: Vec::new(),
collapse_below_m,
min_thickness_m,
})
}
pub fn materialize_wireframe(&mut self, proj: &Project) -> Result<(), SrsError> {
if self.wireframe.is_some() {
return Ok(());
}
let mut wf_horizons: Vec<Horizon> = Vec::with_capacity(self.resolved.len());
let mut tie = Vec::new();
for (name, sh) in self.horizon_names.iter().zip(&self.resolved) {
if matches!(sh.source, HorizonSource::TopsOnly(_)) {
continue;
}
let s = proj.horizon_surface(name, Some(&self.top_geom), None)?;
let s = if geom_eq(&s.geom, &self.top_geom) {
s
} else {
s.resample(&self.top_geom)?
};
let mut gridded = surface_to_gridded(&s);
if self.tie_to_tops {
tie_horizon(proj, name, &s, &self.top_geom, &mut gridded, &mut tie);
}
wf_horizons.push(Horizon {
name: name.clone(),
role: HorizonRole::Intermediate, surface: gridded,
});
}
let n_wf = wf_horizons.len();
for (i, h) in wf_horizons.iter_mut().enumerate() {
h.role = horizon_role(i, n_wf);
}
let boundary = boundary_of(proj, self.outline.as_deref(), &self.top_geom);
self.wireframe = Some(Wireframe {
boundary,
horizons: wf_horizons.into(),
contacts: Vec::new(),
});
self.tie = tie;
Ok(())
}
pub fn tie_report(&self) -> &[TieResidual] {
&self.tie
}
pub fn tie_ok(&self) -> bool {
self.tie.iter().all(|t| t.ok)
}
pub fn set_priors(&mut self, porosity: f64, net_to_gross: f64, water_saturation: f64) {
self.priors = ConstantPriors {
porosity,
net_to_gross,
water_saturation,
};
}
pub fn set_zones(&mut self, n_zones: usize) {
self.zones_limited = n_zones > 1;
}
pub fn set_layering(&mut self, spec: LayersSpec, conformity: Conformity) {
self.nk = spec.nk(self.gross_m);
self.conformity = conformity;
}
pub fn zones_limited(&self) -> bool {
self.zones_limited
}
pub fn set_zonation(&mut self, zones: Vec<ZoneSpec>) -> Result<(), SrsError> {
zonation::validate(&self.horizon_names, &zones)?;
self.zonation = Some(zones);
Ok(())
}
pub fn set_collapse_below_m(&mut self, collapse_below_m: f64) {
self.collapse_below_m = Some(collapse_below_m);
}
pub fn set_zone_priors(&mut self, zone: &str, porosity: f64, net_to_gross: f64, sw: f64) {
let priors = ConstantPriors {
porosity,
net_to_gross,
water_saturation: sw,
};
self.zone_priors.retain(|(z, _)| z != zone);
self.zone_priors.push((zone.to_string(), priors));
}
pub fn set_well_ties(&mut self, ties: Vec<WellTieInput>) {
let g = &self.top_geom;
let mut out = Vec::new();
for (id, x, y, tops) in ties {
let Some((fi, fj)) = g.xy_to_ij(x, y) else {
continue; };
if !(fi.is_finite() && fj.is_finite()) {
continue;
}
let ip = fi.round().clamp(0.0, (g.ncol - 1) as f64) as usize;
let jp = fj.round().clamp(0.0, (g.nrow - 1) as f64) as usize;
let mut wt = WellTie::new(&id, x, y, ip, jp);
for (horizon, depth_m) in tops {
wt = wt.with_top(horizon, depth_m);
}
out.push(wt);
}
self.well_ties = out;
}
pub fn is_zoned(&self) -> bool {
self.zonation.is_some()
}
pub fn build_grid_stack(self) -> Result<StaticGrid, SrsError> {
let Some(zones) = &self.zonation else {
return Err(SrsError::InvalidInput(
"build_grid_stack requires a declared zonation (set_zonation)".into(),
));
};
let raw = zonation::assemble_stack(&self.horizon_names, &self.resolved, zones)?;
let stack_frame = grid::stack_frame(&self.top_geom)?;
let conditioned =
match StaticModelBuilder::condition_scatter_stack(raw.clone(), &stack_frame) {
Ok(c) => c,
Err(_) => {
let mut raw = raw;
condition_scatter_facade(&mut raw, &self.top_geom)?;
raw
}
};
Ok(self.into_grid(Frame::Stack(conditioned)))
}
pub fn build_grid_wireframe(mut self, proj: &Project) -> Result<StaticGrid, SrsError> {
self.materialize_wireframe(proj)?;
let wireframe = self
.wireframe
.take()
.expect("materialize_wireframe populates the wireframe");
Ok(self.into_grid(Frame::Wireframe(wireframe)))
}
fn into_grid(self, frame: Frame) -> StaticGrid {
let opts = BuildOpts {
area_m2: self.area_m2,
gross_height_m: self.gross_m,
nk: self.nk,
conformity: self.conformity,
solve_opts: SolveOpts::default(),
priors: self.priors,
};
StaticGrid::new(
frame,
self.top_geom,
opts,
self.min_thickness_m,
self.collapse_below_m,
self.tie,
self.zone_priors,
self.well_ties,
)
}
}
const DEFAULT_DZ_M: f64 = 1.0;
const MAX_DEFAULT_NK: usize = 200;
fn default_nk(gross_m: f64) -> usize {
let n = if gross_m.is_finite() && gross_m > 0.0 {
((gross_m / DEFAULT_DZ_M).ceil() as usize).max(1)
} else {
1
};
if n > MAX_DEFAULT_NK {
eprintln!(
"peteksim: default layering capped at {MAX_DEFAULT_NK} layers (gross \
{gross_m:.0} m / {DEFAULT_DZ_M} m → {n}); pass ps.layers(dz_m=..) or n=.. to choose"
);
MAX_DEFAULT_NK
} else {
n
}
}
fn horizon_role(idx: usize, n: usize) -> HorizonRole {
if idx == 0 {
HorizonRole::Top
} else if idx == n - 1 {
HorizonRole::Base
} else {
HorizonRole::Intermediate
}
}
fn geom_eq(a: &GridGeometry, b: &GridGeometry) -> bool {
a.ncol == b.ncol
&& a.nrow == b.nrow
&& (a.xori - b.xori).abs() < 1e-6
&& (a.yori - b.yori).abs() < 1e-6
&& (a.xinc - b.xinc).abs() < 1e-9
&& (a.yinc - b.yinc).abs() < 1e-9
}
fn surface_to_gridded(s: &Surface) -> GriddedDepth {
let g = &s.geom;
let vals = s.values();
let (ncol, nrow) = (g.ncol, g.nrow);
let mut depth = vec![f64::NAN; ncol * nrow];
for j in 0..nrow {
for i in 0..ncol {
depth[j * ncol + i] = -vals[[i, j]];
}
}
GriddedDepth {
ncol,
nrow,
depth_m: depth,
is_control: vec![true; ncol * nrow],
}
}
fn tie_horizon(
proj: &Project,
name: &str,
raw: &Surface,
geom: &GridGeometry,
gridded: &mut GriddedDepth,
tie: &mut Vec<TieResidual>,
) {
for bw in proj.wells() {
let Some(iv) = bw.top(name) else {
continue; };
let (x, y, pick) = match bw.xyz(iv.top_md) {
Some(p) => (p.x, p.y, -p.z),
None => {
tie.push(TieResidual {
horizon: name.into(),
well_id: bw.id.clone(),
surface_m: f64::NAN,
pick_m: f64::NAN,
residual_m: f64::NAN,
ok: false,
note: "well trajectory could not position the pick MD".into(),
});
continue;
}
};
let surface_m = raw.sample(x, y).map(|z| -z).unwrap_or(f64::NAN);
match geom.xy_to_ij(x, y) {
Some((fi, fj)) => {
let i = fi.round().clamp(0.0, (geom.ncol - 1) as f64) as usize;
let j = fj.round().clamp(0.0, (geom.nrow - 1) as f64) as usize;
let flat = j * geom.ncol + i;
gridded.depth_m[flat] = pick;
gridded.is_control[flat] = true;
tie.push(TieResidual {
horizon: name.into(),
well_id: bw.id.clone(),
surface_m,
pick_m: pick,
residual_m: pick - surface_m,
ok: surface_m.is_finite(),
note: if surface_m.is_finite() {
String::new()
} else {
"horizon undefined at the well location".into()
},
});
}
None => tie.push(TieResidual {
horizon: name.into(),
well_id: bw.id.clone(),
surface_m,
pick_m: pick,
residual_m: f64::NAN,
ok: false,
note: "well is outside the horizon grid".into(),
}),
}
}
}
fn resolve_picks(proj: &Project, name: &str, geom: &GridGeometry) -> Vec<Pick> {
let mut picks = Vec::new();
for bw in proj.wells() {
let Some(iv) = bw.top(name) else {
continue;
};
let Some(p) = bw.xyz(iv.top_md) else {
continue;
};
let (x, y, depth) = (p.x, p.y, -p.z);
if !(x.is_finite() && y.is_finite() && depth.is_finite()) {
continue;
}
let Some((fi, fj)) = geom.xy_to_ij(x, y) else {
continue;
};
if !(fi.is_finite() && fj.is_finite()) {
continue;
}
let ip = fi.round().clamp(0.0, (geom.ncol - 1) as f64) as usize;
let jp = fj.round().clamp(0.0, (geom.nrow - 1) as f64) as usize;
picks.push(Pick {
ip,
jp,
depth_m: depth,
});
}
picks
}
fn scatter_points(proj: &Project, name: &str) -> Result<Vec<WorldPoint>, SrsError> {
let ps = proj.geo().points(name).ok_or_else(|| {
SrsError::InvalidInput(format!(
"horizon '{name}' has no loaded point-set to scatter"
))
})?;
let points: Vec<WorldPoint> = ps
.coords()
.iter()
.filter(|c| c[0].is_finite() && c[1].is_finite() && c[2].is_finite())
.map(|c| WorldPoint {
x: c[0],
y: c[1],
depth_m: -c[2],
})
.collect();
if points.is_empty() {
return Err(SrsError::InvalidInput(format!(
"horizon point-set '{name}' has no finite points to scatter"
)));
}
Ok(points)
}
fn condition_scatter_facade(stack: &mut HorizonStack, geom: &GridGeometry) -> Result<(), SrsError> {
for h in stack.horizons.iter_mut() {
let HorizonSource::Scatter(points) = &h.source else {
continue;
};
let coords: Vec<[f64; 3]> = points.iter().map(|p| [p.x, p.y, -p.depth_m]).collect();
let surf = PointSet::from_coords(coords)
.to_surface(geom.clone(), GridMethod::MinimumCurvature)
.map_err(SrsError::from)?;
h.source = HorizonSource::Mapped(surface_to_gridded(&surf));
}
Ok(())
}
fn resolve_outline(
proj: &Project,
outline: Option<&str>,
geom: &GridGeometry,
) -> Result<(Option<String>, f64), SrsError> {
if let Some(name) = outline {
let poly = proj.geo().polygons(name).ok_or_else(|| {
SrsError::InvalidInput(format!(
"outline polygon '{name}' not loaded; loaded polygons: {}",
polygon_inventory(proj)
))
})?;
return Ok((Some(name.to_string()), poly.area()));
}
if let Some(poly) = proj.geo().polygons("ModelEdge") {
return Ok((Some("ModelEdge".to_string()), poly.area()));
}
eprintln!(
"peteksim: default outline 'ModelEdge' not loaded; using framework bbox area/boundary. \
Loaded polygons: {}",
polygon_inventory(proj)
);
Ok((None, footprint_area(geom)))
}
fn polygon_inventory(proj: &Project) -> String {
let names: Vec<&str> = proj.geo().polygons_named().map(|(name, _)| name).collect();
if names.is_empty() {
"<none>".to_string()
} else {
names.join(", ")
}
}
fn boundary_of(proj: &Project, outline: Option<&str>, geom: &GridGeometry) -> Boundary {
if let Some(name) = outline {
if let Some(poly) = proj.geo().polygons(name) {
let rings = poly.rings();
if let Some(first) = rings.into_iter().next() {
let ring: Vec<[f64; 2]> = first.iter().map(|p| [p[0], p[1]]).collect();
if ring.len() >= 4 {
return Boundary {
ring,
hardness: Hardness::Interpolated,
};
}
}
} else {
eprintln!(
"peteksim: resolved outline '{name}' disappeared before boundary build; \
using framework bbox. Loaded polygons: {}",
polygon_inventory(proj)
);
}
}
bbox_ring(geom)
}
fn bbox_ring(g: &GridGeometry) -> Boundary {
let (x0, y0) = (g.xori, g.yori);
let x1 = g.xori + g.xinc * (g.ncol.saturating_sub(1)) as f64;
let y1 = g.yori + g.yinc * (g.nrow.saturating_sub(1)) as f64;
Boundary {
ring: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]],
hardness: Hardness::Interpolated,
}
}
fn footprint_area(g: &GridGeometry) -> f64 {
let w = g.xinc * (g.ncol.saturating_sub(1)) as f64;
let h = g.yinc * (g.nrow.saturating_sub(1)) as f64;
(w * h).abs()
}
fn stack_gross(resolved: &[StackHorizon]) -> Option<f64> {
if resolved.len() < 2 {
return None;
}
let top = &resolved[0].source;
let base = &resolved[resolved.len() - 1].source;
if let (HorizonSource::Mapped(a), HorizonSource::Mapped(b)) = (top, base) {
if a.depth_m.len() == b.depth_m.len() {
let mut sum = 0.0;
let mut n = 0usize;
for (t, d) in a.depth_m.iter().zip(&b.depth_m) {
if t.is_finite() && d.is_finite() {
sum += d - t;
n += 1;
}
}
if n > 0 {
return Some((sum / n as f64).abs());
}
}
}
match (source_mean_depth(top), source_mean_depth(base)) {
(Some(a), Some(b)) => Some((b - a).abs()),
_ => None,
}
}
fn source_mean_depth(source: &HorizonSource) -> Option<f64> {
let (mut sum, mut n) = (0.0, 0usize);
match source {
HorizonSource::Mapped(g) => {
for &z in &g.depth_m {
if z.is_finite() {
sum += z;
n += 1;
}
}
}
HorizonSource::Scatter(points) => {
for p in points {
if p.depth_m.is_finite() {
sum += p.depth_m;
n += 1;
}
}
}
HorizonSource::TopsOnly(_) => return None,
}
(n > 0).then(|| sum / n as f64)
}
#[cfg(test)]
mod tests {
use super::*;
use petekstatic::gridder::Conformity;
use petekstatic::model::StackZone;
const XORI: f64 = 431_000.0;
const YORI: f64 = 6_521_000.0;
const CELL: f64 = 100.0;
const NCOL: usize = 13;
const NROW: usize = 11;
fn world_geom() -> GridGeometry {
GridGeometry {
xori: XORI,
yori: YORI,
xinc: CELL,
yinc: CELL,
ncol: NCOL,
nrow: NROW,
rotation_deg: 0.0,
yflip: false,
}
}
fn anchorless_scatter(depth0: f64) -> Vec<WorldPoint> {
let g = world_geom();
let span_x = CELL * (NCOL - 1) as f64;
let span_y = CELL * (NROW - 1) as f64;
let mut pts = Vec::new();
let mut s: u64 = 0x9E37_79B9_7F4A_7C15;
let mut next = || {
s = s
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((s >> 33) as f64) / (u32::MAX as f64)
};
for _ in 0..240 {
let fx = next();
let fy = next();
let x = g.xori + (0.2 + 0.6 * fx) * span_x + 0.37;
let y = g.yori + (0.2 + 0.6 * fy) * span_y + 0.29;
let depth_m = depth0 + 30.0 * fx + 20.0 * fy; pts.push(WorldPoint { x, y, depth_m });
}
pts
}
fn scatter_stack() -> HorizonStack {
HorizonStack {
horizons: vec![
StackHorizon {
name: "Top".into(),
source: HorizonSource::Scatter(anchorless_scatter(2000.0)),
},
StackHorizon {
name: "Base".into(),
source: HorizonSource::Scatter(anchorless_scatter(2050.0)),
},
],
zone_layers: vec![StackZone::new(
"Zone",
Conformity::Proportional,
8,
Vec::new(),
)],
}
}
#[test]
fn engine_conditions_anchorless_world_scatter_to_finite_fields() {
let geom = world_geom();
let frame = grid::stack_frame(&geom).unwrap();
let stack = scatter_stack();
let conditioned = StaticModelBuilder::condition_scatter_stack(stack, &frame)
.expect("engine must condition anchorless world scatter after the ridge fix");
for h in &conditioned.horizons {
let HorizonSource::Mapped(g) = &h.source else {
panic!("horizon '{}' was not conditioned to Mapped", h.name);
};
let finite = g.depth_m.iter().filter(|z| z.is_finite()).count();
assert!(
finite > 0,
"horizon '{}' conditioned to an all-NaN field",
h.name
);
}
}
fn under_constrained_scatter(depth0: f64) -> Vec<WorldPoint> {
vec![
WorldPoint {
x: XORI + 2.37 * CELL,
y: YORI + 2.29 * CELL,
depth_m: depth0,
},
WorldPoint {
x: XORI + 6.37 * CELL,
y: YORI + 5.29 * CELL,
depth_m: depth0 + 10.0,
},
WorldPoint {
x: XORI + 10.37 * CELL,
y: YORI + 8.29 * CELL,
depth_m: depth0 + 20.0,
},
]
}
fn under_constrained_stack() -> HorizonStack {
HorizonStack {
horizons: vec![
StackHorizon {
name: "Top".into(),
source: HorizonSource::Scatter(under_constrained_scatter(2000.0)),
},
StackHorizon {
name: "Base".into(),
source: HorizonSource::Scatter(under_constrained_scatter(2050.0)),
},
],
zone_layers: vec![StackZone::new(
"Zone",
Conformity::Proportional,
8,
Vec::new(),
)],
}
}
#[test]
fn engine_errors_on_under_constrained_world_scatter() {
let geom = world_geom();
let frame = grid::stack_frame(&geom).unwrap();
let r = StaticModelBuilder::condition_scatter_stack(under_constrained_stack(), &frame);
assert!(
r.is_err(),
"expected the engine to error on genuine <4-control under-constraint; got Ok"
);
}
#[test]
fn facade_fallback_conditions_under_constrained_world_scatter() {
let geom = world_geom();
let mut stack = under_constrained_stack();
condition_scatter_facade(&mut stack, &geom).expect("facade fallback must grid");
for h in &stack.horizons {
match &h.source {
HorizonSource::Mapped(g) => {
assert_eq!(g.ncol, NCOL);
assert_eq!(g.nrow, NROW);
let finite = g.depth_m.iter().filter(|z| z.is_finite()).count();
assert!(
finite > 0,
"horizon '{}' conditioned to an all-NaN field",
h.name
);
let mean: f64 =
g.depth_m.iter().filter(|z| z.is_finite()).sum::<f64>() / finite as f64;
assert!(
(1900.0..2150.0).contains(&mean),
"horizon '{}' mean depth {mean} out of the seeded band",
h.name
);
}
_ => panic!("horizon '{}' was not conditioned to Mapped", h.name),
}
}
}
}