use crate::error::StaticError;
use crate::grid::{Grid, Property};
use crate::gridder::ExtrapolationPolicy;
use crate::gridder::{
layer_grid_stack_into, solve_surface_seeded, Conformity, Control, KernelSurface, LayerScratch,
SolveOpts, Surface, ZoneLayerSpec,
};
use crate::model::builder::{
condition_scatter, repair_interface, resolve_stack_surfaces, spacing, stack_boundary_ring,
stack_framework_from_names, substitute_tie_datums, validate_stack, warm_surface,
wireframe_structure, BuildOpts, HorizonSource, HorizonStack, StackFrame, WellTie,
};
use crate::model::draw::{PerturbationField, RealizationDraw};
use crate::model::model::{Georef, StaticModel};
use crate::model::pipeline::{McMode, PropertyPipeline, PropertyReport};
use crate::model::population::{override_zone_priors, populate, PetroSample};
use crate::model::provenance::{
BuildWarning, InterfaceRepair, PopulationMode, Provenance, StackProvenance, WellTieRecord,
ZoneProvenance,
};
use crate::model::spec::BuildSpec;
use crate::model::trend::TrendSurface;
use crate::model::zones::ZoneTable;
use crate::volumetrics::{validate_fraction, validate_positive, ConstantPriors, NTG, PORO, SW};
use crate::wireframe::{Contact, ContactKind, Hardness, Wireframe};
use petektools::geostat::sgs_unconditional;
use petektools::Lattice;
use std::borrow::Cow;
const STRUCTURAL_MAX_NEIGHBOURS: usize = 16;
const SEED_GOLDEN: u64 = 0x9E37_79B9_7F4A_7C15;
fn horizon_seed(base: u64, salt: u64) -> u64 {
base ^ salt.wrapping_add(1).wrapping_mul(SEED_GOLDEN)
}
#[allow(clippy::too_many_arguments)]
fn perturbation_field(
field: &PerturbationField,
origin_x: f64,
origin_y: f64,
dx: f64,
dy: f64,
nx: usize,
ny: usize,
seed: u64,
) -> Result<Vec<f64>, StaticError> {
let n = nx * ny;
if !(field.sd_m.is_finite() && field.sd_m > 0.0) {
return Ok(vec![0.0; n]);
}
let lattice = Lattice::regular(origin_x, origin_y, dx, dy, nx, ny);
let spacing = dx.max(dy);
let radius = (field.variogram.range * 1.5).max(spacing * 4.0);
let arr = sgs_unconditional(
&lattice,
0.0,
field.sd_m * field.sd_m,
&field.variogram,
STRUCTURAL_MAX_NEIGHBOURS,
radius,
seed,
)
.map_err(|e| StaticError::Grid(format!("structural perturbation field failed: {e}")))?;
let mut out = vec![0.0; n];
for jp in 0..ny {
for ip in 0..nx {
out[jp * nx + ip] = arr[[ip, jp]];
}
}
Ok(out)
}
fn pin_ties(field: &mut [f64], well_ties: &[WellTieRecord], horizon: &str, nx: usize, ny: usize) {
for rec in well_ties {
if rec.ip < nx && rec.jp < ny && rec.residuals.iter().any(|r| r.horizon == horizon) {
field[rec.jp * nx + rec.ip] = 0.0;
}
}
}
#[derive(Debug, Clone)]
struct StackState {
surfaces: Vec<Surface>,
horizon_names: Vec<String>,
zone_names: Vec<String>,
zone_colors: Vec<Option<String>>,
zone_specs: Vec<ZoneLayerSpec>,
zone_contacts: Vec<Vec<Contact>>,
interface_repairs: Vec<InterfaceRepair>,
tie_dxdy: (f64, f64),
framework: Wireframe,
source: HorizonStack,
}
#[derive(Debug, Clone)]
struct PropertyModel {
pipeline: PropertyPipeline,
mode: McMode,
cached: Option<(Vec<f64>, PropertyReport)>,
}
#[derive(Debug, Clone)]
struct ZonePropertyModel {
zone: String,
pipeline: PropertyPipeline,
mode: McMode,
cached: Option<(Vec<f64>, PropertyReport)>,
}
#[derive(Debug, Clone)]
struct GrossField {
node_dz: Vec<f64>,
mean: f64,
}
#[derive(Debug, Clone)]
pub struct StaticModelTemplate {
ni: usize,
nj: usize,
nk: usize,
conformity: Conformity,
solve_opts: SolveOpts,
top_controls: Vec<Control>,
gross_field: Option<GrossField>,
logs: Option<Vec<PetroSample>>,
trend: Option<TrendSurface>,
framework: Wireframe,
default_inputs_ref: &'static str,
spec: BuildSpec,
seed: KernelSurface,
properties: Vec<PropertyModel>,
zone_properties: Vec<ZonePropertyModel>,
well_ties: Vec<WellTieRecord>,
scratch: LayerScratch,
stack: Option<StackState>,
}
impl StaticModelTemplate {
pub fn new(wf: &Wireframe, opts: BuildOpts) -> Result<Self, StaticError> {
let s = wireframe_structure(wf, &opts)?;
let (ni, nj) = (s.ni, s.nj);
let top_controls = s.top_controls;
let seed = warm_surface(ni + 1, nj + 1, &top_controls)?;
let gross_field = match &s.base_controls {
None => None,
Some(bc) => {
let base = warm_surface(ni + 1, nj + 1, bc)?;
let mut node_dz = Vec::with_capacity((ni + 1) * (nj + 1));
for jp in 0..=nj {
for ip in 0..=ni {
node_dz.push(base.z(ip, jp) - seed.z(ip, jp));
}
}
let mean = node_dz.iter().sum::<f64>() / node_dz.len() as f64;
if !(mean.is_finite() && mean > 0.0) {
return Err(StaticError::Grid(format!(
"Base horizon gross field must have a positive mean, got {mean}"
)));
}
Some(GrossField { node_dz, mean })
}
};
Ok(Self {
ni,
nj,
nk: opts.nk,
conformity: opts.conformity,
solve_opts: opts.solve_opts,
top_controls,
gross_field,
logs: None,
trend: None,
framework: wf.clone(),
default_inputs_ref: "wireframe",
spec: BuildSpec::default(),
seed,
properties: Vec::new(),
zone_properties: Vec::new(),
well_ties: Vec::new(),
scratch: LayerScratch::new(),
stack: None,
})
}
pub fn from_horizon_stack(stack: HorizonStack, opts: BuildOpts) -> Result<Self, StaticError> {
let (ni, nj) = validate_stack(&stack)?;
let (nx, ny) = (ni + 1, nj + 1);
let policy = ExtrapolationPolicy::default();
let mut surfaces = resolve_stack_surfaces(&stack, nx, ny, policy)?;
let is_derived: Vec<bool> = stack
.horizons
.iter()
.map(|h| matches!(h.source, HorizonSource::TopsOnly(_)))
.collect();
let mut interface_repairs = Vec::new();
for i in 0..surfaces.len() - 1 {
let (upper, lower) = repair_interface(
&surfaces[i],
&surfaces[i + 1],
None, true, is_derived[i],
is_derived[i + 1],
i,
&mut interface_repairs,
)?;
surfaces[i] = upper;
surfaces[i + 1] = lower;
}
let horizon_names: Vec<String> = stack.horizons.iter().map(|h| h.name.clone()).collect();
let zone_contacts: Vec<Vec<Contact>> = stack
.zone_layers
.iter()
.map(|z| z.contacts.clone())
.collect();
let zone_specs: Vec<ZoneLayerSpec> = stack
.zone_layers
.iter()
.map(|z| ZoneLayerSpec {
conformity: z.conformity,
requested_nk: z.nk,
})
.collect();
let all_contacts: Vec<Contact> = zone_contacts.iter().flatten().copied().collect();
let framework = stack_framework_from_names(
&horizon_names,
&surfaces,
all_contacts,
nx,
ny,
stack_boundary_ring(None, None, nx, ny),
);
let zone_names: Vec<String> = stack.zone_layers.iter().map(|z| z.name.clone()).collect();
let zone_colors: Vec<Option<String>> =
stack.zone_layers.iter().map(|z| z.color.clone()).collect();
let stack_state = StackState {
surfaces,
horizon_names,
zone_names,
zone_colors,
zone_specs,
zone_contacts,
interface_repairs,
tie_dxdy: spacing(opts.area_m2, ni, nj),
framework: framework.clone(),
source: stack,
};
Ok(Self {
ni,
nj,
nk: opts.nk,
conformity: opts.conformity,
solve_opts: opts.solve_opts,
top_controls: Vec::new(),
gross_field: None,
logs: None,
trend: None,
framework,
default_inputs_ref: "horizon-stack",
spec: BuildSpec::default(),
seed: KernelSurface::flat(nx, ny, 0.0),
properties: Vec::new(),
zone_properties: Vec::new(),
well_ties: Vec::new(),
scratch: LayerScratch::new(),
stack: Some(stack_state),
})
}
pub fn from_scatter_stack(
mut stack: HorizonStack,
opts: BuildOpts,
frame: StackFrame,
) -> Result<Self, StaticError> {
condition_scatter(&mut stack, &frame)?;
let g = frame.georef;
Ok(Self::from_horizon_stack(stack, opts)?.with_georef(
g.origin_x,
g.origin_y,
g.spacing_x,
g.spacing_y,
))
}
#[must_use]
pub fn with_clamp_base_to_top(mut self, clamp: bool) -> Self {
self.spec.clamp_base_to_top = clamp;
self
}
#[must_use]
pub fn with_min_thickness_m(mut self, min_thickness_m: f64) -> Self {
self.spec.min_thickness_m = Some(min_thickness_m);
self
}
#[must_use]
pub fn with_collapse_below_m(mut self, collapse_below_m: f64) -> Self {
self.spec.collapse_below_m = Some(collapse_below_m);
self
}
#[must_use]
pub fn with_logs(mut self, samples: Vec<PetroSample>) -> Self {
self.logs = if samples.is_empty() {
None
} else {
Some(crate::model::population::sort_by_tvd(samples))
};
self
}
#[must_use]
pub fn with_areal_trend(mut self, trend: TrendSurface) -> Self {
self.trend = Some(trend);
self
}
#[must_use]
pub fn with_property(self, pipeline: PropertyPipeline) -> Self {
self.with_property_mode(pipeline, McMode::LevelShift)
}
#[must_use]
pub fn with_property_mode(mut self, pipeline: PropertyPipeline, mode: McMode) -> Self {
self.properties.push(PropertyModel {
pipeline,
mode,
cached: None,
});
self
}
#[must_use]
pub fn with_zone_property(
self,
zone_name: impl Into<String>,
pipeline: PropertyPipeline,
) -> Self {
self.with_zone_property_mode(zone_name, pipeline, McMode::LevelShift)
}
#[must_use]
pub fn with_zone_property_mode(
mut self,
zone_name: impl Into<String>,
pipeline: PropertyPipeline,
mode: McMode,
) -> Self {
self.zone_properties.push(ZonePropertyModel {
zone: zone_name.into(),
pipeline,
mode,
cached: None,
});
self
}
#[must_use]
pub fn with_inputs_ref(mut self, inputs_ref: impl Into<String>) -> Self {
self.spec.inputs_ref = Some(inputs_ref.into());
self
}
pub fn with_spec(mut self, spec: BuildSpec) -> Result<Self, StaticError> {
let ties = spec.well_ties.clone();
self.spec = spec;
self.spec.well_ties = Vec::new(); let policy = self.spec.extrapolation;
self = self.with_extrapolation(policy)?;
if !ties.is_empty() {
self = self.with_well_ties(ties)?;
}
Ok(self)
}
fn inputs_ref_string(&self) -> String {
self.spec
.inputs_ref
.clone()
.unwrap_or_else(|| self.default_inputs_ref.to_string())
}
#[must_use]
pub fn with_georef(
mut self,
origin_x: f64,
origin_y: f64,
spacing_x: f64,
spacing_y: f64,
) -> Self {
self.spec.georef = Georef::new(origin_x, origin_y, spacing_x, spacing_y);
self
}
#[must_use]
pub fn with_boundary(mut self, ring: Vec<[f64; 2]>) -> Self {
self.spec.boundary = if ring.is_empty() { None } else { Some(ring) };
self
}
#[must_use]
pub fn with_tie_settings(mut self, ties: crate::model::spec::TieSettings) -> Self {
self.spec.ties = ties;
self
}
pub fn with_well_ties(mut self, ties: Vec<WellTie>) -> Result<Self, StaticError> {
if ties.is_empty() {
return Ok(self);
}
let (nx, ny) = (self.ni + 1, self.nj + 1);
let policy = self.spec.extrapolation;
let settings = self.spec.ties;
let stack = self.stack.as_mut().ok_or_else(|| {
StaticError::InvalidInput(
"with_well_ties requires a stack-aware template (from_horizon_stack)".into(),
)
})?;
let (records, substituted) = substitute_tie_datums(
&mut stack.source,
&ties,
&stack.surfaces,
settings,
stack.tie_dxdy,
nx,
ny,
)?;
if substituted {
stack.surfaces = resolve_stack_surfaces(&stack.source, nx, ny, policy)?;
let is_derived: Vec<bool> = stack
.source
.horizons
.iter()
.map(|h| matches!(h.source, HorizonSource::TopsOnly(_)))
.collect();
let mut interface_repairs = Vec::new();
for i in 0..stack.surfaces.len() - 1 {
let (upper, lower) = repair_interface(
&stack.surfaces[i],
&stack.surfaces[i + 1],
None,
true,
is_derived[i],
is_derived[i + 1],
i,
&mut interface_repairs,
)?;
stack.surfaces[i] = upper;
stack.surfaces[i + 1] = lower;
}
stack.interface_repairs = interface_repairs;
let all_contacts: Vec<Contact> =
stack.zone_contacts.iter().flatten().copied().collect();
stack.framework = stack_framework_from_names(
&stack.horizon_names,
&stack.surfaces,
all_contacts,
nx,
ny,
stack_boundary_ring(None, None, nx, ny),
);
}
self.well_ties = records;
self.spec.well_ties = ties;
Ok(self)
}
pub fn with_extrapolation(mut self, policy: ExtrapolationPolicy) -> Result<Self, StaticError> {
self.spec.extrapolation = policy;
let (nx, ny) = (self.ni + 1, self.nj + 1);
if let Some(stack) = self.stack.as_mut() {
stack.surfaces = resolve_stack_surfaces(&stack.source, nx, ny, policy)?;
let is_derived: Vec<bool> = stack
.source
.horizons
.iter()
.map(|h| matches!(h.source, HorizonSource::TopsOnly(_)))
.collect();
let mut interface_repairs = Vec::new();
for i in 0..stack.surfaces.len() - 1 {
let (upper, lower) = repair_interface(
&stack.surfaces[i],
&stack.surfaces[i + 1],
None,
true,
is_derived[i],
is_derived[i + 1],
i,
&mut interface_repairs,
)?;
stack.surfaces[i] = upper;
stack.surfaces[i + 1] = lower;
}
stack.interface_repairs = interface_repairs;
let all_contacts: Vec<Contact> =
stack.zone_contacts.iter().flatten().copied().collect();
stack.framework = stack_framework_from_names(
&stack.horizon_names,
&stack.surfaces,
all_contacts,
nx,
ny,
stack_boundary_ring(None, None, nx, ny),
);
self.framework = stack.framework.clone();
}
Ok(self)
}
#[must_use]
pub fn with_sugar_cube(mut self, sugar_cube: bool) -> Self {
self.spec.sugar_cube = sugar_cube;
self
}
pub fn realize(&mut self, draw: &RealizationDraw) -> Result<StaticModel, StaticError> {
let mut model = StaticModel::empty(self.framework.clone());
self.realize_into(draw, &mut model)?;
Ok(model)
}
#[must_use]
pub fn reusable_model(&self) -> StaticModel {
StaticModel::empty(self.framework.clone())
}
pub fn realize_into(
&mut self,
draw: &RealizationDraw,
model: &mut StaticModel,
) -> Result<(), StaticError> {
if self.stack.is_some() {
return self.realize_into_stack(draw, model);
}
validate_positive("area_m2", draw.area_m2)?;
validate_positive("gross_height_m", draw.gross_height_m)?;
validate_fraction("porosity", draw.porosity)?;
validate_fraction("net_to_gross", draw.net_to_gross)?;
validate_fraction("water_saturation", draw.water_saturation)?;
if !draw.contact_depth_m.is_finite() {
return Err(StaticError::InvalidInput(format!(
"contact depth must be finite, got {}",
draw.contact_depth_m
)));
}
if let Some(goc) = draw.goc_depth_m {
if !goc.is_finite() {
return Err(StaticError::InvalidInput(format!(
"GOC depth must be finite, got {goc}"
)));
}
if goc > draw.contact_depth_m {
return Err(StaticError::InvalidInput(format!(
"GOC ({goc}) must be shallower than the OWC ({})",
draw.contact_depth_m
)));
}
}
let controls: Cow<'_, [Control]> = match &draw.structural {
None => Cow::Borrowed(&self.top_controls),
Some(p) => {
let mut c = self.top_controls.clone();
for &(ip, jp, dz) in &p.control_shifts {
match c.iter_mut().find(|k| k.ip == ip && k.jp == jp) {
Some(k) => k.z += dz,
None => {
return Err(StaticError::Grid(format!(
"structural shift at ({ip},{jp}) has no matching control"
)))
}
}
}
Cow::Owned(c)
}
};
let top = solve_surface_seeded(&self.seed, &controls)?;
self.seed = top.clone();
let (dx, dy) = spacing(draw.area_m2, self.ni, self.nj);
let top_surface: Surface = match &draw.top_structural {
Some(pf) => {
let (nx, ny) = (self.ni + 1, self.nj + 1);
let (ox, oy) = self
.spec
.georef
.map_or((0.0, 0.0), |g| (g.origin_x, g.origin_y));
let fld = perturbation_field(
pf,
ox,
oy,
dx,
dy,
nx,
ny,
horizon_seed(draw.seed_index, 0),
)?;
top.surface().offset_by_field(&fld)?
}
None => top.surface().clone(),
};
let base = match &self.gross_field {
Some(gf) => {
let scale = draw.gross_height_m / gf.mean;
let dz: Vec<f64> = gf.node_dz.iter().map(|g| g * scale).collect();
top_surface.offset_by_field(&dz)?
}
None => top_surface.offset_by(draw.gross_height_m),
};
let mut warnings = Vec::new();
let base = match self.spec.min_thickness_m {
Some(min_t) => {
let (repaired, columns, worst_m) =
base.repair_min_thickness(&top_surface, min_t)?;
if columns > 0 {
warnings.push(BuildWarning::ThinColumnsRepaired { columns, worst_m });
}
repaired
}
None => base.guard_below(&top_surface, self.spec.clamp_base_to_top)?,
};
let specs = [ZoneLayerSpec {
conformity: self.conformity,
requested_nk: self.nk,
}];
let (mut coord, mut zcorn) = model.grid_mut().take_geometry_buffers();
let layered = layer_grid_stack_into(
&[&top_surface, &base],
dx,
dy,
&specs,
self.spec.collapse_below_m,
&mut self.scratch,
&mut coord,
&mut zcorn,
)?;
let nk = layered.nk;
if layered.truncated_cells > 0 {
warnings.push(BuildWarning::LayersTruncated {
cells: layered.truncated_cells,
});
}
if layered.collapsed_cells > 0 {
warnings.push(BuildWarning::CellsCollapsed {
cells: layered.collapsed_cells,
});
}
if layered.nk_capped {
warnings.push(BuildWarning::LayerCountCapped { nk });
}
model
.grid_mut()
.install_geometry(layered.dims, coord, zcorn);
let grid = model.grid_mut();
let priors = ConstantPriors {
porosity: draw.porosity,
net_to_gross: draw.net_to_gross,
water_saturation: draw.water_saturation,
};
populate(grid, priors, self.logs.as_deref(), self.trend.as_ref())?;
let property_reports =
realize_properties(&mut self.properties, grid, draw, self.spec.georef)?;
let mut framework = self.framework.clone();
let mut contacts = Vec::with_capacity(2);
if let Some(goc) = draw.goc_depth_m {
contacts.push(Contact {
kind: ContactKind::Goc,
depth_m: goc,
hardness: Hardness::Assumed,
});
}
contacts.push(Contact {
kind: ContactKind::Owc,
depth_m: draw.contact_depth_m,
hardness: Hardness::Assumed,
});
framework.contacts = contacts;
let population = if self.logs.is_some() || !self.properties.is_empty() {
PopulationMode::Logs
} else {
PopulationMode::Priors
};
let provenance = Provenance {
inputs_ref: self.inputs_ref_string(),
solve_opts: self.solve_opts,
conformity: self.conformity,
nk,
population,
warnings,
realization: Some(draw.clone()),
property_reports,
stack: None,
well_ties: Vec::new(),
sugar_cube: self.spec.sugar_cube,
};
model.reset_state(
framework,
ZoneTable::single(nk),
provenance,
draw.sw_gas,
self.spec.georef,
None,
);
Ok(())
}
fn realize_into_stack(
&mut self,
draw: &RealizationDraw,
model: &mut StaticModel,
) -> Result<(), StaticError> {
validate_positive("area_m2", draw.area_m2)?;
validate_fraction("porosity", draw.porosity)?;
validate_fraction("net_to_gross", draw.net_to_gross)?;
validate_fraction("water_saturation", draw.water_saturation)?;
let stack = self
.stack
.take()
.expect("realize_into_stack requires a stack");
let result = self.realize_into_stack_inner(&stack, draw, model);
self.stack = Some(stack);
result
}
fn realize_into_stack_inner(
&mut self,
stack: &StackState,
draw: &RealizationDraw,
model: &mut StaticModel,
) -> Result<(), StaticError> {
let nzones = stack.zone_names.len();
for zd in &draw.zones {
if zd.zone >= nzones {
return Err(StaticError::InvalidInput(format!(
"zone draw index {} out of range (nzones={nzones})",
zd.zone
)));
}
if let Some(owc) = zd.owc_depth_m {
if !owc.is_finite() {
return Err(StaticError::InvalidInput(format!(
"zone {} OWC depth must be finite, got {owc}",
zd.zone
)));
}
}
if let Some(goc) = zd.goc_depth_m {
if !goc.is_finite() {
return Err(StaticError::InvalidInput(format!(
"zone {} GOC depth must be finite, got {goc}",
zd.zone
)));
}
if let Some(owc) = zd.owc_depth_m {
if goc > owc {
return Err(StaticError::InvalidInput(format!(
"zone {} GOC ({goc}) must be shallower than its OWC ({owc})",
zd.zone
)));
}
}
}
if let Some(p) = zd.porosity {
validate_fraction("porosity", p)?;
}
if let Some(n) = zd.net_to_gross {
validate_fraction("net_to_gross", n)?;
}
if let Some(s) = zd.water_saturation {
validate_fraction("water_saturation", s)?;
}
}
let (dx, dy) = spacing(draw.area_m2, self.ni, self.nj);
let structural_active = draw.top_structural.is_some()
|| draw.zones.iter().any(|z| z.isochore_structural.is_some());
let perturbed_surfaces: Vec<Surface>;
let surf_refs: Vec<&Surface> = if structural_active {
perturbed_surfaces = self.perturb_stack_surfaces(stack, draw, dx, dy)?;
perturbed_surfaces.iter().collect()
} else {
stack.surfaces.iter().collect()
};
let (mut coord, mut zcorn) = model.grid_mut().take_geometry_buffers();
let mut warnings = Vec::new();
let layered = layer_grid_stack_into(
&surf_refs,
dx,
dy,
&stack.zone_specs,
self.spec.collapse_below_m,
&mut self.scratch,
&mut coord,
&mut zcorn,
)?;
let nk = layered.nk;
if layered.truncated_cells > 0 {
warnings.push(BuildWarning::LayersTruncated {
cells: layered.truncated_cells,
});
}
if layered.collapsed_cells > 0 {
warnings.push(BuildWarning::CellsCollapsed {
cells: layered.collapsed_cells,
});
}
if layered.nk_capped {
warnings.push(BuildWarning::LayerCountCapped { nk });
}
model
.grid_mut()
.install_geometry(layered.dims, coord, zcorn);
let grid = model.grid_mut();
let priors = ConstantPriors {
porosity: draw.porosity,
net_to_gross: draw.net_to_gross,
water_saturation: draw.water_saturation,
};
populate(grid, priors, self.logs.as_deref(), self.trend.as_ref())?;
let mut property_reports =
realize_properties(&mut self.properties, grid, draw, self.spec.georef)?;
for zd in &draw.zones {
if zd.porosity.is_some() || zd.net_to_gross.is_some() || zd.water_saturation.is_some() {
let zp = ConstantPriors {
porosity: zd.porosity.unwrap_or(draw.porosity),
net_to_gross: zd.net_to_gross.unwrap_or(draw.net_to_gross),
water_saturation: zd.water_saturation.unwrap_or(draw.water_saturation),
};
override_zone_priors(grid, zp, layered.zones[zd.zone].k_range())?;
}
}
if !self.zone_properties.is_empty() {
let zone_kranges: Vec<core::ops::Range<usize>> =
layered.zones.iter().map(|z| z.k_range()).collect();
realize_zone_properties(
&mut self.zone_properties,
grid,
draw,
self.spec.georef,
&stack.zone_names,
&zone_kranges,
&mut property_reports,
)?;
}
let mk = |kind: ContactKind, depth_m: f64| Contact {
kind,
depth_m,
hardness: Hardness::Assumed,
};
let zone_contacts: Vec<Vec<Contact>> = (0..nzones)
.map(|z| match draw.zones.iter().find(|zd| zd.zone == z) {
Some(zd) => {
let mut c = Vec::new();
if let Some(goc) = zd.goc_depth_m {
c.push(mk(ContactKind::Goc, goc));
}
if let Some(owc) = zd.owc_depth_m {
c.push(mk(ContactKind::Owc, owc));
}
c
}
None => stack.zone_contacts[z].clone(),
})
.collect();
let all_contacts: Vec<Contact> = zone_contacts.iter().flatten().copied().collect();
let mut framework = stack.framework.clone();
framework.contacts = all_contacts;
framework.boundary.ring = stack_boundary_ring(
self.spec.boundary.as_ref(),
self.spec.georef,
self.ni + 1,
self.nj + 1,
);
let per_zone_nk: Vec<usize> = layered.zones.iter().map(|z| z.nk).collect();
let zones = ZoneTable::from_stack(
&stack.horizon_names,
&stack.zone_names,
&stack.zone_colors,
&per_zone_nk,
);
let zone_prov: Vec<ZoneProvenance> = layered
.zones
.iter()
.enumerate()
.map(|(z, sz)| ZoneProvenance {
name: stack.zone_names[z].clone(),
top_horizon: stack.horizon_names[z].clone(),
base_horizon: stack.horizon_names[z + 1].clone(),
conformity: sz.conformity,
nk: sz.nk,
k_start: sz.k_start,
truncated_cells: sz.truncated_cells,
})
.collect();
let stack_prov = StackProvenance {
horizons: stack.horizon_names.clone(),
zones: zone_prov,
interface_repairs: stack.interface_repairs.clone(),
};
let population = if self.logs.is_some() || !self.properties.is_empty() {
PopulationMode::Logs
} else {
PopulationMode::Priors
};
let provenance = Provenance {
inputs_ref: self.inputs_ref_string(),
solve_opts: self.solve_opts,
conformity: self.conformity,
nk,
population,
warnings,
realization: Some(draw.clone()),
property_reports,
stack: Some(stack_prov),
well_ties: self.well_ties.clone(),
sugar_cube: self.spec.sugar_cube,
};
model.reset_state(
framework,
zones,
provenance,
draw.sw_gas,
self.spec.georef,
Some(zone_contacts),
);
Ok(())
}
fn perturb_stack_surfaces(
&self,
stack: &StackState,
draw: &RealizationDraw,
dx: f64,
dy: f64,
) -> Result<Vec<Surface>, StaticError> {
let (nx, ny) = (self.ni + 1, self.nj + 1);
let (ox, oy) = self
.spec
.georef
.map_or((0.0, 0.0), |g| (g.origin_x, g.origin_y));
let nsurf = stack.surfaces.len();
let mut out: Vec<Surface> = Vec::with_capacity(nsurf);
let top_surf = match &draw.top_structural {
Some(pf) => {
let mut fld = perturbation_field(
pf,
ox,
oy,
dx,
dy,
nx,
ny,
horizon_seed(draw.seed_index, 0),
)?;
pin_ties(&mut fld, &self.well_ties, &stack.horizon_names[0], nx, ny);
stack.surfaces[0].offset_by_field(&fld)?
}
None => stack.surfaces[0].clone(),
};
out.push(top_surf);
for z in 0..nsurf - 1 {
let above = &stack.surfaces[z];
let below = &stack.surfaces[z + 1];
let mut t = vec![0.0; nx * ny];
for jp in 0..ny {
for ip in 0..nx {
t[jp * nx + ip] = (below.z(ip, jp) - above.z(ip, jp)).max(0.0);
}
}
if let Some(pf) = draw
.zones
.iter()
.find(|zd| zd.zone == z)
.and_then(|zd| zd.isochore_structural.as_ref())
{
let mut p = perturbation_field(
pf,
ox,
oy,
dx,
dy,
nx,
ny,
horizon_seed(draw.seed_index, (z + 1) as u64),
)?;
pin_ties(&mut p, &self.well_ties, &stack.horizon_names[z + 1], nx, ny);
for idx in 0..nx * ny {
t[idx] = if t[idx] == 0.0 {
0.0
} else {
(t[idx] + p[idx]).max(0.0)
};
}
}
let prev = &out[z];
out.push(prev.offset_by_field(&t)?);
}
Ok(out)
}
}
fn realize_properties(
properties: &mut [PropertyModel],
grid: &mut Grid,
draw: &RealizationDraw,
georef: Option<Georef>,
) -> Result<Vec<PropertyReport>, StaticError> {
let mut reports = Vec::with_capacity(properties.len());
for pm in properties.iter_mut() {
let name = pm.pipeline.name().to_string();
let report = match pm.mode {
McMode::Resimulate => pm
.pipeline
.reseeded(draw.seed_index)
.apply_with_georef(grid, georef)?,
McMode::LevelShift => {
if pm.cached.is_none() {
let r = pm.pipeline.apply_with_georef(grid, georef)?;
let pattern = grid
.properties()
.get(&name)
.expect("pipeline set the property cube")
.values
.clone();
pm.cached = Some((pattern, r));
}
let (pattern, report) = pm.cached.as_ref().expect("cached above");
let shift = draw.property_shift(&name);
let is_fraction = matches!(name.as_str(), PORO | NTG | SW);
let mut values = grid.properties_mut().take_values(&name);
values.clear();
values.reserve(pattern.len());
values.extend(pattern.iter().map(|v| {
let shifted = v + shift;
if is_fraction {
shifted.clamp(0.0, 1.0)
} else {
shifted
}
}));
grid.properties_mut().set(Property {
name: name.clone(),
values,
})?;
report.clone()
}
};
reports.push(report);
}
Ok(reports)
}
fn realize_zone_properties(
zone_properties: &mut [ZonePropertyModel],
grid: &mut Grid,
draw: &RealizationDraw,
georef: Option<Georef>,
zone_names: &[String],
zone_kranges: &[core::ops::Range<usize>],
reports: &mut Vec<PropertyReport>,
) -> Result<(), StaticError> {
for zm in zone_properties.iter_mut() {
let name = zm.pipeline.name().to_string();
let k_range = zone_names
.iter()
.position(|n| n == &zm.zone)
.map(|i| zone_kranges[i].clone())
.ok_or_else(|| {
StaticError::InvalidInput(format!(
"zone property '{name}': zone '{}' is not in the stack's zone table",
zm.zone
))
})?;
let zone = zm.zone.clone();
let report = match zm.mode {
McMode::Resimulate => zm
.pipeline
.reseeded(draw.seed_index)
.apply_in_zone_with_georef(grid, k_range, georef)
.map_err(|e| StaticError::InvalidInput(format!("zone '{zone}': {e}")))?,
McMode::LevelShift => {
if zm.cached.is_none() {
let r = zm
.pipeline
.apply_in_zone_with_georef(grid, k_range.clone(), georef)
.map_err(|e| StaticError::InvalidInput(format!("zone '{zone}': {e}")))?;
let pattern = grid
.properties()
.get(&name)
.expect("zone pipeline set the property cube")
.values
.clone();
zm.cached = Some((pattern, r));
}
let (pattern, report) = zm.cached.as_ref().expect("cached above");
let shift = draw.property_shift(&name);
let is_fraction = matches!(name.as_str(), PORO | NTG | SW);
let dims = grid.dims();
let (ni, nj, nk) = (dims.ni, dims.nj, dims.nk);
let mut values = grid.properties_mut().take_values(&name);
if values.len() != ni * nj * nk {
values = vec![f64::NAN; ni * nj * nk];
}
for k in k_range.clone() {
for j in 0..nj {
for i in 0..ni {
let idx = (k * nj + j) * ni + i;
let shifted = pattern[idx] + shift;
values[idx] = if is_fraction {
shifted.clamp(0.0, 1.0)
} else {
shifted
};
}
}
}
grid.properties_mut().set(Property {
name: name.clone(),
values,
})?;
report.clone()
}
};
reports.push(report);
}
Ok(())
}
#[cfg(test)]
mod stack_tests {
use super::*;
use crate::gridder::Conformity;
use crate::model::draw::ZoneDraw;
use crate::model::HorizonStack;
use crate::wireframe::{GriddedDepth, HorizonRole};
const N: usize = 9;
fn flat_surf(depth: f64) -> GriddedDepth {
GriddedDepth {
ncol: N,
nrow: N,
depth_m: vec![depth; N * N],
is_control: vec![true; N * N],
}
}
fn two_zone_stack() -> HorizonStack {
use crate::model::builder::{HorizonSource, StackHorizon, StackZone};
HorizonStack {
horizons: vec![
StackHorizon {
name: "H0".into(),
source: HorizonSource::Mapped(flat_surf(5000.0)),
},
StackHorizon {
name: "H1".into(),
source: HorizonSource::Mapped(flat_surf(5030.0)),
},
StackHorizon {
name: "H2".into(),
source: HorizonSource::Mapped(flat_surf(5060.0)),
},
],
zone_layers: vec![
StackZone::new("Z0", Conformity::Proportional, 5, Vec::new()),
StackZone::new("Z1", Conformity::Proportional, 4, Vec::new()),
],
}
}
fn opts() -> BuildOpts {
BuildOpts {
area_m2: 1_000_000.0,
gross_height_m: 0.0, nk: 0, conformity: Conformity::Proportional,
solve_opts: SolveOpts::default(),
priors: ConstantPriors {
porosity: 0.2,
net_to_gross: 0.8,
water_saturation: 0.3,
},
}
}
#[derive(PartialEq, Debug)]
struct Fingerprint {
bulk_volume: f64,
poro: Vec<f64>,
ntg: Vec<f64>,
sw: Vec<f64>,
zone_grv: Vec<f64>,
zone_hcpv: Vec<f64>,
}
fn fingerprint(m: &StaticModel) -> Fingerprint {
let zoned = m.in_place_by_zone().unwrap();
Fingerprint {
bulk_volume: m.grid().bulk_volume(),
poro: m.property(PORO).unwrap().values.clone(),
ntg: m.property(NTG).unwrap().values.clone(),
sw: m.property(SW).unwrap().values.clone(),
zone_grv: zoned.zones.iter().map(|z| z.in_place.grv_m3).collect(),
zone_hcpv: zoned.zones.iter().map(|z| z.in_place.hcpv_m3).collect(),
}
}
fn draw_a() -> RealizationDraw {
RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 1)
.with_zone_draw(ZoneDraw::new(0).with_owc(5015.0))
.with_zone_draw(ZoneDraw::new(1).with_priors(0.1, 0.5, 0.4))
}
fn draw_b() -> RealizationDraw {
RealizationDraw::new(900_000.0, 0.0, 0.0, 0.24, 0.85, 0.28, 2)
.with_zone_draw(ZoneDraw::new(0).with_goc(5010.0).with_owc(5025.0))
.with_zone_draw(ZoneDraw::new(1).with_owc(5050.0).with_priors(0.3, 0.9, 0.2))
}
#[test]
fn stack_template_realize_is_deterministic_across_fresh_templates() {
let draw = draw_a();
let mut t1 = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let mut t2 = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let a = t1.realize(&draw).unwrap();
let b = t2.realize(&draw).unwrap();
assert_eq!(
fingerprint(&a),
fingerprint(&b),
"stack realize not deterministic"
);
assert_eq!(a.framework().horizons.len(), 3);
assert_eq!(a.zones().zones().len(), 2);
assert_eq!(a.framework().horizons[1].role, HorizonRole::Intermediate);
}
#[test]
fn stack_realize_into_stale_buffer_bit_matches_fresh() {
let (a, b) = (draw_a(), draw_b());
let fresh_b = {
let mut t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let mut m = t.reusable_model();
t.realize_into(&b, &mut m).unwrap();
fingerprint(&m)
};
let reused = {
let mut t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let mut m = t.reusable_model();
t.realize_into(&a, &mut m).unwrap(); t.realize_into(&b, &mut m).unwrap(); fingerprint(&m)
};
assert_eq!(
reused, fresh_b,
"stale-buffer realize_into != fresh realize"
);
}
#[test]
fn stack_per_zone_contacts_and_priors_drive_in_place() {
let draw = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 7).with_zone_draw(
ZoneDraw::new(0)
.with_owc(5015.0)
.with_priors(0.30, 0.90, 0.20),
);
let mut t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let m = t.realize(&draw).unwrap();
let por = m.zone_stats(PORO).unwrap();
assert!((por[0].mean - 0.30).abs() < 1e-12, "Z0 porosity override");
assert!((por[1].mean - 0.20).abs() < 1e-12, "Z1 base prior");
let zoned = m.in_place_by_zone().unwrap();
assert!(zoned.zones[0].in_place.hcpv_m3 > 0.0, "Z0 oil leg");
assert_eq!(
zoned.zones[1].in_place.hcpv_m3, 0.0,
"Z1 contactless -> zero hydrocarbon"
);
let sum: f64 = zoned.zones.iter().map(|z| z.in_place.grv_m3).sum();
assert!((zoned.total.grv_m3 - sum).abs() <= 1e-9 * sum);
let bad = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 8)
.with_zone_draw(ZoneDraw::new(9).with_owc(5015.0));
assert!(t.realize(&bad).is_err());
}
use crate::model::pipeline::{Gaussian, UpscaleMethod, WellLog};
use crate::model::StaticModelBuilder;
use petektools::{Variogram, VariogramModel};
fn two_zone_stack_owc() -> HorizonStack {
let mut s = two_zone_stack();
let owc = |d: f64| Contact {
kind: ContactKind::Owc,
depth_m: d,
hardness: Hardness::Assumed,
};
s.zone_layers[0].contacts = vec![owc(5030.0)]; s.zone_layers[1].contacts = vec![owc(5060.0)]; s
}
fn z1_ntg_pipe() -> PropertyPipeline {
let samples = || vec![(5033.0, 0.5), (5040.0, 0.5), (5048.0, 0.5), (5055.0, 0.5)];
let wells = vec![
WellLog::new(60.0, 60.0, samples()),
WellLog::new(940.0, 940.0, samples()),
];
let vg = Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 400.0).unwrap();
PropertyPipeline::new(NTG)
.upscale(wells, UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(vg, 42))
}
#[test]
fn stack_volume_bundle_shell_is_non_empty_and_spill_invariant() {
let (ni, nj, nk) = (8usize, 8, 9);
let expected_tris = 2 * 2 * (ni * nj + ni * nk + nj * nk);
let tris = |m: &StaticModel| m.volume_bundle(PORO).unwrap().indices.len() / 3;
let built = crate::model::StaticModelBuilder::from_horizon_stack(two_zone_stack(), opts())
.unwrap()
.build()
.unwrap();
assert!(!built.is_spilled());
assert_eq!(tris(&built), expected_tris, "in-core stack shell");
let spilled =
crate::model::StaticModelBuilder::from_horizon_stack(two_zone_stack(), opts())
.unwrap()
.with_memory_budget(crate::model::MemoryBudget::bytes(1))
.build()
.unwrap();
assert!(spilled.is_spilled(), "tiny budget must spill");
assert_eq!(spilled.grid().dims().cell_count(), 1, "placeholder grid");
assert_eq!(
tris(&spilled),
expected_tris,
"spilled stack shell must match in-core (not empty)"
);
let mut t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let realized = t
.realize(&RealizationDraw::new(
1_000_000.0,
0.0,
0.0,
0.2,
0.8,
0.3,
1,
))
.unwrap();
assert_eq!(tris(&realized), expected_tris, "realized stack shell");
}
#[test]
fn tied_template_zero_spread_matches_tied_builder() {
use crate::model::WellTie;
let ties = || vec![WellTie::new("TIE-1", 500.0, 500.0, 4, 4).with_top("H1", 5022.0)];
let built = crate::model::StaticModelBuilder::from_horizon_stack(two_zone_stack(), opts())
.unwrap()
.with_well_ties(ties())
.build()
.unwrap();
let draw = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 1);
let mut t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts())
.unwrap()
.with_well_ties(ties())
.unwrap();
let realized = t.realize(&draw).unwrap();
assert_eq!(
fingerprint(&built),
fingerprint(&realized),
"tied template realize must equal tied builder build"
);
let untied = crate::model::StaticModelBuilder::from_horizon_stack(two_zone_stack(), opts())
.unwrap()
.build()
.unwrap();
assert_ne!(
fingerprint(&untied),
fingerprint(&built),
"the tie must change the model"
);
let res = &realized.provenance().well_ties;
assert_eq!(res.len(), 1);
assert_eq!(res[0].residuals.len(), 1);
assert!(
(res[0].residuals[0].residual_m - (5022.0 - 5030.0)).abs() < 1e-6,
"tie residual = measured - model"
);
}
#[test]
fn zoned_mc_zero_spread_matches_deterministic_zone_pipe() {
let built = StaticModelBuilder::from_horizon_stack(two_zone_stack_owc(), opts())
.unwrap()
.with_zone_property("Z1", z1_ntg_pipe())
.build()
.unwrap();
let built_zoned = built.in_place_by_zone().unwrap();
let draw = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 1);
let mut t = StaticModelTemplate::from_horizon_stack(two_zone_stack_owc(), opts())
.unwrap()
.with_zone_property("Z1", z1_ntg_pipe());
let realized = t.realize(&draw).unwrap();
let realized_zoned = realized.in_place_by_zone().unwrap();
for (b, r) in built_zoned.zones.iter().zip(realized_zoned.zones.iter()) {
let rel = |x: f64, y: f64| (x - y).abs() / x.abs().max(1.0);
assert!(
rel(b.in_place.grv_m3, r.in_place.grv_m3) < 1e-9,
"zone {} GRV: built {} vs realized {}",
b.zone,
b.in_place.grv_m3,
r.in_place.grv_m3
);
assert!(
rel(b.in_place.hcpv_m3, r.in_place.hcpv_m3) < 1e-9,
"zone {} HCPV: built {} vs realized {} (zone pipe honoured?)",
b.zone,
b.in_place.hcpv_m3,
r.in_place.hcpv_m3
);
}
let z1_ntg = realized.zone_stats(NTG).unwrap()[1].mean;
assert!(
(z1_ntg - 0.8).abs() > 0.1,
"Z1 NTG {z1_ntg} should reflect the ~0.5 pipe, not the 0.8 prior"
);
}
fn field(sd: f64, range: f64) -> PerturbationField {
PerturbationField::new(
sd,
Variogram::new(VariogramModel::Spherical, 0.0, 1.0, range).unwrap(),
)
}
#[test]
fn perturb_stack_surfaces_is_bit_reproducible() {
let draw = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 5)
.with_top_structural(field(8.0, 300.0))
.with_zone_draw(ZoneDraw::new(0).with_isochore_structural(field(6.0, 300.0)));
let t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts()).unwrap();
let stack = t.stack.as_ref().unwrap();
let (dx, dy) = spacing(draw.area_m2, t.ni, t.nj);
let a = t.perturb_stack_surfaces(stack, &draw, dx, dy).unwrap();
let b = t.perturb_stack_surfaces(stack, &draw, dx, dy).unwrap();
for (sa, sb) in a.iter().zip(b.iter()) {
for jp in 0..=t.nj {
for ip in 0..=t.ni {
assert_eq!(
sa.z(ip, jp),
sb.z(ip, jp),
"perturbed surface not reproducible"
);
}
}
}
assert!(
(0..=t.nj).any(|jp| (0..=t.ni).any(|ip| a[0].z(ip, jp) != stack.surfaces[0].z(ip, jp))),
"top perturbation did not move the surface"
);
}
#[test]
fn perturb_pins_ties_to_zero_at_the_tie_node() {
use crate::model::WellTie;
let ties = vec![WellTie::new("T", 500.0, 500.0, 4, 4).with_top("H0", 5001.0)];
let t = StaticModelTemplate::from_horizon_stack(two_zone_stack(), opts())
.unwrap()
.with_well_ties(ties)
.unwrap();
let draw = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 3)
.with_top_structural(field(30.0, 400.0));
let stack = t.stack.as_ref().unwrap();
let (dx, dy) = spacing(draw.area_m2, t.ni, t.nj);
let surfs = t.perturb_stack_surfaces(stack, &draw, dx, dy).unwrap();
assert_eq!(
surfs[0].z(4, 4),
stack.surfaces[0].z(4, 4),
"top perturbation must be zero at the tie node"
);
assert!(
(0..=t.nj).any(|jp| (0..=t.ni).any(|ip| {
(ip, jp) != (4, 4) && surfs[0].z(ip, jp) != stack.surfaces[0].z(ip, jp)
})),
"perturbation should be live away from the tie"
);
}
#[test]
fn perturb_zero_masks_a_fully_merged_zone() {
let mut s = two_zone_stack();
s.horizons[1].source = HorizonSource::Mapped(flat_surf(5000.0)); let t = StaticModelTemplate::from_horizon_stack(s, opts()).unwrap();
let draw = RealizationDraw::new(1_000_000.0, 0.0, 0.0, 0.2, 0.8, 0.3, 11)
.with_zone_draw(ZoneDraw::new(0).with_isochore_structural(field(15.0, 400.0)));
let stack = t.stack.as_ref().unwrap();
let (dx, dy) = spacing(draw.area_m2, t.ni, t.nj);
let surfs = t.perturb_stack_surfaces(stack, &draw, dx, dy).unwrap();
for jp in 0..=t.nj {
for ip in 0..=t.ni {
assert_eq!(
surfs[1].z(ip, jp),
surfs[0].z(ip, jp),
"merged zone must stay merged (zero-masked) at ({ip},{jp})"
);
}
}
}
}