use crate::error::StaticError;
use crate::grid::{Grid, Ijk, Property};
use crate::model::model::Georef;
use crate::model::trend::TrendSurface;
use crate::petro::{arithmetic_mean, geometric_mean, harmonic_mean, WeightedSample};
use petektools::geostat::{sgs_seeded, SgsParams};
use petektools::{Lattice, Variogram};
use rayon::prelude::*;
const SEED_GOLDEN: u64 = 0x9E37_79B9_7F4A_7C15;
const COLLOCATED_MIN_COVERAGE: f64 = 0.5;
#[derive(Debug, Clone, PartialEq)]
pub struct WellLog {
pub x: f64,
pub y: f64,
pub samples: Vec<(f64, f64)>,
}
impl WellLog {
#[must_use]
pub fn new(x: f64, y: f64, samples: Vec<(f64, f64)>) -> Self {
Self { x, y, samples }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum McMode {
#[default]
LevelShift,
Resimulate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpscaleMethod {
Arithmetic,
Harmonic,
Geometric,
}
impl UpscaleMethod {
fn apply(self, values: &[f64]) -> Result<f64, StaticError> {
let s: Vec<WeightedSample> = values
.iter()
.map(|&v| WeightedSample::new(1.0, v))
.collect();
match self {
UpscaleMethod::Arithmetic => arithmetic_mean(&s),
UpscaleMethod::Harmonic => harmonic_mean(&s),
UpscaleMethod::Geometric => geometric_mean(&s),
}
}
}
#[derive(Debug, Clone)]
pub struct Gaussian {
variogram: Variogram,
seed: u64,
search: Option<(usize, f64)>,
trend: Option<(TrendSurface, f64)>,
allow_mean_fill: bool,
unbounded_search: bool,
}
const DEFAULT_MAX_NEIGHBOURS: usize = 16;
impl Gaussian {
#[must_use]
pub fn new(variogram: Variogram, seed: u64) -> Self {
Self {
variogram,
seed,
search: None,
trend: None,
allow_mean_fill: false,
unbounded_search: false,
}
}
#[must_use]
pub fn with_search(mut self, max_neighbours: usize, radius_m: f64) -> Self {
self.search = Some((max_neighbours, radius_m));
self
}
#[must_use]
pub fn with_unbounded_search(mut self) -> Self {
self.unbounded_search = true;
self
}
#[must_use]
pub fn allow_mean_fill(mut self) -> Self {
self.allow_mean_fill = true;
self
}
#[must_use]
pub fn with_trend(mut self, trend: TrendSurface, corr: f64) -> Self {
self.trend = Some((trend, corr));
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct UpscaleQc {
pub property: String,
pub conditioned_cells: usize,
pub log_samples: usize,
pub log_mean: f64,
pub upscaled_mean: f64,
pub upscaled_min: f64,
pub upscaled_max: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PropertyReport {
pub property: String,
pub upscale: UpscaleQc,
pub propagated: bool,
}
#[derive(Debug, Clone)]
pub struct PropertyPipeline {
name: String,
upscale: Option<(Vec<WellLog>, UpscaleMethod)>,
propagate: Option<Gaussian>,
}
impl PropertyPipeline {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
upscale: None,
propagate: None,
}
}
#[must_use]
pub fn upscale(mut self, wells: Vec<WellLog>, method: UpscaleMethod) -> Self {
self.upscale = Some((wells, method));
self
}
#[must_use]
pub fn propagate(mut self, gaussian: Gaussian) -> Self {
self.propagate = Some(gaussian);
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub(crate) fn reseeded(&self, salt: u64) -> Self {
let mut p = self.clone();
if let Some(g) = p.propagate {
p.propagate = Some(Gaussian {
seed: g.seed ^ salt.wrapping_mul(SEED_GOLDEN),
..g
});
}
p
}
pub fn upscale_cells(&self, grid: &Grid) -> Result<(Vec<f64>, UpscaleQc), StaticError> {
let (wells, method) = self.upscale.as_ref().ok_or_else(|| {
StaticError::InvalidInput(format!(
"property '{}' has no upscale step (call .upscale(...))",
self.name
))
})?;
let dims = grid.dims();
let (ni, nj, nk) = (dims.ni, dims.nj, dims.nk);
let lattice = areal_lattice(grid)?;
let mut cells = vec![f64::NAN; ni * nj * nk];
let mut log_values: Vec<f64> = Vec::new();
for well in wells {
let Some((fi, fj)) = lattice.xy_to_ij(well.x, well.y) else {
continue;
};
let (fi, fj) = (fi.round(), fj.round());
if fi < 0.0 || fj < 0.0 {
continue;
}
let (i, j) = (fi as usize, fj as usize);
if i >= ni || j >= nj {
continue;
}
for k in 0..nk {
let cell = grid.cell(Ijk::new(i, j, k));
let (t, b) = cell_depth_at_xy(&cell, well.x, well.y);
let (lo, hi) = (t.min(b), t.max(b));
let in_range: Vec<f64> = well
.samples
.iter()
.filter(|(tvd, _)| *tvd >= lo && *tvd <= hi)
.map(|(_, v)| *v)
.collect();
if !in_range.is_empty() {
log_values.extend_from_slice(&in_range);
cells[(k * nj + j) * ni + i] = method.apply(&in_range)?;
}
}
}
let qc = upscale_qc(&self.name, &cells, &log_values);
Ok((cells, qc))
}
pub fn apply(&self, grid: &mut Grid) -> Result<PropertyReport, StaticError> {
self.apply_with_georef(grid, None)
}
pub(crate) fn apply_with_georef(
&self,
grid: &mut Grid,
georef: Option<Georef>,
) -> Result<PropertyReport, StaticError> {
let (cells, qc) = self.upscale_cells(grid)?;
let propagated = match &self.propagate {
None => {
grid.properties_mut().set(Property {
name: self.name.clone(),
values: cells,
})?;
false
}
Some(g) => {
let values = propagate_sgs(grid, &self.name, &cells, g, georef)?;
grid.properties_mut().set(Property {
name: self.name.clone(),
values,
})?;
true
}
};
Ok(PropertyReport {
property: self.name.clone(),
upscale: qc,
propagated,
})
}
pub fn apply_in_zone(
&self,
grid: &mut Grid,
k_range: core::ops::Range<usize>,
) -> Result<PropertyReport, StaticError> {
self.apply_in_zone_with_georef(grid, k_range, None)
}
pub(crate) fn apply_in_zone_with_georef(
&self,
grid: &mut Grid,
k_range: core::ops::Range<usize>,
georef: Option<Georef>,
) -> Result<PropertyReport, StaticError> {
let dims = grid.dims();
let (ni, nj, nk) = (dims.ni, dims.nj, dims.nk);
let (mut cells, _) = self.upscale_cells(grid)?;
for k in 0..nk {
if !k_range.contains(&k) {
for j in 0..nj {
for i in 0..ni {
cells[(k * nj + j) * ni + i] = f64::NAN;
}
}
}
}
let log_values: Vec<f64> = cells.iter().copied().filter(|v| v.is_finite()).collect();
let qc = upscale_qc(&self.name, &cells, &log_values);
let mut base = grid
.properties()
.get(&self.name)
.map(|p| p.values.clone())
.unwrap_or_else(|| vec![f64::NAN; ni * nj * nk]);
let propagated = match &self.propagate {
None => {
for k in k_range.clone() {
for j in 0..nj {
for i in 0..ni {
let idx = (k * nj + j) * ni + i;
if cells[idx].is_finite() {
base[idx] = cells[idx];
}
}
}
}
false
}
Some(g) => {
propagate_sgs_into(grid, &self.name, &cells, g, k_range, &mut base, georef)?;
true
}
};
grid.properties_mut().set(Property {
name: self.name.clone(),
values: base,
})?;
Ok(PropertyReport {
property: self.name.clone(),
upscale: qc,
propagated,
})
}
}
fn cell_depth_at_xy(cell: &crate::grid::Cell, wx: f64, wy: f64) -> (f64, f64) {
let c = &cell.corners;
let (x0, x1) = (c[0].x, c[1].x);
let (y0, y1) = (c[0].y, c[2].y);
let frac = |q: f64, a: f64, b: f64| {
let d = b - a;
if d.abs() > f64::EPSILON {
((q - a) / d).clamp(0.0, 1.0)
} else {
0.5
}
};
let (tx, ty) = (frac(wx, x0, x1), frac(wy, y0, y1));
let bilinear = |z00: f64, z10: f64, z01: f64, z11: f64| {
(1.0 - tx) * (1.0 - ty) * z00
+ tx * (1.0 - ty) * z10
+ (1.0 - tx) * ty * z01
+ tx * ty * z11
};
let top = bilinear(c[0].z, c[1].z, c[2].z, c[3].z);
let bottom = bilinear(c[4].z, c[5].z, c[6].z, c[7].z);
(top, bottom)
}
pub(crate) fn areal_lattice(grid: &Grid) -> Result<Lattice, StaticError> {
let dims = grid.dims();
let (ni, nj) = (dims.ni, dims.nj);
if ni < 2 || nj < 2 {
return Err(StaticError::InvalidInput(format!(
"property pipeline needs an areal lattice of at least 2x2 columns, got {ni}x{nj}"
)));
}
let c00 = grid.cell(Ijk::new(0, 0, 0)).centroid();
let c10 = grid.cell(Ijk::new(1, 0, 0)).centroid();
let c01 = grid.cell(Ijk::new(0, 1, 0)).centroid();
let dx = c10.x - c00.x;
let dy = c01.y - c00.y;
if !(dx.is_finite() && dx > 0.0 && dy.is_finite() && dy > 0.0) {
return Err(StaticError::InvalidInput(format!(
"property pipeline needs a regular axis-aligned column lattice (node spacing dx={dx}, dy={dy})"
)));
}
Ok(Lattice::regular(c00.x, c00.y, dx, dy, ni, nj))
}
fn propagate_sgs(
grid: &Grid,
property: &str,
cells: &[f64],
g: &Gaussian,
georef: Option<Georef>,
) -> Result<Vec<f64>, StaticError> {
let dims = grid.dims();
let mut base = vec![f64::NAN; dims.ni * dims.nj * dims.nk];
propagate_sgs_into(grid, property, cells, g, 0..dims.nk, &mut base, georef)?;
Ok(base)
}
fn propagate_sgs_into(
grid: &Grid,
property: &str,
cells: &[f64],
g: &Gaussian,
k_range: core::ops::Range<usize>,
base: &mut [f64],
georef: Option<Georef>,
) -> Result<(), StaticError> {
let dims = grid.dims();
let (ni, nj) = (dims.ni, dims.nj);
let lattice = areal_lattice(grid)?;
let conditioned: Vec<f64> = k_range
.clone()
.flat_map(|k| (0..ni * nj).map(move |c| cells[k * ni * nj + c]))
.filter(|v| v.is_finite())
.collect();
if conditioned.is_empty() {
return Err(StaticError::InvalidInput(format!(
"property '{property}': no conditioning data in the simulated range \
(upscale produced no informed cells) — the wells' samples do not fall in \
any cell of this range. Check the well positions/depths against the grid \
(and, for a zone-scoped pipe, that the wells penetrate the zone)."
)));
}
let global_mean = conditioned.iter().sum::<f64>() / conditioned.len() as f64;
let mut column_xy = vec![(0.0, 0.0); ni * nj];
for j in 0..nj {
for i in 0..ni {
let c = grid.cell(Ijk::new(i, j, 0)).centroid();
column_xy[j * ni + i] = (c.x, c.y);
}
}
let (max_neighbours, radius) = g.search.unwrap_or_else(|| {
let spacing = lattice.xinc.max(lattice.yinc);
if g.unbounded_search {
let extent = ((ni as f64) * lattice.xinc).hypot((nj as f64) * lattice.yinc);
(DEFAULT_MAX_NEIGHBOURS, extent.max(spacing))
} else {
let radius = (g.variogram.range * 1.5).max(spacing * 4.0);
(DEFAULT_MAX_NEIGHBOURS, radius)
}
});
let secondary = match &g.trend {
None => None,
Some((trend, corr)) => {
let sample_lattice = match georef {
Some(gr) if trend.is_georeferenced() => {
Lattice::regular(gr.origin_x, gr.origin_y, gr.spacing_x, gr.spacing_y, ni, nj)
}
_ => lattice.clone(),
};
let field = trend.resample_to(&sample_lattice)?;
let finite = field.iter().filter(|v| v.is_finite()).count();
let total = field.len().max(1);
if (finite as f64) < COLLOCATED_MIN_COVERAGE * total as f64 {
return Err(StaticError::InvalidInput(format!(
"collocated trend covers only {finite}/{total} areal nodes (< {:.0}% of the \
model frame) — the trend's georeference does not overlap the grid. A \
world-georeferenced trend needs the model's world georef (build with \
`with_georef`); a local trend must match the grid's local lattice.",
COLLOCATED_MIN_COVERAGE * 100.0
)));
}
Some((field, *corr))
}
};
let params = SgsParams {
variogram: g.variogram,
max_neighbours,
radius,
seed: g.seed, collocated: secondary,
};
let layers: Vec<usize> = k_range.collect();
let fields: Vec<(usize, Vec<f64>)> = layers
.par_iter()
.map(|&k| -> Result<(usize, Vec<f64>), StaticError> {
let mut coords: Vec<[f64; 3]> = Vec::new();
for j in 0..nj {
for i in 0..ni {
let v = cells[(k * nj + j) * ni + i];
if v.is_finite() {
let (x, y) = column_xy[j * ni + i];
coords.push([x, y, v]);
}
}
}
let mut layer = vec![0.0_f64; ni * nj];
if coords.is_empty() {
if !g.allow_mean_fill {
return Err(StaticError::InvalidInput(format!(
"property '{property}': simulated layer {k} has no conditioning data \
— a silent constant mean-fill would erase its spatial structure. \
Ensure the wells condition every simulated layer, or opt into the \
structureless mean-fill with `Gaussian::allow_mean_fill`."
)));
}
layer.iter_mut().for_each(|v| *v = global_mean);
return Ok((k, layer));
}
let seed = g.seed ^ (k as u64).wrapping_add(1).wrapping_mul(SEED_GOLDEN);
let field = sgs_seeded(&coords, &lattice, ¶ms, seed).map_err(|e| {
StaticError::Grid(format!("SGS propagate failed on layer {k}: {e}"))
})?;
for j in 0..nj {
for i in 0..ni {
layer[j * ni + i] = field[[i, j]];
}
}
Ok((k, layer))
})
.collect::<Result<Vec<_>, StaticError>>()?;
for (k, layer) in fields {
base[k * ni * nj..(k + 1) * ni * nj].copy_from_slice(&layer);
}
Ok(())
}
fn upscale_qc(property: &str, cells: &[f64], log_values: &[f64]) -> UpscaleQc {
let conditioned: Vec<f64> = cells.iter().copied().filter(|v| v.is_finite()).collect();
let mean = |v: &[f64]| {
if v.is_empty() {
f64::NAN
} else {
v.iter().sum::<f64>() / v.len() as f64
}
};
let (mut min, mut max) = (f64::NAN, f64::NAN);
if !conditioned.is_empty() {
min = conditioned.iter().copied().fold(f64::INFINITY, f64::min);
max = conditioned
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
}
UpscaleQc {
property: property.to_string(),
conditioned_cells: conditioned.len(),
log_samples: log_values.len(),
log_mean: mean(log_values),
upscaled_mean: mean(&conditioned),
upscaled_min: min,
upscaled_max: max,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grid::{build_box, BoxSpec, Dims};
use petektools::VariogramModel;
const NI: usize = 5;
const NJ: usize = 5;
const NK: usize = 4;
fn test_grid() -> Grid {
build_box(BoxSpec::square(
10_000.0,
40.0,
Dims::new(NI, NJ, NK).unwrap(),
))
.unwrap()
}
fn two_wells() -> Vec<WellLog> {
let low = WellLog::new(
10.0,
10.0,
vec![(5.0, 0.10), (15.0, 0.12), (25.0, 0.14), (35.0, 0.16)],
);
let high = WellLog::new(
90.0,
90.0,
vec![(5.0, 0.28), (15.0, 0.26), (25.0, 0.24), (35.0, 0.22)],
);
vec![low, high]
}
fn nscore_variogram() -> Variogram {
Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 50.0).unwrap()
}
fn idx(i: usize, j: usize, k: usize) -> usize {
(k * NJ + j) * NI + i
}
#[test]
fn upscale_honours_logs_at_well_columns() {
let grid = test_grid();
let pipe = PropertyPipeline::new("PHIE").upscale(two_wells(), UpscaleMethod::Arithmetic);
let (cells, qc) = pipe.upscale_cells(&grid).unwrap();
for (k, v) in [(0, 0.10), (1, 0.12), (2, 0.14), (3, 0.16)] {
assert!(
(cells[idx(0, 0, k)] - v).abs() < 1e-12,
"cell (0,0,{k}) = {} != {v}",
cells[idx(0, 0, k)]
);
}
for (k, v) in [(0, 0.28), (1, 0.26), (2, 0.24), (3, 0.22)] {
assert!((cells[idx(4, 4, k)] - v).abs() < 1e-12);
}
assert!(cells[idx(2, 2, 0)].is_nan());
assert_eq!(qc.conditioned_cells, 8);
assert_eq!(qc.log_samples, 8);
assert!((qc.log_mean - qc.upscaled_mean).abs() < 1e-12);
assert!((qc.upscaled_min - 0.10).abs() < 1e-12);
assert!((qc.upscaled_max - 0.28).abs() < 1e-12);
}
#[test]
fn upscale_averages_multiple_samples_in_a_cell() {
let grid = test_grid();
let well = WellLog::new(10.0, 10.0, vec![(2.0, 0.18), (5.0, 0.20), (8.0, 0.22)]);
let pipe = PropertyPipeline::new("PHIE").upscale(vec![well], UpscaleMethod::Arithmetic);
let (cells, qc) = pipe.upscale_cells(&grid).unwrap();
assert!((cells[idx(0, 0, 0)] - 0.20).abs() < 1e-12);
assert_eq!(qc.conditioned_cells, 1);
assert_eq!(qc.log_samples, 3);
}
#[test]
fn sgs_propagation_honours_conditioning_and_fills_every_cell() {
let mut grid = test_grid();
let pipe = PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 1));
let report = pipe.apply(&mut grid).unwrap();
assert!(report.propagated);
let prop = grid.properties().get("PHIE").unwrap();
assert!(prop.values.iter().all(|v| v.is_finite()), "cube has NaN");
for k in 0..NK {
let lo = [0.10, 0.12, 0.14, 0.16][k];
let hi = [0.28, 0.26, 0.24, 0.22][k];
assert!(
(prop.values[idx(0, 0, k)] - lo).abs() < 1e-6,
"well (0,0,{k}) not honoured: {}",
prop.values[idx(0, 0, k)]
);
assert!((prop.values[idx(4, 4, k)] - hi).abs() < 1e-6);
}
}
#[test]
fn sgs_reproduces_the_data_histogram_loosely() {
let mut grid = test_grid();
let pipe = PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 7));
pipe.apply(&mut grid).unwrap();
let prop = grid.properties().get("PHIE").unwrap();
let field_mean = prop.values.iter().sum::<f64>() / prop.values.len() as f64;
assert!((field_mean - 0.19).abs() < 0.05, "field mean {field_mean}");
assert!(
prop.values.iter().all(|&v| (0.09..=0.29).contains(&v)),
"field escaped the data range"
);
}
#[test]
fn sgs_is_seeded_reproducible_and_seed_sensitive() {
let run = |seed: u64| {
let mut grid = test_grid();
PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), seed))
.apply(&mut grid)
.unwrap();
grid.properties().get("PHIE").unwrap().values.clone()
};
assert_eq!(
run(1),
run(1),
"same seed must reproduce the cube bit-for-bit"
);
assert_ne!(
run(1),
run(2),
"a different seed must give a different field"
);
}
#[test]
fn propagate_needs_conditioning_data() {
let mut grid = test_grid();
let off = WellLog::new(1e6, 1e6, vec![(5.0, 0.2)]);
let pipe = PropertyPipeline::new("PHIE")
.upscale(vec![off], UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 1));
assert!(pipe.apply(&mut grid).is_err());
}
#[test]
fn apply_without_propagate_sets_only_conditioned_cells() {
let mut grid = test_grid();
let report = PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.apply(&mut grid)
.unwrap();
assert!(!report.propagated);
let prop = grid.properties().get("PHIE").unwrap();
assert!((prop.values[idx(0, 0, 0)] - 0.10).abs() < 1e-12);
assert!(prop.values[idx(2, 2, 0)].is_nan());
}
#[test]
fn apply_in_zone_fills_only_its_krange_and_merges_into_the_base() {
let mut grid = test_grid();
grid.properties_mut()
.set(Property::constant("PHIE", 0.5, NI * NJ * NK))
.unwrap();
let report = PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 3))
.apply_in_zone(&mut grid, 1..3)
.unwrap();
assert!(report.propagated);
let v = &grid.properties().get("PHIE").unwrap().values;
for (i, j, k) in [(0, 0, 0), (2, 2, 0), (4, 4, 3), (2, 2, 3)] {
assert!(
(v[idx(i, j, k)] - 0.5).abs() < 1e-12,
"layer {k} outside 1..3 was overwritten"
);
}
for k in 1..3 {
for j in 0..NJ {
for i in 0..NI {
assert!(v[idx(i, j, k)].is_finite());
}
}
let lo = [0.10, 0.12, 0.14, 0.16][k];
let hi = [0.28, 0.26, 0.24, 0.22][k];
assert!(
(v[idx(0, 0, k)] - lo).abs() < 1e-6,
"well (0,0,{k}) honoured"
);
assert!((v[idx(4, 4, k)] - hi).abs() < 1e-6);
}
}
#[test]
fn apply_in_zone_matches_full_apply_over_the_whole_range() {
let full = {
let mut g = test_grid();
PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 5))
.apply(&mut g)
.unwrap();
g.properties().get("PHIE").unwrap().values.clone()
};
let zoned = {
let mut g = test_grid();
PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 5))
.apply_in_zone(&mut g, 0..NK)
.unwrap();
g.properties().get("PHIE").unwrap().values.clone()
};
assert_eq!(full, zoned, "apply_in_zone(0..nk) must equal apply()");
}
#[test]
fn tiny_lattice_is_rejected() {
let grid = build_box(BoxSpec::square(10_000.0, 40.0, Dims::new(1, 1, 4).unwrap())).unwrap();
let pipe = PropertyPipeline::new("PHIE").upscale(two_wells(), UpscaleMethod::Arithmetic);
assert!(pipe.upscale_cells(&grid).is_err());
}
fn i_increasing_trend() -> TrendSurface {
let values: Vec<f64> = (0..NI * NJ).map(|k| (k % NI) as f64).collect();
TrendSurface::new(NI, NJ, values)
.unwrap()
.with_georef(10.0, 10.0, 20.0, 20.0)
}
fn wells_j_varying() -> Vec<WellLog> {
let south = WellLog::new(
50.0,
10.0,
vec![(5.0, 0.15), (15.0, 0.16), (25.0, 0.14), (35.0, 0.15)],
);
let north = WellLog::new(
50.0,
90.0,
vec![(5.0, 0.23), (15.0, 0.24), (25.0, 0.22), (35.0, 0.23)],
);
vec![south, north]
}
fn slope_vs_i(values: &[f64]) -> f64 {
let (mut sx, mut sy, mut sxy, mut sxx, mut n) = (0.0, 0.0, 0.0, 0.0, 0.0);
for k in 0..NK {
for j in 0..NJ {
for i in 0..NI {
let (x, y) = (i as f64, values[idx(i, j, k)]);
sx += x;
sy += y;
sxy += x * y;
sxx += x * x;
n += 1.0;
}
}
}
(sxy / n - (sx / n) * (sy / n)) / (sxx / n - (sx / n).powi(2))
}
#[test]
fn collocated_corr_zero_is_a_bitwise_noop() {
let plain = {
let mut g = test_grid();
PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 9))
.apply(&mut g)
.unwrap();
g.properties().get("PHIE").unwrap().values.clone()
};
let with_zero = {
let mut g = test_grid();
PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(
Gaussian::new(nscore_variogram(), 9).with_trend(i_increasing_trend(), 0.0),
)
.apply(&mut g)
.unwrap();
g.properties().get("PHIE").unwrap().values.clone()
};
assert_eq!(plain, with_zero, "corr=0 must equal plain SGS bit-for-bit");
}
#[test]
fn collocated_world_trend_without_georef_is_a_loud_error() {
let mut g = test_grid();
let world_trend =
TrendSurface::new(NI, NJ, (0..NI * NJ).map(|k| (k % NI) as f64).collect())
.unwrap()
.with_georef(431_000.0, 6_521_000.0, 20.0, 20.0);
let res = PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 9).with_trend(world_trend, 0.6))
.apply(&mut g);
assert!(
matches!(res, Err(StaticError::InvalidInput(_))),
"world trend on a local model must error, not silently no-op: {res:?}"
);
}
#[test]
fn collocated_positive_corr_shifts_the_lateral_pattern() {
let run = |trend: Option<(TrendSurface, f64)>| {
let mut g = test_grid();
let mut gauss = Gaussian::new(nscore_variogram(), 4);
if let Some((t, c)) = trend {
gauss = gauss.with_trend(t, c);
}
PropertyPipeline::new("PHIE")
.upscale(wells_j_varying(), UpscaleMethod::Arithmetic)
.propagate(gauss)
.apply(&mut g)
.unwrap();
g.properties().get("PHIE").unwrap().values.clone()
};
let plain_slope = slope_vs_i(&run(None));
let co_slope = slope_vs_i(&run(Some((i_increasing_trend(), 0.9))));
assert!(
co_slope > plain_slope,
"collocated trend should steepen the i-slope: plain {plain_slope} vs co {co_slope}"
);
assert!(co_slope > 0.0, "co-slope should track the increasing trend");
}
fn one_layer_well() -> Vec<WellLog> {
vec![WellLog::new(10.0, 10.0, vec![(5.0, 0.2)])]
}
#[test]
fn data_less_layer_is_a_loud_named_error_by_default() {
let mut grid = test_grid();
let res = PropertyPipeline::new("PHIE")
.upscale(one_layer_well(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 1))
.apply(&mut grid);
match res {
Err(StaticError::InvalidInput(m)) => {
assert!(m.contains("PHIE"), "error names the property: {m}");
assert!(m.contains("no conditioning data"), "error explains: {m}");
}
other => panic!("expected a named InvalidInput, got {other:?}"),
}
let mut grid = test_grid();
let r = PropertyPipeline::new("PHIE")
.upscale(one_layer_well(), UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 1).allow_mean_fill())
.apply(&mut grid);
assert!(r.is_ok(), "allow_mean_fill opts into the fill: {r:?}");
assert!(grid
.properties()
.get("PHIE")
.unwrap()
.values
.iter()
.all(|v| v.is_finite()));
}
#[test]
fn no_conditioning_error_names_the_property() {
let mut grid = test_grid();
let off = WellLog::new(1e6, 1e6, vec![(5.0, 0.2)]);
let res = PropertyPipeline::new("KLOGH")
.upscale(vec![off], UpscaleMethod::Arithmetic)
.propagate(Gaussian::new(nscore_variogram(), 1))
.apply(&mut grid);
match res {
Err(StaticError::InvalidInput(m)) => {
assert!(m.contains("KLOGH"), "names property: {m}")
}
other => panic!("expected named error, got {other:?}"),
}
}
#[test]
fn bounded_search_is_the_default_and_matches_unbounded() {
let run = |gauss: Gaussian| {
let mut g = test_grid();
PropertyPipeline::new("PHIE")
.upscale(two_wells(), UpscaleMethod::Arithmetic)
.propagate(gauss)
.apply(&mut g)
.unwrap();
g.properties().get("PHIE").unwrap().values.clone()
};
let bounded = run(Gaussian::new(nscore_variogram(), 11));
let unbounded = run(Gaussian::new(nscore_variogram(), 11).with_unbounded_search());
for k in 0..NK {
assert!((bounded[idx(0, 0, k)] - [0.10, 0.12, 0.14, 0.16][k]).abs() < 1e-6);
}
let maxdiff = bounded
.iter()
.zip(&unbounded)
.map(|(a, b)| (a - b).abs())
.fold(0.0f64, f64::max);
assert!(
maxdiff < 5e-3,
"bounded default should match unbounded within tolerance: maxdiff {maxdiff}"
);
}
}