use crate::core::facade::model::{zone_volume, Model};
use crate::core::facade::spec::DistSpec;
use crate::core::facade::uncertainty::PSummary;
use crate::units::SrsError;
use petekstatic::model::{
GasFvf, OilFvf, PerturbationField, RealizationDraw, StaticModelTemplate, ZoneDraw,
};
use petekstatic::wireframe::{Contact, ContactKind};
use petektools::sampling::{reservoir_summary, seeded_rng};
use rayon::prelude::*;
use std::ops::Range;
#[derive(Debug, Clone, Default)]
pub struct ZoneMcSpec {
pub zone: String,
pub contact_sd_m: Option<f64>,
pub goc_sd_m: Option<f64>,
pub porosity: Option<DistSpec>,
pub net_to_gross: Option<DistSpec>,
pub water_saturation: Option<DistSpec>,
}
#[derive(Debug, Clone, Default)]
pub struct ZonedMcConfig {
pub porosity: Option<DistSpec>,
pub net_to_gross: Option<DistSpec>,
pub water_saturation: Option<DistSpec>,
pub contact_sd_m: Option<f64>,
pub goc_sd_m: Option<f64>,
pub boi: Option<DistSpec>,
pub bgi: Option<DistSpec>,
pub per_zone: Vec<ZoneMcSpec>,
pub top_structural: Option<PerturbationField>,
pub zone_isochore_structural: Vec<Option<PerturbationField>>,
pub n: usize,
pub seed: u64,
pub workers: usize,
}
#[derive(Debug, Clone)]
pub struct ZonePCurves {
pub zone: String,
pub stoiip: PSummary,
pub giip: PSummary,
pub two_contact: bool,
pub stoiip_samples: Vec<f64>,
pub giip_samples: Vec<f64>,
}
pub struct ZonedMcOutcome {
pub zones: Vec<ZonePCurves>,
pub total_stoiip: PSummary,
pub total_giip: PSummary,
pub total_stoiip_samples: Vec<f64>,
pub total_giip_samples: Vec<f64>,
pub two_contact: bool,
}
const LEVEL_LO: f64 = 1.0e-4;
const LEVEL_HI: f64 = 1.0 - 1.0e-4;
const CONTACT_GAP_M: f64 = 1.0;
type ZoneRow = Vec<(f64, f64)>;
struct ZonePlan {
idx: usize,
owc: Vec<f64>,
goc: Vec<f64>,
poro: Option<Vec<f64>>,
ntg: Option<Vec<f64>>,
sw: Option<Vec<f64>>,
}
pub fn run_zoned_uncertainty(
model: &Model,
cfg: ZonedMcConfig,
) -> Result<ZonedMcOutcome, SrsError> {
if cfg.n == 0 {
return Err(SrsError::InvalidInput(
"zoned uncertainty needs n >= 1".into(),
));
}
let n = cfg.n;
let stack = model
.stack()
.ok_or_else(|| SrsError::InvalidInput("zoned MC needs a stack model".into()))?;
let zone_names: Vec<String> = stack.zone_layers.iter().map(|z| z.name.clone()).collect();
let n_zones = zone_names.len();
let modelled = model.pipeline_names();
let is_modelled = |p: &str| modelled.iter().any(|q| q == p);
let global = model.opts().priors;
let area = model.opts().area_m2;
let gross = model.opts().gross_height_m;
let mut rng = seeded_rng(cfg.seed);
let boi = match &cfg.boi {
Some(d) => d.sample_vec(n, &mut rng),
None => vec![model.boi(); n],
};
let bgi: Option<Vec<f64>> = match model.bgi() {
Some(b) => Some(match &cfg.bgi {
Some(d) => d.sample_vec(n, &mut rng),
None => vec![b; n],
}),
None => None,
};
let base_poro = cfg.porosity.as_ref().map(|d| d.sample_vec(n, &mut rng));
let base_ntg = cfg.net_to_gross.as_ref().map(|d| d.sample_vec(n, &mut rng));
let base_sw = cfg
.water_saturation
.as_ref()
.map(|d| d.sample_vec(n, &mut rng));
let mut plans = Vec::with_capacity(n_zones);
for (zi, name) in zone_names.iter().enumerate() {
let per = cfg.per_zone.iter().find(|z| &z.zone == name);
let (base_goc, base_owc) = zone_base_contacts(&stack.zone_layers[zi].contacts);
let owc_sd = per.and_then(|p| p.contact_sd_m).or(cfg.contact_sd_m);
let goc_sd = per.and_then(|p| p.goc_sd_m).or(cfg.goc_sd_m);
let owc = draw_contact(base_owc, owc_sd, n, &mut rng)?;
let mut goc = draw_contact(base_goc, goc_sd, n, &mut rng)?;
if !goc.is_empty() && !owc.is_empty() {
for i in 0..n {
goc[i] = goc[i].min(owc[i] - CONTACT_GAP_M);
}
}
let zbase = zone_base_priors(model.zone_priors(), global, name);
let has_explicit = model.zone_priors().iter().any(|(z, _)| z == name);
let poro = zone_level(
"PORO",
is_modelled("PORO"),
zbase.porosity,
base_poro.as_deref(),
per.and_then(|p| p.porosity.as_ref()),
has_explicit,
n,
&mut rng,
);
let ntg = zone_level(
"NTG",
is_modelled("NTG"),
zbase.net_to_gross,
base_ntg.as_deref(),
per.and_then(|p| p.net_to_gross.as_ref()),
has_explicit,
n,
&mut rng,
);
let sw = zone_level(
"SW",
is_modelled("SW"),
zbase.water_saturation,
base_sw.as_deref(),
per.and_then(|p| p.water_saturation.as_ref()),
has_explicit,
n,
&mut rng,
);
plans.push(ZonePlan {
idx: zi,
owc,
goc,
poro,
ntg,
sw,
});
}
let deep = deepest_contact(&plans).unwrap_or(1.0e6);
let mut draws = Vec::with_capacity(n);
for i in 0..n {
let mut d = RealizationDraw::new(
area,
gross,
deep, global.porosity,
global.net_to_gross,
global.water_saturation,
cfg.seed.wrapping_add(i as u64),
);
if let Some(field) = cfg.top_structural {
d = d.with_top_structural(field);
}
if is_modelled("PORO") {
if let Some(v) = &base_poro {
d = d.with_property_shift("PORO", v[i]);
}
}
if is_modelled("NTG") {
if let Some(v) = &base_ntg {
d = d.with_property_shift("NTG", v[i]);
}
}
if is_modelled("SW") {
if let Some(v) = &base_sw {
d = d.with_property_shift("SW", v[i]);
}
}
for p in &plans {
let mut zd = ZoneDraw::new(p.idx);
if let Some(Some(field)) = cfg.zone_isochore_structural.get(p.idx).copied() {
zd = zd.with_isochore_structural(field);
}
if !p.owc.is_empty() {
zd = zd.with_owc(p.owc[i]);
}
if !p.goc.is_empty() {
zd = zd.with_goc(p.goc[i]);
}
if let Some(v) = &p.poro {
zd.porosity = Some(v[i]);
}
if let Some(v) = &p.ntg {
zd.net_to_gross = Some(v[i]);
}
if let Some(v) = &p.sw {
zd.water_saturation = Some(v[i]);
}
d = d.with_zone_draw(zd);
}
draws.push(d);
}
let base_tmpl = model.stack_template()?;
let workers = if cfg.workers <= 1 {
1
} else {
cfg.workers.min(n)
};
let rows: Vec<ZoneRow> = if workers == 1 {
run_draws_range(&base_tmpl, &draws, &boi, bgi.as_deref(), 0..n, n_zones)?
} else {
let ranges = shard_ranges(n, workers);
let (bt, dr, br) = (&base_tmpl, &draws, &boi);
let bg = bgi.as_deref();
let shards: Result<Vec<Vec<ZoneRow>>, SrsError> = ranges
.into_par_iter()
.map(|r| run_draws_range(bt, dr, br, bg, r, n_zones))
.collect();
shards?.into_iter().flatten().collect()
};
let mut zones_out = Vec::with_capacity(n_zones);
let mut total_oil = vec![0.0; n];
let mut total_gas = vec![0.0; n];
for zi in 0..n_zones {
let mut oil = Vec::with_capacity(n);
let mut gas = Vec::with_capacity(n);
for (i, row) in rows.iter().enumerate() {
let (o, g) = row[zi];
oil.push(o);
gas.push(g);
total_oil[i] += o;
total_gas[i] += g;
}
let two_contact = gas.iter().any(|&x| x > 0.0);
zones_out.push(ZonePCurves {
zone: zone_names[zi].clone(),
stoiip: psummary(&oil)?,
giip: if two_contact {
psummary(&gas)?
} else {
PSummary::zero()
},
two_contact,
stoiip_samples: oil,
giip_samples: if two_contact { gas } else { Vec::new() },
});
}
let any_two = zones_out.iter().any(|z| z.two_contact);
Ok(ZonedMcOutcome {
total_stoiip: psummary(&total_oil)?,
total_giip: if any_two {
psummary(&total_gas)?
} else {
PSummary::zero()
},
total_stoiip_samples: total_oil,
total_giip_samples: if any_two { total_gas } else { Vec::new() },
zones: zones_out,
two_contact: any_two,
})
}
fn run_draws_range(
base_tmpl: &StaticModelTemplate,
draws: &[RealizationDraw],
boi: &[f64],
bgi: Option<&[f64]>,
range: Range<usize>,
n_zones: usize,
) -> Result<Vec<ZoneRow>, SrsError> {
let mut tmpl = base_tmpl.clone();
let mut model = tmpl.reusable_model();
let mut out = Vec::with_capacity(range.len());
for i in range {
tmpl.realize_into(&draws[i], &mut model)
.map_err(SrsError::from)?;
let zoned = model.in_place_by_zone().map_err(SrsError::from)?;
let oil_fvf = OilFvf::new(boi[i]).map_err(SrsError::from)?;
let gas_fvf = match bgi {
Some(b) => Some(GasFvf::new(b[i]).map_err(SrsError::from)?),
None => None,
};
let mut row = vec![(0.0, 0.0); n_zones];
for (zi, z) in zoned.zones.iter().enumerate().take(n_zones) {
let zv = zone_volume(&z.zone, &z.in_place, oil_fvf, gas_fvf);
row[zi] = (zv.stoiip_sm3, zv.giip_sm3);
}
out.push(row);
}
Ok(out)
}
fn shard_ranges(n: usize, workers: usize) -> Vec<Range<usize>> {
let base = n / workers;
let rem = n % workers;
let mut ranges = Vec::with_capacity(workers);
let mut start = 0;
for w in 0..workers {
let len = base + usize::from(w < rem);
ranges.push(start..start + len);
start += len;
}
ranges
}
fn zone_base_contacts(contacts: &[Contact]) -> (Option<f64>, Option<f64>) {
let mut goc = None;
let mut lower = None;
for c in contacts {
match c.kind {
ContactKind::Goc => goc = Some(c.depth_m),
_ => lower = Some(c.depth_m),
}
}
(goc, lower)
}
fn draw_contact(
base: Option<f64>,
sd: Option<f64>,
n: usize,
rng: &mut impl rand::Rng,
) -> Result<Vec<f64>, SrsError> {
match base {
None => Ok(Vec::new()),
Some(b) => match sd {
Some(s) if s > 0.0 => {
let shifts = DistSpec::normal(0.0, s)?.sample_vec(n, rng);
Ok(shifts.into_iter().map(|d| b + d).collect())
}
_ => Ok(vec![b; n]),
},
}
}
#[allow(clippy::too_many_arguments)]
fn zone_level(
_property: &str,
modelled: bool,
zone_base: f64,
base_shift: Option<&[f64]>,
zone_dist: Option<&DistSpec>,
has_explicit_prior: bool,
n: usize,
rng: &mut impl rand::Rng,
) -> Option<Vec<f64>> {
let zone_shift = zone_dist.map(|d| d.sample_vec(n, rng));
if modelled {
zone_shift.map(|zs| {
(0..n)
.map(|i| clamp_level(zone_base + zs[i]))
.collect::<Vec<_>>()
})
} else if has_explicit_prior || base_shift.is_some() || zone_shift.is_some() {
Some(
(0..n)
.map(|i| {
let b = base_shift.map_or(0.0, |v| v[i]);
let z = zone_shift.as_ref().map_or(0.0, |v| v[i]);
clamp_level(zone_base + b + z)
})
.collect(),
)
} else {
None
}
}
fn clamp_level(v: f64) -> f64 {
v.clamp(LEVEL_LO, LEVEL_HI)
}
fn zone_base_priors(
zone_priors: &[(String, petekstatic::model::ConstantPriors)],
global: petekstatic::model::ConstantPriors,
name: &str,
) -> petekstatic::model::ConstantPriors {
zone_priors
.iter()
.find(|(z, _)| z == name)
.map(|(_, p)| *p)
.unwrap_or(global)
}
fn deepest_contact(plans: &[ZonePlan]) -> Option<f64> {
plans
.iter()
.flat_map(|p| p.owc.first().copied())
.filter(|d| d.is_finite())
.fold(None, |acc, d| Some(acc.map_or(d, |a: f64| a.max(d))))
.map(|d| d + 100.0)
}
fn psummary(v: &[f64]) -> Result<PSummary, SrsError> {
let s = reservoir_summary(v)?;
Ok(PSummary {
p90: s.p90,
p50: s.p50,
p10: s.p10,
mean: s.mean,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::facade::grid::{Frame, StaticGrid};
use crate::core::inplace::Fluid;
use petekio::GridGeometry;
use petekstatic::gridder::{Conformity, SolveOpts};
use petekstatic::model::{
BuildOpts, ConstantPriors, HorizonSource, HorizonStack, StackHorizon, StackZone,
};
use petekstatic::wireframe::{Boundary, Contact, ContactKind, GriddedDepth, Hardness};
const N: usize = 11;
const INC: f64 = 100.0;
const SIDE: f64 = INC * (N - 1) as f64;
fn geom() -> GridGeometry {
GridGeometry {
xori: 0.0,
yori: 0.0,
xinc: INC,
yinc: INC,
ncol: N,
nrow: N,
rotation_deg: 0.0,
yflip: false,
}
}
fn flat(depth: f64) -> GriddedDepth {
GriddedDepth {
ncol: N,
nrow: N,
depth_m: vec![depth; N * N],
is_control: vec![true; N * N],
}
}
fn mapped(name: &str, depth: f64) -> StackHorizon {
StackHorizon {
name: name.into(),
source: HorizonSource::Mapped(flat(depth)),
}
}
fn owc(depth_m: f64) -> Vec<Contact> {
vec![Contact {
kind: ContactKind::Owc,
depth_m,
hardness: Hardness::Hard,
}]
}
fn stack_grid() -> StaticGrid {
let stack = HorizonStack {
horizons: vec![
mapped("H0", 2000.0),
mapped("H1", 2020.0),
mapped("H2", 2040.0),
],
zone_layers: vec![
StackZone::new("Z0", Conformity::Proportional, 4, Vec::new()),
StackZone::new("Z1", Conformity::Proportional, 4, owc(2030.0)),
],
};
let opts = BuildOpts {
area_m2: SIDE * SIDE,
gross_height_m: 40.0,
nk: 8,
conformity: Conformity::Proportional,
solve_opts: SolveOpts::default(),
priors: ConstantPriors {
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
},
};
let boundary = Boundary {
ring: vec![
[0.0, 0.0],
[SIDE, 0.0],
[SIDE, SIDE],
[0.0, SIDE],
[0.0, 0.0],
],
hardness: Hardness::Interpolated,
};
let _ = boundary; StaticGrid::new(
Frame::Stack(stack),
geom(),
opts,
Some(0.0),
None,
Vec::new(),
Vec::new(),
Vec::new(),
)
}
fn cfg(n: usize, contact_sd_m: Option<f64>) -> ZonedMcConfig {
ZonedMcConfig {
contact_sd_m,
boi: None,
n,
seed: 7,
workers: 1,
..Default::default()
}
}
#[test]
fn zoned_mc_rolls_up_per_zone_and_conserves() {
let model = stack_grid()
.model(None, None, Fluid::Oil, 1.25, None, None, false)
.unwrap();
assert!(model.is_zoned());
let out = model.zoned_uncertainty(cfg(48, Some(3.0))).unwrap();
assert_eq!(out.zones.len(), 2);
assert_eq!(out.zones[0].zone, "Z0");
assert_eq!(out.zones[1].zone, "Z1");
assert!(out.zones[0].stoiip_samples.iter().all(|&v| v == 0.0));
assert_eq!(out.zones[0].stoiip.mean, 0.0);
assert!(out.zones[1].stoiip.mean > 0.0);
for i in 0..out.total_stoiip_samples.len() {
let sum: f64 = out.zones.iter().map(|z| z.stoiip_samples[i]).sum();
assert!((out.total_stoiip_samples[i] - sum).abs() < 1e-6);
}
let t = &out.total_stoiip;
assert!(t.p90 <= t.p50 && t.p50 <= t.p10);
}
#[test]
fn zoned_mc_zero_spread_matches_deterministic() {
let model = stack_grid()
.model(None, None, Fluid::Oil, 1.25, None, None, false)
.unwrap();
let det = model.in_place_by_zone().unwrap();
let out = model.zoned_uncertainty(cfg(8, None)).unwrap();
for (zi, z) in out.zones.iter().enumerate() {
let d = det.zones[zi].stoiip_sm3;
assert!(
(z.stoiip.mean - d).abs() <= 1e-6 * d.abs().max(1.0),
"zone {} MC mean {} != deterministic {}",
z.zone,
z.stoiip.mean,
d
);
}
assert!(
(out.total_stoiip.mean - det.total.stoiip_sm3).abs()
<= 1e-6 * det.total.stoiip_sm3.abs().max(1.0)
);
}
}