mod builder;
mod draw;
mod mc;
#[allow(clippy::module_inception)]
mod model;
mod pipeline;
mod population;
mod provenance;
mod spec;
mod template;
mod tornado;
mod trend;
mod view;
mod zones;
pub use builder::{
BuildOpts, HorizonSource, HorizonStack, Pick, StackFrame, StackHorizon, StackZone,
StaticModelBuilder, WellTie, WorldPoint,
};
pub use draw::{PerturbationField, RealizationDraw, StructuralPerturbation, ZoneDraw};
pub use mc::{
aggregate_field, default_mc_workers, run_mc, Input, McInputs, McResult, McSettings,
RealizedInputs,
};
pub use crate::spill::{
decide_mode, live_set_bytes, physical_ram_bytes, spill_grid, spill_grid_to, BuildMode,
MemoryBudget, SpillBacking, SpillNotice, DEFAULT_BUDGET_FRACTION,
};
#[allow(deprecated)]
pub use mc::{
run_structured_mc, run_structured_mc_parallel, run_structured_mc_parallel_spilled,
run_structured_mc_spilled,
};
pub use model::{Georef, StaticModel, ZoneInPlace, ZoneStat, ZonedInPlace};
pub use pipeline::{
Gaussian, McMode, PropertyPipeline, PropertyReport, UpscaleMethod, UpscaleQc, WellLog,
};
pub use population::PetroSample;
pub use provenance::{
BuildWarning, HorizonTieResidual, InterfaceRepair, PopulationMode, Provenance, StackProvenance,
WellTieRecord, ZoneProvenance,
};
pub use spec::{BuildSpec, TieMethod, TieSettings};
pub use template::StaticModelTemplate;
pub use tornado::{tornado, TornadoBar};
pub use trend::TrendSurface;
pub use view::{
ContactMask, GridFrame, HorizonTrace, IntersectionBundle, MapBundle, MapSpec, ScalarLayer,
SectionColumn, SectionContact, SectionSpec, SectionZone, ValueRange, VolumeBundle, WellMarker,
WellTieResidual, SCHEMA_VERSION,
};
pub use zones::{Zone, ZoneTable};
pub use crate::uncertainty::PercentileSummary;
pub use crate::volumetrics::{ConstantPriors, GasFvf, InPlace, OilFvf, ZoneVolumes};
pub use petektools::sampling::{Correlation, ReservoirSummary, Sampler};
static_assertions::assert_impl_all!(StaticModelTemplate: Send);
static_assertions::assert_impl_all!(StaticModel: Send);
#[cfg(test)]
mod tests {
use super::*;
use crate::gridder::{Conformity, SolveOpts};
use crate::wireframe::{
Boundary, Contact, ContactKind, GriddedDepth, Hardness, Horizon, HorizonRole, Wireframe,
};
fn flat_wireframe(n: usize, depth_m: f64, owc_m: f64) -> Wireframe {
Wireframe {
boundary: Boundary {
ring: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]],
hardness: Hardness::Interpolated,
},
horizons: std::sync::Arc::new(vec![Horizon {
name: "top".into(),
role: HorizonRole::Top,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: vec![depth_m; n * n],
is_control: vec![true; n * n],
},
}]),
contacts: vec![Contact {
kind: ContactKind::Owc,
depth_m: owc_m,
hardness: Hardness::Hard,
}],
}
}
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,
},
}
}
#[test]
fn builder_property_pipeline_populates_a_conditioned_cube() {
use petektools::{Variogram, VariogramModel};
let wf = flat_wireframe(11, 5000.0, 5100.0);
let low = WellLog::new(
0.5,
0.5,
vec![
(5005.0, 0.10),
(5015.0, 0.12),
(5025.0, 0.14),
(5035.0, 0.16),
(5045.0, 0.18),
],
);
let high = WellLog::new(
9.5,
9.5,
vec![
(5005.0, 0.26),
(5015.0, 0.25),
(5025.0, 0.24),
(5035.0, 0.23),
(5045.0, 0.22),
],
);
let vgm = Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 5.0).unwrap();
let pipe = PropertyPipeline::new("PHIE")
.upscale(vec![low, high], UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(vgm, 42));
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_property(pipe)
.build()
.unwrap();
let prop = m.property("PHIE").expect("PHIE cube present");
assert_eq!(prop.values.len(), 10 * 10 * 5);
assert!(prop.values.iter().all(|v| v.is_finite()));
assert_eq!(m.provenance().population, PopulationMode::Logs);
assert_eq!(m.provenance().property_reports.len(), 1);
let report = &m.provenance().property_reports[0];
assert_eq!(report.property, "PHIE");
assert!(report.propagated);
assert_eq!(report.upscale.conditioned_cells, 10); }
fn phie_pipeline(seed: u64) -> PropertyPipeline {
use petektools::{Variogram, VariogramModel};
let low = WellLog::new(
0.5,
0.5,
vec![
(5005.0, 0.10),
(5015.0, 0.12),
(5025.0, 0.14),
(5035.0, 0.16),
(5045.0, 0.18),
],
);
let high = WellLog::new(
9.5,
9.5,
vec![
(5005.0, 0.26),
(5015.0, 0.25),
(5025.0, 0.24),
(5035.0, 0.23),
(5045.0, 0.22),
],
);
let vgm = Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 5.0).unwrap();
PropertyPipeline::new("PHIE")
.upscale(vec![low, high], UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(vgm, seed))
}
fn base_draw(seed_index: u64) -> RealizationDraw {
RealizationDraw::new(100.0, 50.0, 5100.0, 0.25, 0.8, 0.3, seed_index)
}
fn poro_boundary_pipeline(seed: u64) -> PropertyPipeline {
use petektools::{Variogram, VariogramModel};
let zero = WellLog::new(
0.5,
0.5,
vec![
(5005.0, 0.0),
(5015.0, 0.0),
(5025.0, 0.0),
(5035.0, 0.0),
(5045.0, 0.0),
],
);
let one = WellLog::new(
9.5,
9.5,
vec![
(5005.0, 1.0),
(5015.0, 1.0),
(5025.0, 1.0),
(5035.0, 1.0),
(5045.0, 1.0),
],
);
let vgm = Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 5.0).unwrap();
PropertyPipeline::new("PORO")
.upscale(vec![zero, one], UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(vgm, seed))
}
#[test]
fn level_shift_saturates_a_fraction_cube_holding_zeros_and_ones() {
let wf = flat_wireframe(11, 5000.0, 5100.0);
let mut t = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property(poro_boundary_pipeline(42));
let m0 = t.realize(&base_draw(0)).unwrap();
let pat = m0.property("PORO").unwrap().values.clone();
assert!(
pat.iter().any(|&v| v <= 1e-9),
"cube has a non-net (0.0) cell"
);
assert!(
pat.iter().any(|&v| v >= 1.0 - 1e-9),
"cube has a fully-saturated (1.0) cell"
);
let shift = 0.1;
let m1 = t
.realize(&base_draw(1).with_property_shift("PORO", shift))
.unwrap();
let cube = m1.property("PORO").unwrap().values.clone();
let mut saturated = 0usize;
let (mut d_interior, mut n_interior) = (0.0f64, 0usize);
for (v0, v1) in pat.iter().zip(&cube) {
let expect = (v0 + shift).clamp(0.0, 1.0);
assert!(
(v1 - expect).abs() < 1e-12,
"shift-then-clamp per cell: {v0} -> {v1}"
);
assert!((0.0..=1.0).contains(v1), "shifted cell out of [0,1]: {v1}");
if v0 + shift > 1.0 {
saturated += 1;
assert!((v1 - 1.0).abs() < 1e-12, "boundary cell saturates at 1.0");
} else {
d_interior += v1 - v0;
n_interior += 1;
}
}
assert!(
saturated > 0,
"a 1.0 cell saturated under the positive shift"
);
assert!(
(d_interior / n_interior as f64 - shift).abs() < 1e-12,
"interior mean shifts by the drawn amount"
);
assert!(
m1.in_place().is_ok(),
"level-shifted conditioned cube is a valid model"
);
}
#[test]
fn level_shift_conditioned_cube_survives_1000_draws_but_inputs_stay_h2() {
use crate::error::StaticError;
let wf = flat_wireframe(11, 5000.0, 5100.0);
let mut t = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property(poro_boundary_pipeline(42));
for i in 0..1000u64 {
let shift = -0.5 + (i as f64) * (1.0 / 999.0);
let m = t
.realize(&base_draw(i).with_property_shift("PORO", shift))
.expect("level-shift realization is valid");
let cube = &m.property("PORO").unwrap().values;
assert!(
cube.iter().all(|v| (0.0..=1.0).contains(v)),
"draw {i}: cube in [0,1]"
);
assert!(m.in_place().is_ok(), "draw {i}: valid in-place");
}
let bad = RealizationDraw::new(100.0, 50.0, 5100.0, 1.5, 0.8, 0.3, 0);
assert!(
matches!(t.realize(&bad), Err(StaticError::InvalidInput(_))),
"garbage input prior still rejected (H2)"
);
}
#[test]
fn level_shift_keeps_the_pattern_and_moves_the_mean() {
let wf = flat_wireframe(11, 5000.0, 5100.0);
let mut t = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property(phie_pipeline(42));
let m0 = t.realize(&base_draw(0)).unwrap();
let c0 = m0.property("PHIE").unwrap().values.clone();
let m1 = t
.realize(&base_draw(1).with_property_shift("PHIE", 0.05))
.unwrap();
let c1 = m1.property("PHIE").unwrap().values.clone();
for (a, b) in c0.iter().zip(&c1) {
assert!(
(b - a - 0.05).abs() < 1e-12,
"not a pure level shift: {a} {b}"
);
}
let mean0 = c0.iter().sum::<f64>() / c0.len() as f64;
let mean1 = c1.iter().sum::<f64>() / c1.len() as f64;
assert!(
(mean1 - mean0 - 0.05).abs() < 1e-9,
"mean not shifted by 0.05"
);
}
#[test]
fn resimulate_redraws_a_new_pattern_per_seed() {
let wf = flat_wireframe(11, 5000.0, 5100.0);
let mut t = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property_mode(phie_pipeline(42), McMode::Resimulate);
let a = t
.realize(&base_draw(1))
.unwrap()
.property("PHIE")
.unwrap()
.values
.clone();
let b = t
.realize(&base_draw(2))
.unwrap()
.property("PHIE")
.unwrap()
.values
.clone();
assert_ne!(
a, b,
"resimulate must give a different pattern per seed_index"
);
assert!((a[0] - 0.10).abs() < 1e-6 && (b[0] - 0.10).abs() < 1e-6);
}
#[test]
fn mc_property_pipeline_is_bit_reproducible_both_modes() {
let wf = flat_wireframe(11, 5000.0, 5100.0);
let mut tl = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property(phie_pipeline(42));
let l0 = tl
.realize(&base_draw(7))
.unwrap()
.property("PHIE")
.unwrap()
.values
.clone();
let l1 = tl
.realize(&base_draw(7))
.unwrap()
.property("PHIE")
.unwrap()
.values
.clone();
assert_eq!(
l0, l1,
"LevelShift must be bit-reproducible for identical draws"
);
let mut tr = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property_mode(phie_pipeline(42), McMode::Resimulate);
let r0 = tr
.realize(&base_draw(5))
.unwrap()
.property("PHIE")
.unwrap()
.values
.clone();
let r1 = tr
.realize(&base_draw(5))
.unwrap()
.property("PHIE")
.unwrap()
.values
.clone();
assert_eq!(
r0, r1,
"Resimulate must be bit-reproducible for the same seed_index"
);
}
#[test]
fn flat_build_matches_box_volume() {
let wf = flat_wireframe(11, 5000.0, 5100.0);
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let expected_grv_m3 = 100.0 * 50.0;
let ip = m.in_place().unwrap();
assert!((ip.grv_m3 - expected_grv_m3).abs() / expected_grv_m3 < 1e-6);
let ooip = ip.ooip_sm3(OilFvf::new(1.25).unwrap());
assert!(ooip > 0.0);
assert_eq!(m.property_names().len(), 3);
assert_eq!(m.zones().zones().len(), 1);
assert!(m.provenance().realization.is_none());
}
#[test]
fn structural_high_via_control_raises_in_place() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let mut b = StaticModelBuilder::from_wireframe(&wf, opts()).unwrap();
let before = b.build().unwrap().in_place().unwrap().hcpv_m3;
b.add_top_control(5, 5, 4980.0);
let after = b.build().unwrap().in_place().unwrap().hcpv_m3;
assert!(
after > before,
"structural high should raise in-place: {before} -> {after}"
);
}
#[test]
fn logs_populate_cells_in_their_depth_range() {
let wf = flat_wireframe(11, 5000.0, 5100.0);
let samples: Vec<PetroSample> = (0..=25)
.map(|i| (5000.0 + f64::from(i), 0.30, 0.20))
.collect();
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_logs(samples)
.build()
.unwrap();
let poro = &m.property("PORO").unwrap().values;
assert!(poro.iter().any(|&v| (v - 0.30).abs() < 1e-9), "log cells");
assert!(poro.iter().any(|&v| (v - 0.25).abs() < 1e-9), "prior cells");
assert!(matches!(m.provenance().population, PopulationMode::Logs));
}
#[test]
fn builder_needs_a_top_and_a_contact() {
let mut wf = flat_wireframe(4, 5000.0, 5100.0);
std::sync::Arc::make_mut(&mut wf.horizons).clear();
assert!(StaticModelBuilder::from_wireframe(&wf, opts()).is_err());
let mut wf2 = flat_wireframe(4, 5000.0, 5100.0);
wf2.contacts.clear();
assert!(StaticModelBuilder::from_wireframe(&wf2, opts()).is_err());
}
fn wedge_wireframe(n: usize, top_m: f64, thin_m: f64, thick_m: f64) -> Wireframe {
let mut base_depth = vec![0.0; n * n];
for r in 0..n {
for c in 0..n {
let frac = c as f64 / (n - 1) as f64;
base_depth[r * n + c] = top_m + thin_m + (thick_m - thin_m) * frac;
}
}
let mut wf = flat_wireframe(n, top_m, top_m + 1000.0);
std::sync::Arc::make_mut(&mut wf.horizons).push(Horizon {
name: "base".into(),
role: HorizonRole::Base,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: base_depth,
is_control: vec![true; n * n],
},
});
wf
}
#[test]
fn base_horizon_relief_drives_spatially_varying_gross() {
use crate::grid::Ijk;
let wf = wedge_wireframe(11, 5000.0, 20.0, 120.0);
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let footprint = 100.0;
let expected = footprint * 70.0;
assert!(
(m.bulk_volume() - expected).abs() / expected < 1e-6,
"wedge bulk {} != analytic {expected}",
m.bulk_volume()
);
let constant_offset = footprint * 50.0;
assert!(
(m.bulk_volume() - constant_offset).abs() / constant_offset > 0.3,
"base relief was ignored: bulk {} ~= constant-offset {constant_offset}",
m.bulk_volume()
);
let dz_updip = m.grid().cell(Ijk::new(0, 0, 0)).dz();
let dz_downdip = m.grid().cell(Ijk::new(9, 0, 0)).dz();
assert!(
dz_downdip > dz_updip * 3.0,
"gross should thicken downdip: updip {dz_updip} vs downdip {dz_downdip}"
);
assert!(
m.provenance().warnings.is_empty(),
"clean wedge build has no warnings"
);
}
#[test]
fn no_base_horizon_falls_back_to_constant_offset() {
let wf = flat_wireframe(11, 5000.0, 6000.0);
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let expected = 100.0 * 50.0; assert!((m.bulk_volume() - expected).abs() / expected < 1e-9);
assert!(m.provenance().warnings.is_empty());
}
#[test]
fn unused_intermediate_horizon_is_warned() {
use crate::model::BuildWarning;
let mut wf = flat_wireframe(11, 5000.0, 6000.0);
std::sync::Arc::make_mut(&mut wf.horizons).push(Horizon {
name: "mid".into(),
role: HorizonRole::Intermediate,
surface: GriddedDepth {
ncol: 11,
nrow: 11,
depth_m: vec![5030.0; 11 * 11],
is_control: vec![true; 11 * 11],
},
});
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
assert!(
m.provenance().warnings.iter().any(|w| matches!(
w,
BuildWarning::UnusedHorizon {
role: HorizonRole::Intermediate,
..
}
)),
"intermediate horizon should raise an UnusedHorizon warning: {:?}",
m.provenance().warnings
);
let expected = 100.0 * 50.0;
assert!((m.bulk_volume() - expected).abs() / expected < 1e-9);
}
#[test]
fn wedge_template_at_mean_gross_matches_deterministic_build() {
let wf = wedge_wireframe(11, 5000.0, 20.0, 120.0);
let det = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let draw = RealizationDraw::new(100.0, 70.0, 6000.0, 0.25, 0.8, 0.3, 0);
let real = t.realize(&draw).unwrap();
let (a, b) = (det.bulk_volume(), real.bulk_volume());
assert!(
(a - b).abs() <= 1e-12 * a,
"template at mean gross diverged from deterministic build: {a} vs {b}"
);
use crate::grid::Ijk;
for ijk in [Ijk::new(0, 0, 0), Ijk::new(9, 0, 0)] {
let (dd, rd) = (det.grid().cell(ijk).dz(), real.grid().cell(ijk).dz());
assert!(
(dd - rd).abs() <= 1e-12 * dd.max(1.0),
"cell dz {dd} vs {rd}"
);
}
}
#[test]
fn wedge_template_gross_scales_level_preserving_shape() {
use crate::grid::Ijk;
let wf = wedge_wireframe(11, 5000.0, 20.0, 120.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let at_mean = t
.realize(&RealizationDraw::new(
100.0, 70.0, 6000.0, 0.25, 0.8, 0.3, 0,
))
.unwrap();
let doubled = t
.realize(&RealizationDraw::new(
100.0, 140.0, 6000.0, 0.25, 0.8, 0.3, 1,
))
.unwrap();
assert!(
(doubled.bulk_volume() - 2.0 * at_mean.bulk_volume()).abs()
<= 1e-9 * doubled.bulk_volume()
);
let dz = |m: &StaticModel, i| m.grid().cell(Ijk::new(i, 0, 0)).dz();
for i in [0usize, 5, 9] {
let (one, two) = (dz(&at_mean, i), dz(&doubled, i));
assert!(
(two - 2.0 * one).abs() <= 1e-9 * two,
"col {i}: {one} -> {two}"
);
}
let r1 = dz(&at_mean, 9) / dz(&at_mean, 0);
let r2 = dz(&doubled, 9) / dz(&doubled, 0);
assert!((r1 - r2).abs() < 1e-9, "shape ratio drifted: {r1} vs {r2}");
let split = t
.realize(&RealizationDraw::new(100.0, 70.0, 5100.0, 0.25, 0.8, 0.3, 2).with_goc(5030.0))
.unwrap()
.in_place()
.unwrap();
let (g, o) = (split.gas.unwrap(), split.oil.unwrap());
assert!(g.cells > 0 && o.cells > 0);
assert!((split.hcpv_m3 - (g.hcpv_m3 + o.hcpv_m3)).abs() <= 1e-9 * split.hcpv_m3);
}
fn sparse_thin_wireframe(n: usize, sep_m: f64) -> Wireframe {
let mut top = vec![f64::NAN; n * n];
let mut base = vec![f64::NAN; n * n];
let defined = [
(0, 0, 5000.0),
(n - 1, 0, 5020.0),
(0, n - 1, 5010.0),
(n - 1, n - 1, 5030.0),
(n / 2, n / 2, 4995.0),
];
for &(c, r, z) in &defined {
top[r * n + c] = z;
base[r * n + c] = z + sep_m;
}
let mut wf = flat_wireframe(n, 5000.0, 9000.0);
std::sync::Arc::make_mut(&mut wf.horizons)[0].surface = GriddedDepth {
ncol: n,
nrow: n,
depth_m: top,
is_control: vec![true; n * n],
};
std::sync::Arc::make_mut(&mut wf.horizons).push(Horizon {
name: "base".into(),
role: HorizonRole::Base,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: base,
is_control: vec![true; n * n],
},
});
wf
}
#[test]
fn thin_sparse_template_at_mean_gross_matches_deterministic_build() {
let wf = sparse_thin_wireframe(11, 2.0);
let det = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let draw = RealizationDraw::new(100.0, 2.0, 9000.0, 0.25, 0.8, 0.3, 0);
let real = t.realize(&draw).unwrap();
let (a, b) = (det.bulk_volume(), real.bulk_volume());
assert!(a > 0.0, "thin column has positive bulk");
assert!(
(a - b).abs() <= 1e-9 * a,
"thin sparse template diverged from build: {a} vs {b}"
);
use crate::grid::Ijk;
for ijk in [Ijk::new(0, 0, 0), Ijk::new(5, 5, 0), Ijk::new(9, 9, 0)] {
let (dd, rd) = (det.grid().cell(ijk).dz(), real.grid().cell(ijk).dz());
assert!(
(dd - rd).abs() <= 1e-9 * dd.max(1.0),
"cell dz {dd} vs {rd}"
);
}
}
fn crossing_wireframe(n: usize, top_m: f64) -> Wireframe {
let mut base_depth = vec![0.0; n * n];
for r in 0..n {
for c in 0..n {
base_depth[r * n + c] = if c < n / 2 {
top_m - 10.0
} else {
top_m + 10.0
};
}
}
let mut wf = flat_wireframe(n, top_m, top_m + 1000.0);
std::sync::Arc::make_mut(&mut wf.horizons).push(Horizon {
name: "base".into(),
role: HorizonRole::Base,
surface: GriddedDepth {
ncol: n,
nrow: n,
depth_m: base_depth,
is_control: vec![true; n * n],
},
});
wf
}
#[test]
fn crossing_base_errors_by_default() {
use crate::error::StaticError;
let wf = crossing_wireframe(11, 5000.0);
let err = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap_err();
match err {
StaticError::CrossedSurfaces { nodes, worst_m } => {
assert!(nodes > 0, "reports offending nodes");
assert!(worst_m < 0.0, "worst crossing is negative: {worst_m}");
}
other => panic!("expected CrossedSurfaces, got {other}"),
}
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let draw = RealizationDraw::new(100.0, 20.0, 6000.0, 0.25, 0.8, 0.3, 0);
assert!(matches!(
t.realize(&draw),
Err(StaticError::CrossedSurfaces { .. })
));
}
#[test]
fn crossing_base_clamps_offending_columns_only() {
use crate::grid::Ijk;
let wf = crossing_wireframe(11, 5000.0);
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_clamp_base_to_top(true)
.build()
.unwrap();
let dz_left = m.grid().cell(Ijk::new(0, 0, 0)).dz();
let dz_right = m.grid().cell(Ijk::new(9, 0, 0)).dz();
assert!(dz_left.abs() < 1e-6, "crossed column zeroed: dz={dz_left}");
assert!(dz_right > 1.0, "good column preserved: dz={dz_right}");
}
#[test]
fn min_thickness_repairs_crossing_and_records_a_warning() {
use crate::error::StaticError;
use crate::grid::Ijk;
use crate::model::provenance::BuildWarning;
let wf = crossing_wireframe(11, 5000.0);
assert!(matches!(
StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build(),
Err(StaticError::CrossedSurfaces { .. })
));
let min_t = 2.0;
let nk = 5; let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_min_thickness_m(min_t)
.build()
.unwrap();
let col_thick = |model: &StaticModel, i: usize| {
(0..nk)
.map(|k| model.grid().cell(Ijk::new(i, 0, k)).dz())
.sum::<f64>()
};
assert!(
(col_thick(&m, 0) - min_t).abs() < 1e-6,
"crossed column repaired to min thickness: {}",
col_thick(&m, 0)
);
let clean = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_clamp_base_to_top(true)
.build()
.unwrap();
assert_eq!(
col_thick(&m, 9),
col_thick(&clean, 9),
"non-crossing column bit-unchanged"
);
let (columns, worst_m) = m
.provenance()
.warnings
.iter()
.find_map(|w| match w {
BuildWarning::ThinColumnsRepaired { columns, worst_m } => {
Some((*columns, *worst_m))
}
_ => None,
})
.expect("a ThinColumnsRepaired warning was recorded");
assert!(columns > 0, "repaired-column count populated");
assert!(
worst_m < 0.0,
"worst violation is a true crossing (negative): {worst_m}"
);
}
#[test]
fn template_min_thickness_repairs_per_realization() {
use crate::model::provenance::BuildWarning;
let wf = crossing_wireframe(11, 5000.0);
let mut t = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_min_thickness_m(2.0);
let draw = RealizationDraw::new(100.0, 20.0, 6000.0, 0.25, 0.8, 0.3, 0);
let m = t
.realize(&draw)
.expect("min-thickness realization builds cleanly");
let (columns, worst_m) = m
.provenance()
.warnings
.iter()
.find_map(|w| match w {
BuildWarning::ThinColumnsRepaired { columns, worst_m } => {
Some((*columns, *worst_m))
}
_ => None,
})
.expect("a ThinColumnsRepaired warning was recorded on the realization");
assert!(columns > 0, "repaired-column count populated");
assert!(worst_m < 0.0, "worst violation negative: {worst_m}");
}
#[test]
fn no_base_template_keeps_constant_offset() {
let wf = flat_wireframe(11, 5000.0, 6000.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let m = t
.realize(&RealizationDraw::new(
100.0, 50.0, 6000.0, 0.25, 0.8, 0.3, 0,
))
.unwrap();
let expected = 100.0 * 50.0;
assert!((m.bulk_volume() - expected).abs() / expected < 1e-9);
}
#[test]
fn uniform_trend_is_a_noop() {
let wf = flat_wireframe(11, 5000.0, 6000.0);
let trend = TrendSurface::new(10, 10, vec![1.0; 100]).unwrap();
let plain = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let trended = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_areal_trend(trend)
.build()
.unwrap();
let a = &plain.property("NTG").unwrap().values;
let b = &trended.property("NTG").unwrap().values;
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(b) {
assert!(
(x - y).abs() < 1e-12,
"uniform trend changed NTG: {x} vs {y}"
);
}
}
#[test]
fn step_trend_shifts_ntg_preserving_mean() {
use crate::grid::Ijk;
let mut o = opts();
o.priors.net_to_gross = 0.5;
let wf = flat_wireframe(11, 5000.0, 6000.0);
let mut vals = vec![0.0; 100];
for r in 0..10 {
for c in 0..10 {
vals[r * 10 + c] = if c < 5 { 1.0 } else { 2.0 };
}
}
let trend = TrendSurface::new(10, 10, vals).unwrap();
let m = StaticModelBuilder::from_wireframe(&wf, o)
.unwrap()
.with_areal_trend(trend)
.build()
.unwrap();
let ntg = &m.property("NTG").unwrap().values;
let dims = m.grid().dims();
let at = |i, j, k| ntg[dims.linear(Ijk::new(i, j, k)).unwrap()];
assert!(
(at(0, 0, 0) - 0.5 * (2.0 / 3.0)).abs() < 1e-9,
"left NTG {}",
at(0, 0, 0)
);
assert!(
(at(9, 0, 0) - 0.5 * (4.0 / 3.0)).abs() < 1e-9,
"right NTG {}",
at(9, 0, 0)
);
let mean = ntg.iter().sum::<f64>() / ntg.len() as f64;
assert!(
(mean - 0.5).abs() < 1e-9,
"trend shifted the field mean to {mean}"
);
}
#[test]
fn trend_with_porosity_flag_modulates_both_cubes() {
let mut o = opts();
o.priors.net_to_gross = 0.4;
o.priors.porosity = 0.2;
let wf = flat_wireframe(11, 5000.0, 6000.0);
let mut vals = vec![1.0; 100];
for r in 0..10 {
vals[r * 10 + 9] = 1.5; }
let trend = TrendSurface::new(10, 10, vals).unwrap().with_porosity();
let m = StaticModelBuilder::from_wireframe(&wf, o)
.unwrap()
.with_areal_trend(trend)
.build()
.unwrap();
let poro = &m.property("PORO").unwrap().values;
let pmin = poro.iter().cloned().fold(f64::INFINITY, f64::min);
let pmax = poro.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(pmax > pmin + 1e-6, "porosity should vary with the flag on");
}
#[test]
fn two_contact_framework_splits_in_place() {
let mut wf = flat_wireframe(11, 5000.0, 5040.0); wf.contacts.insert(
0,
Contact {
kind: ContactKind::Goc,
depth_m: 5020.0,
hardness: Hardness::Hard,
},
);
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let ip = m.in_place().unwrap();
let gas = ip.gas.expect("gas zone present");
let oil = ip.oil.expect("oil zone present");
assert_eq!(gas.cells, 10 * 10 * 2, "2 gas layers");
assert_eq!(oil.cells, 10 * 10 * 2, "2 oil layers");
assert!((ip.hcpv_m3 - (gas.hcpv_m3 + oil.hcpv_m3)).abs() < 1e-6);
assert!(ip.gas_zone_ogip_sm3(GasFvf::new(0.004).unwrap()) > 0.0);
assert!(ip.oil_zone_ooip_sm3(OilFvf::new(1.25).unwrap()) > 0.0);
}
#[test]
fn sw_gas_override_lowers_gas_zone_ogip() {
let mut wf = flat_wireframe(11, 5000.0, 5040.0);
wf.contacts.insert(
0,
Contact {
kind: ContactKind::Goc,
depth_m: 5020.0,
hardness: Hardness::Hard,
},
);
let base = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap()
.in_place()
.unwrap();
let over = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.with_sw_gas(0.1) .build()
.unwrap()
.in_place()
.unwrap();
assert!(
over.gas.unwrap().hcpv_m3 > base.gas.unwrap().hcpv_m3,
"lower gas Sw raises gas HCPV"
);
assert!(
(over.oil.unwrap().hcpv_m3 - base.oil.unwrap().hcpv_m3).abs() < 1e-6,
"oil leg untouched by sw_gas"
);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let draw = RealizationDraw::new(100.0, 50.0, 5040.0, 0.25, 0.8, 0.3, 0)
.with_goc(5020.0)
.with_sw_gas(0.1);
let tip = t.realize(&draw).unwrap().in_place().unwrap();
assert!(tip.gas.unwrap().hcpv_m3 > base.gas.unwrap().hcpv_m3);
}
#[test]
fn in_place_summary_matches_full_aggregates() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let m = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap();
let full = m.in_place().unwrap();
let sum = m.in_place_summary().unwrap();
assert!((full.grv_m3 - sum.grv_m3).abs() < 1e-9);
assert!((full.hcpv_m3 - sum.hcpv_m3).abs() < 1e-9);
assert_eq!(full.cells_in_column, sum.cells_in_column);
assert!(!full.per_cell_hcpv.is_empty());
assert!(
sum.per_cell_hcpv.is_empty(),
"summary skips the per-cell cube"
);
let mut wf2 = flat_wireframe(11, 5000.0, 5040.0);
wf2.contacts.insert(
0,
Contact {
kind: ContactKind::Goc,
depth_m: 5020.0,
hardness: Hardness::Hard,
},
);
let m2 = StaticModelBuilder::from_wireframe(&wf2, opts())
.unwrap()
.build()
.unwrap();
let f2 = m2.in_place().unwrap();
let s2 = m2.in_place_summary().unwrap();
assert!((f2.gas.unwrap().hcpv_m3 - s2.gas.unwrap().hcpv_m3).abs() < 1e-9);
assert!((f2.oil.unwrap().hcpv_m3 - s2.oil.unwrap().hcpv_m3).abs() < 1e-9);
assert!(s2.per_cell_hcpv.is_empty());
}
#[test]
fn single_contact_framework_stays_generic() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let ip = StaticModelBuilder::from_wireframe(&wf, opts())
.unwrap()
.build()
.unwrap()
.in_place()
.unwrap();
assert!(ip.gas.is_none() && ip.oil.is_none());
assert!(ip.ooip_sm3(OilFvf::new(1.25).unwrap()) > 0.0);
}
#[test]
fn draw_with_goc_realizes_a_two_contact_model() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let draw = RealizationDraw::new(100.0, 50.0, 5040.0, 0.25, 0.8, 0.3, 0).with_goc(5020.0);
let ip = t.realize(&draw).unwrap().in_place().unwrap();
assert!(
ip.gas.is_some() && ip.oil.is_some(),
"GOC draw splits the column"
);
assert!(ip.gas.unwrap().cells > 0 && ip.oil.unwrap().cells > 0);
let bad = RealizationDraw::new(100.0, 50.0, 5030.0, 0.25, 0.8, 0.3, 1).with_goc(5060.0);
assert!(t.realize(&bad).is_err());
}
#[test]
fn template_realizes_n100_and_aggregates_a_p_curve() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let boi = OilFvf::new(1.25).unwrap();
let mut ooip = Vec::with_capacity(100);
for i in 0..100u64 {
let f = (i % 10) as f64 / 10.0; let draw = RealizationDraw::new(
90.0 + 20.0 * f, 45.0 + 10.0 * f, 5020.0 + 10.0 * f, 0.20 + 0.10 * f, 0.75 + 0.10 * f, 0.25 + 0.10 * f, i, );
let m = t.realize(&draw).unwrap();
assert_eq!(m.provenance().realization.as_ref().unwrap().seed_index, i);
ooip.push(m.in_place().unwrap().ooip_sm3(boi));
}
let s = PercentileSummary::from_realizations(&ooip).unwrap();
assert!(s.p90 < s.p50 && s.p50 < s.p10, "P-curve ordered: {s:?}");
assert!(s.p90 > 0.0);
}
#[test]
fn template_areal_trend_shifts_ntg_preserving_mean() {
use crate::grid::Ijk;
let wf = flat_wireframe(11, 5000.0, 6000.0);
let mut vals = vec![0.0; 100];
for r in 0..10 {
for c in 0..10 {
vals[r * 10 + c] = if c < 5 { 1.0 } else { 2.0 };
}
}
let trend = TrendSurface::new(10, 10, vals).unwrap();
let mut t = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_areal_trend(trend);
let draw = RealizationDraw::new(100.0, 50.0, 6000.0, 0.25, 0.5, 0.3, 0);
let m = t.realize(&draw).unwrap();
let ntg = &m.property("NTG").unwrap().values;
let dims = m.grid().dims();
let at = |i, j, k| ntg[dims.linear(Ijk::new(i, j, k)).unwrap()];
assert!(
(at(0, 0, 0) - 0.5 * (2.0 / 3.0)).abs() < 1e-9,
"left {}",
at(0, 0, 0)
);
assert!(
(at(9, 0, 0) - 0.5 * (4.0 / 3.0)).abs() < 1e-9,
"right {}",
at(9, 0, 0)
);
let mean = ntg.iter().sum::<f64>() / ntg.len() as f64;
assert!((mean - 0.5).abs() < 1e-9, "field mean drifted to {mean}");
}
#[test]
fn identical_draws_realize_identical_models() {
let wf = flat_wireframe(9, 5000.0, 5025.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let draw = RealizationDraw::new(100.0, 50.0, 5025.0, 0.25, 0.8, 0.3, 7);
let a = t.realize(&draw).unwrap().in_place().unwrap().hcpv_m3;
let b = t.realize(&draw).unwrap().in_place().unwrap().hcpv_m3;
assert!(
(a - b).abs() <= 1e-6 * a.abs(),
"chained identical draws diverged: {a} vs {b}"
);
}
#[test]
fn structural_perturbation_shifts_a_control() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let nominal = RealizationDraw::new(100.0, 50.0, 5025.0, 0.25, 0.8, 0.3, 0);
let before = t.realize(&nominal).unwrap().in_place().unwrap().hcpv_m3;
let crest = nominal.clone().with_structural(StructuralPerturbation {
control_shifts: vec![(5, 5, -20.0)],
});
let after = t.realize(&crest).unwrap().in_place().unwrap().hcpv_m3;
assert!(
after > before,
"crest shift should raise in-place: {before} -> {after}"
);
let bad = nominal.with_structural(StructuralPerturbation {
control_shifts: vec![(99, 99, -20.0)],
});
assert!(t.realize(&bad).is_err());
}
fn assert_models_bit_identical(a: &StaticModel, b: &StaticModel, ctx: &str) {
assert_eq!(
a.grid().cell_count(),
b.grid().cell_count(),
"{ctx}: cell count"
);
for lin in 0..a.grid().cell_count() {
assert_eq!(
a.grid().cell_centroid_z_at(lin).to_bits(),
b.grid().cell_centroid_z_at(lin).to_bits(),
"{ctx}: ZCORN centroid-z diverged at cell {lin}"
);
assert_eq!(
a.grid().cell_volume_at(lin).to_bits(),
b.grid().cell_volume_at(lin).to_bits(),
"{ctx}: cell volume diverged at cell {lin}"
);
}
for name in ["PORO", "SW", "NTG"] {
assert_eq!(
a.property(name).map(|p| &p.values),
b.property(name).map(|p| &p.values),
"{ctx}: cube {name} diverged"
);
}
}
#[test]
fn realize_into_recycles_buffers_without_staleness() {
let wf = flat_wireframe(11, 5000.0, 5025.0);
let poro = || {
use petektools::{Variogram, VariogramModel};
let low = WellLog::new(0.5, 0.5, vec![(5005.0, 0.10), (5045.0, 0.18)]);
let high = WellLog::new(9.5, 9.5, vec![(5005.0, 0.26), (5045.0, 0.22)]);
let vgm = Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 5.0).unwrap();
PropertyPipeline::new("PORO")
.upscale(vec![low, high], UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(vgm, 42).allow_mean_fill())
};
let draw_a = RealizationDraw::new(100.0, 50.0, 5025.0, 0.20, 0.80, 0.30, 1)
.with_property_shift("PORO", -0.03);
let draw_b = RealizationDraw::new(120.0, 80.0, 5030.0, 0.26, 0.85, 0.25, 2)
.with_property_shift("PORO", 0.04);
let mut t_ref = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property(poro());
let model_a = t_ref.realize(&draw_a).unwrap();
let model_ref_b = t_ref.realize(&draw_b).unwrap();
let mut t_rec = StaticModelTemplate::new(&wf, opts())
.unwrap()
.with_property(poro());
let mut m = t_rec.reusable_model();
t_rec.realize_into(&draw_a, &mut m).unwrap();
assert_ne!(
m.property("PORO").unwrap().values,
model_ref_b.property("PORO").unwrap().values,
"draws A and B must differ for the staleness test to bite"
);
assert_models_bit_identical(&model_a, &m, "draw A: recycled vs fresh");
t_rec.realize_into(&draw_b, &mut m).unwrap();
assert_models_bit_identical(&model_ref_b, &m, "draw B: recycled (post-A) vs fresh");
}
#[test]
fn realize_rejects_a_garbage_draw() {
let wf = flat_wireframe(9, 5000.0, 5025.0);
let mut t = StaticModelTemplate::new(&wf, opts()).unwrap();
let mut bad = RealizationDraw::new(100.0, 50.0, 5025.0, -0.1, 0.8, 0.3, 0);
assert!(t.realize(&bad).is_err()); bad.porosity = 0.25;
bad.water_saturation = 1.2;
assert!(t.realize(&bad).is_err()); bad.water_saturation = 0.3;
bad.area_m2 = 0.0;
assert!(t.realize(&bad).is_err()); bad.area_m2 = 100.0;
bad.contact_depth_m = f64::INFINITY;
assert!(t.realize(&bad).is_err()); }
}