use crate::core::facade::model::Model;
use crate::core::facade::project::BoreWell;
use crate::core::facade::spec::TrendSpec;
use crate::core::inplace::Fluid;
use crate::units::SrsError;
use petekio::GridGeometry;
use petekstatic::model::{
BuildOpts, ConstantPriors, Gaussian, Georef, HorizonStack, McMode, MemoryBudget,
PropertyPipeline, StackFrame, StaticModel, StaticModelBuilder, UpscaleMethod, UpscaleQc,
WellLog, WellTie,
};
use petekstatic::wireframe::{Contact, ContactKind, Hardness, Wireframe};
use petektools::Variogram;
use std::collections::BTreeMap;
pub(crate) enum Frame {
Wireframe(Wireframe),
Stack(HorizonStack),
}
#[derive(Clone)]
struct PendingPipe {
wells: Vec<WellLog>,
method: UpscaleMethod,
gaussian: Option<Gaussian>,
resimulate: bool,
}
impl Default for PendingPipe {
fn default() -> Self {
Self {
wells: Vec::new(),
method: UpscaleMethod::Arithmetic,
gaussian: None,
resimulate: false,
}
}
}
pub struct StaticGrid {
frame: Frame,
top_geom: GridGeometry,
opts: BuildOpts,
pipes: BTreeMap<String, PendingPipe>,
zone_pipes: BTreeMap<(String, String), PendingPipe>,
zone_priors: Vec<(String, ConstantPriors)>,
well_ties: Vec<WellTie>,
ties: Vec<crate::core::facade::framework::TieResidual>,
min_thickness_m: Option<f64>,
collapse_below_m: Option<f64>,
}
impl StaticGrid {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
frame: Frame,
top_geom: GridGeometry,
opts: BuildOpts,
min_thickness_m: Option<f64>,
collapse_below_m: Option<f64>,
ties: Vec<crate::core::facade::framework::TieResidual>,
zone_priors: Vec<(String, ConstantPriors)>,
well_ties: Vec<WellTie>,
) -> Self {
Self {
frame,
top_geom,
opts,
pipes: BTreeMap::new(),
zone_pipes: BTreeMap::new(),
zone_priors,
well_ties,
min_thickness_m,
collapse_below_m,
ties,
}
}
pub fn is_stacked(&self) -> bool {
matches!(self.frame, Frame::Stack(_))
}
pub fn geom(&self) -> &GridGeometry {
&self.top_geom
}
pub fn ties(&self) -> &[crate::core::facade::framework::TieResidual] {
&self.ties
}
pub fn well_ties_for(&self, well_id: &str) -> Vec<(String, f64)> {
let parent = well_id.split(' ').next().unwrap_or("");
self.ties
.iter()
.filter(|t| t.ok && (t.well_id == well_id || t.well_id == parent))
.map(|t| (t.horizon.clone(), t.residual_m))
.collect()
}
pub fn set_upscale(
&mut self,
mnemonic: &str,
wells: &[BoreWell<'_>],
method: UpscaleMethod,
net_cutoff: Option<f64>,
zone: Option<&str>,
) -> Result<usize, SrsError> {
let logs = positioned_logs(wells, mnemonic, net_cutoff);
let n: usize = logs.iter().map(|w| w.samples.len()).sum();
let registered = self.register_logs(&logs);
if n > 0 && registered.is_empty() {
return Err(SrsError::InvalidInput(format!(
"property '{mnemonic}': {n} log samples positioned, but no well maps onto the \
model grid — the well (x, y) fall outside the framework's areal extent. Check \
that the wells and horizons share a georeference/CRS (the model grid is \
area-scaled onto the horizon lattice; wells at a foreign CRS condition 0 cells)."
)));
}
let pipe = self.pipe_entry(mnemonic, zone);
pipe.wells = registered;
pipe.method = method;
Ok(n)
}
fn pipe_entry(&mut self, mnemonic: &str, zone: Option<&str>) -> &mut PendingPipe {
match zone {
Some(z) => self
.zone_pipes
.entry((z.to_string(), mnemonic.to_string()))
.or_default(),
None => self.pipes.entry(mnemonic.to_string()).or_default(),
}
}
fn register_logs(&self, logs: &[WellLog]) -> Vec<WellLog> {
let g = &self.top_geom;
let ni = g.ncol.saturating_sub(1).max(1);
let nj = g.nrow.saturating_sub(1).max(1);
let side = self.opts.area_m2.max(0.0).sqrt();
let dx = side / ni as f64;
let dy = side / nj as f64;
logs.iter()
.filter_map(|w| {
let (fi, fj) = g.xy_to_ij(w.x, w.y)?;
if !(fi.is_finite() && fj.is_finite()) {
return None;
}
if fi < -0.5 || fj < -0.5 || fi > ni as f64 - 0.5 || fj > nj as f64 - 0.5 {
return None;
}
let i = fi.round().clamp(0.0, (ni - 1) as f64);
let j = fj.round().clamp(0.0, (nj - 1) as f64);
Some(WellLog::new(
(i + 0.5) * dx,
(j + 0.5) * dy,
w.samples.clone(),
))
})
.collect()
}
pub fn upscale_qc(&self, mnemonic: &str, zone: Option<&str>) -> Result<UpscaleQc, SrsError> {
let pipe = match zone {
Some(z) => self
.zone_pipes
.get(&(z.to_string(), mnemonic.to_string()))
.ok_or_else(|| {
SrsError::InvalidInput(format!(
"property '{mnemonic}' has no upscale step in zone '{z}'"
))
})?,
None => self.pipes.get(mnemonic).ok_or_else(|| {
SrsError::InvalidInput(format!("property '{mnemonic}' has no upscale step"))
})?,
};
let grid = self.scratch_grid()?;
let pl = PropertyPipeline::new(mnemonic).upscale(pipe.wells.clone(), pipe.method);
let (_cells, qc) = pl.upscale_cells(grid.grid()).map_err(SrsError::from)?;
Ok(qc)
}
#[allow(clippy::too_many_arguments)]
pub fn set_propagate(
&mut self,
mnemonic: &str,
variogram: Variogram,
seed: u64,
search: Option<(usize, f64)>,
trend: Option<&TrendSpec>,
resimulate: bool,
allow_mean_fill: bool,
zone: Option<&str>,
) -> Result<(), SrsError> {
let mut g = Gaussian::new(variogram, seed);
if let Some((max_n, radius)) = search {
g = g.with_search(max_n, radius);
}
if let Some(t) = trend {
let (surface, corr) = t.parts();
g = g.with_trend(surface, corr);
}
if allow_mean_fill {
g = g.allow_mean_fill();
}
let pipe = self.pipe_entry(mnemonic, zone);
pipe.gaussian = Some(g);
pipe.resimulate = resimulate;
Ok(())
}
fn zone_pipelines(&self) -> Vec<(String, PropertyPipeline, McMode)> {
self.zone_pipes
.iter()
.map(|((zone, name), p)| {
let mut pl = PropertyPipeline::new(name).upscale(p.wells.clone(), p.method);
if let Some(g) = &p.gaussian {
pl = pl.propagate(g.clone());
}
let mode = if p.resimulate {
McMode::Resimulate
} else {
McMode::LevelShift
};
(zone.clone(), pl, mode)
})
.collect()
}
fn pipelines(&self) -> Vec<(PropertyPipeline, McMode)> {
self.pipes
.iter()
.map(|(name, p)| {
let mut pl = PropertyPipeline::new(name).upscale(p.wells.clone(), p.method);
if let Some(g) = &p.gaussian {
pl = pl.propagate(g.clone());
}
let mode = if p.resimulate {
McMode::Resimulate
} else {
McMode::LevelShift
};
(pl, mode)
})
.collect()
}
pub fn property_names(&self) -> Vec<String> {
self.pipes.keys().cloned().collect()
}
fn scratch_grid(&self) -> Result<StaticModel, SrsError> {
match &self.frame {
Frame::Wireframe(wireframe) => {
let deep = deepest(wireframe) + 1.0e5;
let wf = with_contacts(wireframe, None, deep);
let mut builder =
StaticModelBuilder::from_wireframe(&wf, self.opts).map_err(SrsError::from)?;
if let Some(mt) = self.min_thickness_m {
builder = builder.with_min_thickness_m(mt);
}
builder.build().map_err(SrsError::from)
}
Frame::Stack(stack) => {
let (gx, gy, gsx, gsy) = georef_args(&self.top_geom);
let mut builder = StaticModelBuilder::from_horizon_stack(stack.clone(), self.opts)
.map_err(SrsError::from)?
.with_georef(gx, gy, gsx, gsy)
.with_min_thickness_m(self.min_thickness_m.unwrap_or(0.0));
if let Some(cb) = self.collapse_below_m {
builder = builder.with_collapse_below_m(cb);
}
builder.build().map_err(SrsError::from)
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn model(
self,
goc_m: Option<f64>,
fwl_m: Option<f64>,
fluid: Fluid,
boi: f64,
bgi: Option<f64>,
memory_budget_bytes: Option<u64>,
sugar_cube: bool,
) -> Result<Model, SrsError> {
let pipes = self.pipelines();
let zone_pipes = self.zone_pipelines();
let zone_priors = self.zone_priors.clone();
let well_ties = self.well_ties.clone();
let opts = self.opts;
let top_geom = self.top_geom.clone();
let min_thickness_m = self.min_thickness_m;
let collapse_below_m = self.collapse_below_m;
let (gx, gy, gsx, gsy) = georef_args(&top_geom);
match self.frame {
Frame::Wireframe(wireframe) => {
let fwl = fwl_m.filter(|v| v.is_finite()).ok_or_else(|| {
SrsError::InvalidInput(
"model needs a finite lower contact (fwl/owc) depth".into(),
)
})?;
let wf = with_contacts(&wireframe, goc_m, fwl);
let mut builder = StaticModelBuilder::from_wireframe(&wf, opts)?;
builder = builder.with_georef(gx, gy, gsx, gsy);
builder = builder.with_sugar_cube(sugar_cube);
if let Some(b) = memory_budget_bytes {
builder = builder.with_memory_budget(MemoryBudget::bytes(b));
}
if let Some(mt) = min_thickness_m {
builder = builder.with_min_thickness_m(mt);
}
for (pl, _mode) in &pipes {
builder = builder.with_property(pl.clone());
}
let model = builder.build().map_err(SrsError::from)?;
Ok(Model::new(
model,
wf,
top_geom,
opts,
pipes,
fluid,
boi,
bgi,
goc_m,
fwl,
min_thickness_m,
false,
None,
Vec::new(),
None,
Vec::new(),
Vec::new(),
sugar_cube,
))
}
Frame::Stack(stack) => {
let stack_for_mc = stack.clone();
let mut builder = StaticModelBuilder::from_horizon_stack(stack, opts)?
.with_georef(gx, gy, gsx, gsy)
.with_sugar_cube(sugar_cube)
.with_min_thickness_m(min_thickness_m.unwrap_or(0.0));
if let Some(b) = memory_budget_bytes {
builder = builder.with_memory_budget(MemoryBudget::bytes(b));
}
if let Some(cb) = collapse_below_m {
builder = builder.with_collapse_below_m(cb);
}
for (name, priors) in &zone_priors {
builder = builder.with_zone_priors(name.clone(), *priors);
}
for (pl, _mode) in &pipes {
builder = builder.with_property(pl.clone());
}
for (zone, pl, _mode) in &zone_pipes {
builder = builder.with_zone_property(zone.clone(), pl.clone());
}
if !well_ties.is_empty() {
builder = builder.with_well_ties(well_ties.clone());
}
let model = builder.build().map_err(SrsError::from)?;
let wf = model.framework().clone();
Ok(Model::new(
model,
wf,
top_geom,
opts,
pipes,
fluid,
boi,
bgi,
None,
f64::NAN,
min_thickness_m,
true,
Some(stack_for_mc),
zone_priors,
collapse_below_m,
zone_pipes,
well_ties,
sugar_cube,
))
}
}
}
}
fn positioned_logs(
wells: &[BoreWell<'_>],
mnemonic: &str,
net_cutoff: Option<f64>,
) -> Vec<WellLog> {
let mut out = Vec::new();
for bw in wells {
let Some(view) = bw.log(mnemonic) else {
continue;
};
let mds = view.md();
let vals = view.values();
let net = net_cutoff.zip(bw.log("NTG"));
let mut samples = Vec::new();
let (mut sx, mut sy) = (0.0, 0.0);
for (md, v) in mds.iter().zip(vals.iter()) {
if !v.is_finite() {
continue;
}
if let Some((cut, ntg)) = &net {
if !matches!(ntg.at_md(*md), Some(f) if f > *cut) {
continue;
}
}
if let Some(p) = bw.xyz(*md) {
if p.x.is_finite() && p.y.is_finite() && p.z.is_finite() {
samples.push((-p.z, *v)); sx += p.x;
sy += p.y;
}
}
}
if !samples.is_empty() {
let n = samples.len() as f64;
out.push(WellLog::new(sx / n, sy / n, samples));
}
}
out
}
pub(crate) fn georef_args(g: &GridGeometry) -> (f64, f64, f64, f64) {
(g.xori + 0.5 * g.xinc, g.yori + 0.5 * g.yinc, g.xinc, g.yinc)
}
pub(crate) fn stack_frame(g: &GridGeometry) -> Result<StackFrame, SrsError> {
let (gx, gy, gsx, gsy) = georef_args(g);
let georef = Georef::new(gx, gy, gsx, gsy).ok_or_else(|| {
SrsError::InvalidInput(
"raw-scatter stack needs a finite, positive-spacing world frame from the top \
horizon lattice"
.into(),
)
})?;
Ok(StackFrame {
ni: g.ncol.saturating_sub(1),
nj: g.nrow.saturating_sub(1),
georef,
})
}
fn with_contacts(wf: &Wireframe, goc_m: Option<f64>, lower_m: f64) -> Wireframe {
let mut contacts = Vec::new();
if let Some(goc) = goc_m {
contacts.push(Contact {
kind: ContactKind::Goc,
depth_m: goc,
hardness: Hardness::Hard,
});
}
contacts.push(Contact {
kind: ContactKind::Owc,
depth_m: lower_m,
hardness: Hardness::Hard,
});
Wireframe {
boundary: wf.boundary.clone(),
horizons: wf.horizons.clone(),
contacts,
}
}
fn deepest(wf: &Wireframe) -> f64 {
wf.horizons
.iter()
.flat_map(|h| h.surface.depth_m.iter().copied())
.filter(|d| d.is_finite())
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
use petekstatic::gridder::{Conformity, SolveOpts};
use petekstatic::model::ConstantPriors;
use petekstatic::wireframe::{Boundary, GriddedDepth, Hardness, Horizon, HorizonRole};
const N: usize = 11; const XORI: f64 = 431_000.0; const YORI: f64 = 6_521_000.0;
const INC: f64 = 120.0;
const TOP_M: f64 = 2000.0;
const BASE_M: f64 = 2040.0;
fn utm_geom() -> GridGeometry {
GridGeometry {
xori: XORI,
yori: YORI,
xinc: INC,
yinc: INC,
ncol: N,
nrow: N,
rotation_deg: 0.0,
yflip: false,
}
}
fn horizon(name: &str, role: HorizonRole, depth: f64) -> Horizon {
Horizon {
name: name.into(),
role,
surface: GriddedDepth {
ncol: N,
nrow: N,
depth_m: vec![depth; N * N],
is_control: vec![true; N * N],
},
}
}
fn utm_grid() -> StaticGrid {
let geom = utm_geom();
let side = INC * (N - 1) as f64; let wf = Wireframe {
boundary: Boundary {
ring: vec![
[XORI, YORI],
[XORI + side, YORI],
[XORI + side, YORI + side],
[XORI, YORI + side],
[XORI, YORI],
],
hardness: Hardness::Interpolated,
},
horizons: vec![
horizon("Top", HorizonRole::Top, TOP_M),
horizon("Base", HorizonRole::Base, BASE_M),
]
.into(),
contacts: Vec::new(),
};
let opts = BuildOpts {
area_m2: side * side,
gross_height_m: BASE_M - TOP_M,
nk: 8,
conformity: Conformity::Proportional,
solve_opts: SolveOpts::default(),
priors: ConstantPriors {
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
},
};
StaticGrid::new(
Frame::Wireframe(wf),
geom,
opts,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
)
}
fn utm_well_log() -> WellLog {
WellLog::new(
XORI + 5.0 * INC, YORI + 5.0 * INC, vec![
(2005.0, 0.20),
(2015.0, 0.22),
(2025.0, 0.24),
(2035.0, 0.26),
],
)
}
#[test]
fn upscale_conditions_cells_at_real_utm_coordinates() {
let grid = utm_grid();
let registered = grid.register_logs(&[utm_well_log()]);
assert_eq!(
registered.len(),
1,
"UTM well should register onto the grid"
);
let scratch = grid.scratch_grid().unwrap();
let (_cells, qc) = PropertyPipeline::new("PORO")
.upscale(registered, UpscaleMethod::Arithmetic)
.upscale_cells(scratch.grid())
.unwrap();
assert!(
qc.conditioned_cells > 0,
"expected >0 conditioned cells at UTM coords, got {}",
qc.conditioned_cells
);
assert!(qc.log_mean.is_finite());
assert!((0.19..=0.27).contains(&qc.upscaled_mean));
}
#[test]
fn set_upscale_registers_and_conditions() {
let mut grid = utm_grid();
let registered = grid.register_logs(&[utm_well_log()]);
grid.pipes.entry("PORO".into()).or_default().wells = registered;
let qc = grid.upscale_qc("PORO", None).unwrap();
assert!(
qc.conditioned_cells > 0,
"0 cells conditioned at UTM coords"
);
}
#[test]
fn well_far_off_the_framework_is_dropped() {
let grid = utm_grid();
let off = WellLog::new(100.0, 100.0, vec![(2005.0, 0.2)]);
assert!(grid.register_logs(&[off]).is_empty());
}
}