use crate::units::SrsError;
use petekstatic::volumetrics::{validate_fraction, validate_positive, GasFvf, OilFvf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fluid {
Oil,
Gas,
}
impl Fluid {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Fluid::Oil => "oil",
Fluid::Gas => "gas",
}
}
pub fn parse(s: &str) -> Result<Self, SrsError> {
match s.to_ascii_lowercase().as_str() {
"oil" => Ok(Fluid::Oil),
"gas" => Ok(Fluid::Gas),
other => Err(SrsError::InvalidInput(format!(
"fluid must be 'oil' or 'gas', got '{other}'"
))),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoxDraw {
pub area_m2: f64,
pub gross_height_m: f64,
pub top_depth_m: f64,
pub contact_depth_m: f64,
pub porosity: f64,
pub net_to_gross: f64,
pub water_saturation: f64,
pub fvf: f64,
}
impl BoxDraw {
#[must_use]
pub fn above_contact_fraction(&self) -> f64 {
if self.gross_height_m <= 0.0 {
return 0.0;
}
((self.contact_depth_m - self.top_depth_m) / self.gross_height_m).clamp(0.0, 1.0)
}
#[must_use]
pub fn grv_m3(&self) -> f64 {
self.area_m2 * self.gross_height_m * self.above_contact_fraction()
}
#[must_use]
pub fn hcpv_m3(&self) -> f64 {
self.grv_m3() * self.net_to_gross * self.porosity * (1.0 - self.water_saturation)
}
pub fn validate(&self, fluid: Fluid) -> Result<(), SrsError> {
validate_positive("area_m2", self.area_m2)?;
validate_positive("gross_height_m", self.gross_height_m)?;
validate_fraction("porosity", self.porosity)?;
validate_fraction("net_to_gross", self.net_to_gross)?;
validate_fraction("water_saturation", self.water_saturation)?;
match fluid {
Fluid::Oil => {
OilFvf::new(self.fvf)?;
}
Fluid::Gas => {
GasFvf::new(self.fvf)?;
}
}
Ok(())
}
#[must_use]
pub fn in_place(&self, fluid: Fluid) -> f64 {
let hcpv = self.hcpv_m3();
match fluid {
Fluid::Oil | Fluid::Gas => hcpv / self.fvf,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use petekstatic::grid::{build_box, BoxSpec, Dims};
use petekstatic::volumetrics::{compute_in_place, populate_constant, ConstantPriors};
fn draw(contact: f64) -> BoxDraw {
BoxDraw {
area_m2: 400_000.0,
gross_height_m: 15.0,
top_depth_m: 1500.0,
contact_depth_m: contact,
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
fvf: 1.25,
}
}
#[test]
fn full_column_matches_grv_from_grid() {
let d = draw(1800.0); let analytic = d.in_place(Fluid::Oil);
let mut grid = build_box(BoxSpec {
area_m2: 400_000.0,
gross_height_m: 15.0,
dims: Dims::new(10, 10, 5).unwrap(),
top_depth_m: 1500.0,
aspect_ratio: 1.0,
})
.unwrap();
populate_constant(
&mut grid,
ConstantPriors {
porosity: 0.25,
net_to_gross: 0.8,
water_saturation: 0.3,
},
)
.unwrap();
let grid_res = compute_in_place(&grid, 1800.0).unwrap();
let grid_ooip = grid_res.ooip_sm3(petekstatic::volumetrics::OilFvf::new(1.25).unwrap());
assert!(
(analytic - grid_ooip).abs() / grid_ooip < 1e-9,
"analytic {analytic} != grid {grid_ooip}"
);
}
#[test]
fn known_si_golden_value() {
let d = draw(1800.0);
assert!((d.hcpv_m3() - 840_000.0).abs() < 1e-6);
assert!((d.in_place(Fluid::Oil) - 672_000.0).abs() < 1e-6);
}
#[test]
fn contact_fraction_clamps() {
assert!((draw(1507.5).above_contact_fraction() - 0.5).abs() < 1e-12);
assert!((draw(1200.0).above_contact_fraction() - 0.0).abs() < 1e-12); assert!((draw(2743.0).above_contact_fraction() - 1.0).abs() < 1e-12); }
#[test]
fn gas_uses_bgi_directly() {
let mut d = draw(2743.0);
d.fvf = 0.004;
let giip = d.in_place(Fluid::Gas);
assert!((giip - d.hcpv_m3() / 0.004).abs() / giip < 1e-12);
}
}