use crate::error::StaticError;
use crate::grid::Grid;
use crate::volumetrics::backing::{compute_clipped, Clip, GridSource};
use crate::volumetrics::fvf::{GasFvf, OilFvf};
use core::ops::Range;
use petektools::units::{m3_to_bcm, m3_to_mcm, m3_to_msm3};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ZoneVolumes {
pub grv_m3: f64,
pub hcpv_m3: f64,
pub cells: usize,
}
#[derive(Debug, Clone)]
pub struct InPlace {
pub grv_m3: f64,
pub hcpv_m3: f64,
pub cells_in_column: usize,
pub per_cell_hcpv: Vec<f64>,
pub gas: Option<ZoneVolumes>,
pub oil: Option<ZoneVolumes>,
}
impl InPlace {
#[must_use]
pub fn grv_mcm(&self) -> f64 {
m3_to_mcm(self.grv_m3)
}
#[must_use]
pub fn ooip_sm3(&self, boi: OilFvf) -> f64 {
self.hcpv_m3 / boi.value()
}
#[must_use]
pub fn oil_msm3(&self, boi: OilFvf) -> f64 {
m3_to_msm3(self.ooip_sm3(boi))
}
#[must_use]
pub fn ogip_sm3(&self, bgi: GasFvf) -> f64 {
self.hcpv_m3 / bgi.value()
}
#[must_use]
pub fn gas_bcm(&self, bgi: GasFvf) -> f64 {
m3_to_bcm(self.ogip_sm3(bgi))
}
#[must_use]
pub fn gas_zone_ogip_sm3(&self, bgi: GasFvf) -> f64 {
self.gas.map_or(0.0, |z| z.hcpv_m3) / bgi.value()
}
#[must_use]
pub fn oil_zone_ooip_sm3(&self, boi: OilFvf) -> f64 {
self.oil.map_or(0.0, |z| z.hcpv_m3) / boi.value()
}
}
pub fn compute_in_place(grid: &Grid, contact_depth_m: f64) -> Result<InPlace, StaticError> {
let nk = grid.dims().nk;
compute_clipped(
&GridSource::new(grid),
Clip::Single(contact_depth_m),
0..nk,
true,
)
}
pub fn compute_in_place_zone(
grid: &Grid,
contact_depth_m: f64,
k_range: Range<usize>,
) -> Result<InPlace, StaticError> {
compute_clipped(
&GridSource::new(grid),
Clip::Single(contact_depth_m),
k_range,
false,
)
}
pub fn compute_in_place_two_contact_zone(
grid: &Grid,
goc_m: f64,
owc_m: f64,
sw_gas: Option<f64>,
k_range: Range<usize>,
) -> Result<InPlace, StaticError> {
compute_clipped(
&GridSource::new(grid),
Clip::Two {
goc: goc_m,
owc: owc_m,
sw_gas,
},
k_range,
false,
)
}
#[must_use]
pub fn compute_zone_bulk(grid: &Grid, k_range: Range<usize>) -> InPlace {
compute_clipped(&GridSource::new(grid), Clip::Bulk, k_range, false)
.expect("bulk volumetrics reads only geometry — infallible in-core")
}
pub fn compute_in_place_summary(grid: &Grid, contact_depth_m: f64) -> Result<InPlace, StaticError> {
let nk = grid.dims().nk;
compute_clipped(
&GridSource::new(grid),
Clip::Single(contact_depth_m),
0..nk,
false,
)
}
pub fn compute_in_place_two_contact(
grid: &Grid,
goc_m: f64,
owc_m: f64,
sw_gas: Option<f64>,
) -> Result<InPlace, StaticError> {
let nk = grid.dims().nk;
compute_clipped(
&GridSource::new(grid),
Clip::Two {
goc: goc_m,
owc: owc_m,
sw_gas,
},
0..nk,
true,
)
}
pub fn compute_in_place_two_contact_summary(
grid: &Grid,
goc_m: f64,
owc_m: f64,
sw_gas: Option<f64>,
) -> Result<InPlace, StaticError> {
let nk = grid.dims().nk;
compute_clipped(
&GridSource::new(grid),
Clip::Two {
goc: goc_m,
owc: owc_m,
sw_gas,
},
0..nk,
false,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grid::{build_box, BoxSpec, Dims};
use crate::volumetrics::populate::{populate_constant, ConstantPriors};
use petektools::units::{ft3_to_rb, sm3_to_stb, FT_TO_M};
fn populated(area: f64, h: f64, top: f64, dims: Dims, p: ConstantPriors) -> Grid {
let mut g = build_box(BoxSpec {
area_m2: area,
gross_height_m: h,
dims,
top_depth_m: top,
aspect_ratio: 1.0,
})
.unwrap();
populate_constant(&mut g, p).unwrap();
g
}
#[test]
fn golden_ooip_full_column() {
let p = ConstantPriors {
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
};
let grid = populated(400_000.0, 50.0, 5000.0, Dims::new(10, 10, 5).unwrap(), p);
let res = compute_in_place(&grid, 6000.0).unwrap();
let bulk = 400_000.0 * 50.0;
assert!((res.grv_m3 - bulk).abs() / bulk < 1e-9);
let hcpv = bulk * 0.8 * 0.25 * 0.7;
assert!((res.hcpv_m3 - hcpv).abs() / hcpv < 1e-9);
let ooip = hcpv / 1.25;
assert!((res.ooip_sm3(OilFvf::new(1.25).unwrap()) - ooip).abs() / ooip < 1e-9);
}
#[test]
fn si_ooip_converts_back_to_the_identical_imperial_stb() {
let boi = OilFvf::new(1.25).unwrap();
let (phi, ntg, sw) = (0.25, 0.8, 0.3);
let grid = populated(
400_000.0,
50.0,
5000.0,
Dims::new(10, 10, 5).unwrap(),
ConstantPriors {
porosity: phi,
net_to_gross: ntg,
water_saturation: sw,
},
);
let ip = compute_in_place(&grid, 6000.0).unwrap();
let si_stb = sm3_to_stb(ip.ooip_sm3(boi));
let hcpv_m3 = 400_000.0 * 50.0 * ntg * phi * (1.0 - sw);
let hcpv_ft3 = hcpv_m3 / (FT_TO_M * FT_TO_M * FT_TO_M); let imperial_stb = ft3_to_rb(hcpv_ft3) / boi.value();
assert!(
(si_stb - imperial_stb).abs() / imperial_stb < 1e-9,
"SI STB {si_stb} != imperial STB {imperial_stb}"
);
assert!(
(ip.ooip_sm3(boi) - 2.24e6).abs() / 2.24e6 < 1e-9,
"OOIP == 2.24 MSm³, got {}",
ip.ooip_sm3(boi)
);
}
#[test]
fn contact_excludes_deeper_cells() {
let p = ConstantPriors {
porosity: 0.2,
net_to_gross: 1.0,
water_saturation: 0.2,
};
let grid = populated(100.0, 100.0, 5000.0, Dims::new(2, 2, 4).unwrap(), p);
let res = compute_in_place(&grid, 5050.0).unwrap();
assert_eq!(res.cells_in_column, 2 * 2 * 2);
let full = compute_in_place(&grid, 9999.0).unwrap();
assert!((res.grv_m3 - full.grv_m3 / 2.0).abs() / full.grv_m3 < 1e-9);
}
#[test]
fn two_contact_splits_gas_cap_and_oil_leg() {
let p = ConstantPriors {
porosity: 0.2,
net_to_gross: 1.0,
water_saturation: 0.2,
};
let grid = populated(400_000.0, 100.0, 5000.0, Dims::new(2, 2, 10).unwrap(), p);
let res = compute_in_place_two_contact(&grid, 5030.0, 5070.0, None).unwrap();
let gas = res.gas.expect("gas zone");
let oil = res.oil.expect("oil zone");
assert_eq!(gas.cells, 2 * 2 * 3, "gas cap = 3 layers");
assert_eq!(oil.cells, 2 * 2 * 4, "oil leg = 4 layers");
let per_layer = 400_000.0 * 10.0;
assert!((gas.grv_m3 - per_layer * 3.0).abs() / (per_layer * 3.0) < 1e-9);
assert!((oil.grv_m3 - per_layer * 4.0).abs() / (per_layer * 4.0) < 1e-9);
assert!((res.hcpv_m3 - (gas.hcpv_m3 + oil.hcpv_m3)).abs() < 1e-6);
assert!((gas.hcpv_m3 / oil.hcpv_m3 - 3.0 / 4.0).abs() < 1e-9);
let bgi = GasFvf::new(0.004).unwrap();
let boi = OilFvf::new(1.25).unwrap();
assert!(
(res.gas_zone_ogip_sm3(bgi) - gas.hcpv_m3 / 0.004).abs() / (gas.hcpv_m3 / 0.004) < 1e-9
);
let expect_ooip = oil.hcpv_m3 / 1.25;
assert!((res.oil_zone_ooip_sm3(boi) - expect_ooip).abs() / expect_ooip < 1e-9);
let single = compute_in_place(&grid, 5070.0).unwrap();
assert!((res.hcpv_m3 - single.hcpv_m3).abs() / single.hcpv_m3 < 1e-9);
assert!(single.gas.is_none() && single.oil.is_none());
}
#[test]
fn two_contact_rejects_goc_below_owc() {
let p = ConstantPriors {
porosity: 0.2,
net_to_gross: 1.0,
water_saturation: 0.2,
};
let grid = populated(100.0, 100.0, 5000.0, Dims::new(2, 2, 4).unwrap(), p);
assert!(compute_in_place_two_contact(&grid, 5080.0, 5030.0, None).is_err());
assert!(compute_in_place_two_contact(&grid, f64::NAN, 5030.0, None).is_err());
assert!(compute_in_place_two_contact(&grid, 5030.0, 5080.0, Some(1.5)).is_err());
}
#[test]
fn sw_gas_override_lowers_gas_cap_hcpv() {
let p = ConstantPriors {
porosity: 0.2,
net_to_gross: 1.0,
water_saturation: 0.4,
};
let grid = populated(100.0, 100.0, 5000.0, Dims::new(2, 2, 10).unwrap(), p);
let base = compute_in_place_two_contact(&grid, 5030.0, 5070.0, None).unwrap();
let over = compute_in_place_two_contact(&grid, 5030.0, 5070.0, Some(0.1)).unwrap();
let (gb, go) = (base.gas.unwrap(), over.gas.unwrap());
assert!((base.oil.unwrap().hcpv_m3 - over.oil.unwrap().hcpv_m3).abs() < 1e-6);
assert!(
(go.hcpv_m3 / gb.hcpv_m3 - (0.9 / 0.6)).abs() < 1e-9,
"gas HCPV ratio {} != 1.5",
go.hcpv_m3 / gb.hcpv_m3
);
}
#[test]
fn missing_property_errors() {
let grid = build_box(BoxSpec::square(40.0, 20.0, Dims::new(2, 2, 2).unwrap())).unwrap();
assert!(compute_in_place(&grid, 100.0).is_err());
}
#[test]
fn ogip_from_gas_fvf() {
let p = ConstantPriors {
porosity: 0.2,
net_to_gross: 0.9,
water_saturation: 0.25,
};
let grid = populated(50.0, 40.0, 8000.0, Dims::new(5, 5, 4).unwrap(), p);
let res = compute_in_place(&grid, 9000.0).unwrap();
let expected = res.hcpv_m3 / 0.004;
assert!((res.ogip_sm3(GasFvf::new(0.004).unwrap()) - expected).abs() / expected < 1e-9);
}
}