use crate::error::StaticError;
use crate::grid::{build_box, BoxSpec, Dims, Grid, Property};
use crate::gridder::{Conformity, SolveOpts};
use crate::model::provenance::{PopulationMode, Provenance};
use crate::model::zones::ZoneTable;
use crate::spill::SpillBacking;
use crate::volumetrics::{compute_clipped, Clip, GridSource, InPlace, ZoneVolumes};
use crate::wireframe::{Contact, ContactKind, Wireframe};
use std::path::Path;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Georef {
pub origin_x: f64,
pub origin_y: f64,
pub spacing_x: f64,
pub spacing_y: f64,
#[serde(default, skip_serializing_if = "is_zero_rotation")]
pub rotation_deg: f64,
#[serde(default, skip_serializing_if = "is_false")]
pub yflip: bool,
}
fn is_zero_rotation(value: &f64) -> bool {
*value == 0.0
}
fn is_false(value: &bool) -> bool {
!*value
}
impl Georef {
#[must_use]
pub fn new(origin_x: f64, origin_y: f64, spacing_x: f64, spacing_y: f64) -> Option<Self> {
Self::oriented(origin_x, origin_y, spacing_x, spacing_y, 0.0, false)
}
#[must_use]
pub fn oriented(
origin_x: f64,
origin_y: f64,
spacing_x: f64,
spacing_y: f64,
rotation_deg: f64,
yflip: bool,
) -> Option<Self> {
if origin_x.is_finite()
&& origin_y.is_finite()
&& spacing_x.is_finite()
&& spacing_x > 0.0
&& spacing_y.is_finite()
&& spacing_y > 0.0
&& rotation_deg.is_finite()
{
let rotation_deg = rotation_deg.rem_euclid(360.0);
Some(Self {
origin_x,
origin_y,
spacing_x,
spacing_y,
rotation_deg: if rotation_deg == 0.0 {
0.0
} else {
rotation_deg
},
yflip,
})
} else {
None
}
}
#[must_use]
pub fn intrinsic_to_world(self, fi: f64, fj: f64) -> (f64, f64) {
petektools::Lattice {
xori: self.origin_x,
yori: self.origin_y,
xinc: self.spacing_x * fi,
yinc: self.spacing_y * fj,
ncol: 2,
nrow: 2,
rotation_deg: self.rotation_deg,
yflip: self.yflip,
}
.node_xy(1, 1)
}
#[must_use]
pub fn world_to_intrinsic(self, x: f64, y: f64) -> Option<(f64, f64)> {
petektools::Lattice {
xori: self.origin_x,
yori: self.origin_y,
xinc: self.spacing_x,
yinc: self.spacing_y,
ncol: 1,
nrow: 1,
rotation_deg: self.rotation_deg,
yflip: self.yflip,
}
.xy_to_ij(x, y)
}
}
#[derive(Debug, Clone)]
pub struct StaticModel {
framework: Wireframe,
grid: Grid,
zones: ZoneTable,
provenance: Provenance,
sw_gas: Option<f64>,
georef: Option<Georef>,
zone_contacts: Option<Vec<Vec<Contact>>>,
spill: Option<Arc<SpillBacking>>,
}
impl StaticModel {
pub(crate) fn new(
framework: Wireframe,
grid: Grid,
zones: ZoneTable,
provenance: Provenance,
sw_gas: Option<f64>,
) -> Self {
Self {
framework,
grid,
zones,
provenance,
sw_gas,
georef: None,
zone_contacts: None,
spill: None,
}
}
pub(crate) fn into_spilled(mut self, backing: SpillBacking) -> Self {
self.grid = build_box(BoxSpec::square(
1.0,
1.0,
Dims::new(1, 1, 1).expect("1x1x1 dims are valid"),
))
.expect("unit box always builds");
self.spill = Some(Arc::new(backing));
self
}
pub(crate) fn spilled(
framework: Wireframe,
zones: ZoneTable,
provenance: Provenance,
sw_gas: Option<f64>,
georef: Option<Georef>,
backing: SpillBacking,
) -> Self {
let placeholder = build_box(BoxSpec::square(
1.0,
1.0,
Dims::new(1, 1, 1).expect("1x1x1 dims are valid"),
))
.expect("unit box always builds");
Self::new(framework, placeholder, zones, provenance, sw_gas)
.with_georef_opt(georef)
.into_spilled(backing)
}
pub(crate) fn set_spill(&mut self, spill: Option<Arc<SpillBacking>>) {
self.spill = spill;
}
#[must_use]
pub fn is_spilled(&self) -> bool {
self.spill.is_some()
}
#[must_use]
pub fn spill_store_path(&self) -> Option<&Path> {
self.spill.as_ref().map(|b| b.store_path())
}
pub(crate) fn view_grid(&self) -> Result<std::borrow::Cow<'_, Grid>, StaticError> {
match &self.spill {
Some(backing) => Ok(std::borrow::Cow::Owned(backing.to_in_core_grid()?)),
None => Ok(std::borrow::Cow::Borrowed(&self.grid)),
}
}
#[must_use]
pub fn dims(&self) -> Dims {
match &self.spill {
Some(b) => b.dims(),
None => self.grid.dims(),
}
}
pub(crate) fn with_georef_opt(mut self, georef: Option<Georef>) -> Self {
self.georef = georef;
self
}
pub(crate) fn with_zone_contacts(mut self, zone_contacts: Option<Vec<Vec<Contact>>>) -> Self {
self.zone_contacts = zone_contacts;
self
}
pub(crate) fn empty(framework: Wireframe) -> Self {
let grid = build_box(BoxSpec::square(
1.0,
1.0,
Dims::new(1, 1, 1).expect("1x1x1 dims are valid"),
))
.expect("unit box always builds");
Self {
framework,
grid,
zones: ZoneTable::single(1),
provenance: Provenance {
inputs_ref: String::new(),
solve_opts: SolveOpts::default(),
conformity: Conformity::Proportional,
nk: 1,
population: PopulationMode::Priors,
realization: None,
warnings: Vec::new(),
property_reports: Vec::new(),
stack: None,
well_ties: Vec::new(),
sugar_cube: false,
},
sw_gas: None,
georef: None,
zone_contacts: None,
spill: None,
}
}
pub(crate) fn grid_mut(&mut self) -> &mut Grid {
&mut self.grid
}
pub(crate) fn reset_state(
&mut self,
framework: Wireframe,
zones: ZoneTable,
provenance: Provenance,
sw_gas: Option<f64>,
georef: Option<Georef>,
zone_contacts: Option<Vec<Vec<Contact>>>,
) {
self.framework = framework;
self.zones = zones;
self.provenance = provenance;
self.sw_gas = sw_gas;
self.georef = georef;
self.zone_contacts = zone_contacts;
self.spill = None;
}
#[must_use]
pub fn georef(&self) -> Option<Georef> {
self.georef
}
#[must_use]
pub fn grid(&self) -> &Grid {
&self.grid
}
#[must_use]
pub fn framework(&self) -> &Wireframe {
&self.framework
}
#[must_use]
pub fn contacts(&self) -> &[Contact] {
&self.framework.contacts
}
#[must_use]
pub fn property(&self, name: &str) -> Option<&Property> {
self.grid.properties().get(name)
}
#[must_use]
pub fn property_names(&self) -> Vec<&str> {
self.grid.properties().names().collect()
}
#[must_use]
pub fn zones(&self) -> &ZoneTable {
&self.zones
}
pub fn zone_stats(&self, property: &str) -> Result<Vec<ZoneStat>, StaticError> {
if self.spill.is_some() {
return Err(StaticError::InvalidInput(
"zone_stats is not available on a spilled (out-of-core) model in v1".into(),
));
}
let cube = self.grid.properties().get(property).ok_or_else(|| {
StaticError::InvalidInput(format!("grid is missing property '{property}'"))
})?;
let dims = self.grid.dims();
let mut out = Vec::with_capacity(self.zones.zones().len());
for zone in self.zones.zones() {
let (mut n, mut sum, mut min, mut max) =
(0usize, 0.0f64, f64::INFINITY, f64::NEG_INFINITY);
for k in zone.k_range.clone() {
for j in 0..dims.nj {
for i in 0..dims.ni {
let c = crate::grid::Ijk::new(i, j, k);
if self.grid.cell(c).dz() <= 1e-9 {
continue; }
let v = cube.values[(k * dims.nj + j) * dims.ni + i];
if !v.is_finite() {
continue;
}
n += 1;
sum += v;
min = min.min(v);
max = max.max(v);
}
}
}
out.push(if n == 0 {
ZoneStat {
zone: zone.name.clone(),
count: 0,
mean: f64::NAN,
min: f64::NAN,
max: f64::NAN,
}
} else {
ZoneStat {
zone: zone.name.clone(),
count: n,
mean: sum / n as f64,
min,
max,
}
});
}
Ok(out)
}
#[must_use]
pub fn provenance(&self) -> &Provenance {
&self.provenance
}
#[must_use]
pub fn bulk_volume(&self) -> f64 {
match &self.spill {
Some(b) => b.bulk_volume().unwrap_or(f64::NAN),
None => self.grid.bulk_volume(),
}
}
pub fn in_place(&self) -> Result<InPlace, StaticError> {
self.in_place_impl(true)
}
pub fn in_place_summary(&self) -> Result<InPlace, StaticError> {
self.in_place_impl(false)
}
fn in_place_impl(&self, per_cell: bool) -> Result<InPlace, StaticError> {
let clip = clip_of(self.contacts(), self.sw_gas)
.ok_or_else(|| StaticError::Grid("static model has no fluid contact".into()))?;
self.volumetrics(clip, 0..self.dims().nk, per_cell)
}
fn volumetrics(
&self,
clip: Clip,
k_range: core::ops::Range<usize>,
per_cell: bool,
) -> Result<InPlace, StaticError> {
match &self.spill {
Some(backing) => compute_clipped(&backing.source(), clip, k_range, per_cell),
None => compute_clipped(&GridSource::new(&self.grid), clip, k_range, per_cell),
}
}
pub fn in_place_by_zone(&self) -> Result<ZonedInPlace, StaticError> {
let mut zones = Vec::with_capacity(self.zones.zones().len());
let (mut t_grv, mut t_hcpv, mut t_cells) = (0.0, 0.0, 0usize);
let (mut t_gas, mut t_oil): (Option<ZoneVolumes>, Option<ZoneVolumes>) = (None, None);
for (z, zone) in self.zones.zones().iter().enumerate() {
let contacts: &[Contact] = match &self.zone_contacts {
Some(zc) => &zc[z],
None => self.contacts(),
};
let ip = self.zone_in_place(contacts, zone.k_range.clone())?;
t_grv += ip.grv_m3;
t_hcpv += ip.hcpv_m3;
t_cells += ip.cells_in_column;
t_gas = add_zone_volumes(t_gas, ip.gas);
t_oil = add_zone_volumes(t_oil, ip.oil);
zones.push(ZoneInPlace {
zone: zone.name.clone(),
in_place: ip,
});
}
Ok(ZonedInPlace {
zones,
total: InPlace {
grv_m3: t_grv,
hcpv_m3: t_hcpv,
cells_in_column: t_cells,
per_cell_hcpv: Vec::new(),
gas: t_gas,
oil: t_oil,
},
})
}
fn zone_in_place(
&self,
contacts: &[Contact],
k_range: core::ops::Range<usize>,
) -> Result<InPlace, StaticError> {
let clip = clip_of(contacts, self.sw_gas).unwrap_or(Clip::Bulk);
self.volumetrics(clip, k_range, false)
}
}
fn clip_of(contacts: &[Contact], sw_gas: Option<f64>) -> Option<Clip> {
let goc = contacts
.iter()
.find(|c| c.kind == ContactKind::Goc)
.map(|c| c.depth_m);
let lower = contacts
.iter()
.filter(|c| matches!(c.kind, ContactKind::Owc | ContactKind::Gwc))
.map(|c| c.depth_m)
.fold(None, |acc: Option<f64>, d| {
Some(acc.map_or(d, |a| a.max(d)))
});
if let (Some(g), Some(w)) = (goc, lower) {
if g <= w {
return Some(Clip::Two {
goc: g,
owc: w,
sw_gas,
});
}
}
contacts.first().map(|c| Clip::Single(c.depth_m))
}
#[derive(Debug, Clone, PartialEq)]
pub struct ZoneStat {
pub zone: String,
pub count: usize,
pub mean: f64,
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone)]
pub struct ZoneInPlace {
pub zone: String,
pub in_place: InPlace,
}
#[derive(Debug, Clone)]
pub struct ZonedInPlace {
pub zones: Vec<ZoneInPlace>,
pub total: InPlace,
}
fn add_zone_volumes(acc: Option<ZoneVolumes>, add: Option<ZoneVolumes>) -> Option<ZoneVolumes> {
match (acc, add) {
(a, None) => a,
(None, Some(v)) => Some(v),
(Some(a), Some(v)) => Some(ZoneVolumes {
grv_m3: a.grv_m3 + v.grv_m3,
hcpv_m3: a.hcpv_m3 + v.hcpv_m3,
cells: a.cells + v.cells,
}),
}
}