use crate::data::adapter::hardness_of;
use crate::data::petekio::{HorizonInput, ModelInputs, PolygonSet, Surface};
use crate::error::StaticError;
use crate::wireframe::{
Boundary, Contact, ContactKind, GriddedDepth, Hardness, Horizon, HorizonRole, Wireframe,
};
fn mean_defined(depths: &[f64]) -> f64 {
let (sum, n) = depths
.iter()
.filter(|d| !d.is_nan())
.fold((0.0, 0usize), |(s, n), d| (s + d, n + 1));
if n == 0 {
f64::NAN
} else {
sum / n as f64
}
}
fn boundary_from(boundary: Option<&PolygonSet>, area_m2: f64) -> Result<Boundary, StaticError> {
match boundary {
Some(p) => {
if let Some(exterior) = p.rings().into_iter().find(|r| r.len() >= 3) {
return Ok(Boundary {
ring: exterior.iter().map(|c| [c[0], c[1]]).collect(),
hardness: Hardness::Interpolated,
});
}
let bb = p.bbox();
Ok(Boundary {
ring: vec![
[bb.xmin, bb.ymin],
[bb.xmax, bb.ymin],
[bb.xmax, bb.ymax],
[bb.xmin, bb.ymax],
[bb.xmin, bb.ymin],
],
hardness: Hardness::Interpolated,
})
}
None => {
if !(area_m2.is_finite() && area_m2 > 0.0) {
return Err(StaticError::InvalidInput(format!(
"no boundary polygon and area not positive ({area_m2} m²)"
)));
}
let side = area_m2.sqrt();
Ok(Boundary {
ring: vec![
[0.0, 0.0],
[side, 0.0],
[side, side],
[0.0, side],
[0.0, 0.0],
],
hardness: Hardness::Assumed,
})
}
}
}
fn surface_depths(surface: &Surface) -> Vec<f64> {
surface.values().iter().map(|z| -z).collect()
}
fn gridded_depth_of(surface: &Surface, hardness: Hardness) -> GriddedDepth {
let is_hard = hardness == Hardness::Hard;
let depth_m = surface_depths(surface);
let is_control = depth_m.iter().map(|d| is_hard && !d.is_nan()).collect();
GriddedDepth {
ncol: surface.geom.ncol,
nrow: surface.geom.nrow,
depth_m,
is_control,
}
}
fn role_for(idx: usize, mean_depths: &[f64]) -> HorizonRole {
if mean_depths.len() == 1 {
return HorizonRole::Top;
}
let shallowest = mean_depths
.iter()
.enumerate()
.filter(|(_, d)| !d.is_nan())
.min_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i);
let deepest = mean_depths
.iter()
.enumerate()
.filter(|(_, d)| !d.is_nan())
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i);
if Some(idx) == shallowest {
HorizonRole::Top
} else if Some(idx) == deepest {
HorizonRole::Base
} else {
HorizonRole::Intermediate
}
}
fn horizons_from(inputs: &[HorizonInput]) -> Vec<Horizon> {
let mean_depths: Vec<f64> = inputs
.iter()
.map(|h| mean_defined(&surface_depths(&h.surface)))
.collect();
inputs
.iter()
.enumerate()
.map(|(i, h)| {
let hardness = hardness_of(h.provenance);
Horizon {
name: h.name.clone(),
role: role_for(i, &mean_depths),
surface: gridded_depth_of(&h.surface, hardness),
}
})
.collect()
}
pub fn assemble_wireframe(inputs: &ModelInputs) -> Result<Wireframe, StaticError> {
let boundary = boundary_from(
inputs.spatial.boundary.as_ref(),
inputs.summary.area_m2.value,
)?;
let horizons = horizons_from(&inputs.spatial.horizons);
let mut contacts = Vec::new();
if let Some(owc) = inputs.summary.owc_depth_m {
contacts.push(Contact {
kind: ContactKind::Owc,
depth_m: owc.value,
hardness: hardness_of(owc.provenance),
});
}
if let Some(goc) = inputs.summary.goc_depth_m {
contacts.push(Contact {
kind: ContactKind::Goc,
depth_m: goc.value,
hardness: hardness_of(goc.provenance),
});
}
Ok(Wireframe {
boundary,
horizons: std::sync::Arc::new(horizons),
contacts,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::petekio::{Distribution, Provenance, SpatialInputs, SummaryInputs, Uncertain};
fn scalar_inputs(area_m2: f64, owc_depth_m: Option<f64>) -> ModelInputs {
let det = |value, provenance| Uncertain {
value,
distribution: Distribution::Deterministic,
provenance,
};
ModelInputs {
summary: SummaryInputs {
area_m2: det(area_m2, Provenance::Assumed),
net_pay_m: det(25.0, Provenance::HardData),
porosity_frac: det(0.22, Provenance::HardData),
water_saturation_frac: det(0.30, Provenance::HardData),
net_to_gross_frac: det(0.80, Provenance::Interpolated),
owc_depth_m: owc_depth_m.map(|d| det(d, Provenance::HardData)),
goc_depth_m: None,
},
spatial: SpatialInputs {
boundary: None,
horizons: vec![],
well_curves: vec![],
},
}
}
#[test]
fn boundary_falls_back_to_area_square_when_no_polygon() {
let wf = assemble_wireframe(&scalar_inputs(2_509_000.0, None)).unwrap();
assert_eq!(wf.boundary.hardness, Hardness::Assumed);
let side = 2_509_000.0_f64.sqrt();
assert!((wf.boundary.ring[2][0] - side).abs() < 1e-6);
assert!(wf.horizons.is_empty());
}
#[test]
fn contacts_from_owc_with_provenance_hardness() {
let wf = assemble_wireframe(&scalar_inputs(2_509_000.0, Some(2511.6))).unwrap();
assert_eq!(wf.contacts.len(), 1);
assert_eq!(wf.contacts[0].kind, ContactKind::Owc);
assert!((wf.contacts[0].depth_m - 2511.6).abs() < 1e-9);
assert_eq!(wf.contacts[0].hardness, Hardness::Hard);
}
#[test]
fn no_contact_when_owc_goc_absent() {
let wf = assemble_wireframe(&scalar_inputs(2_509_000.0, None)).unwrap();
assert!(wf.contacts.is_empty());
}
#[test]
fn no_extent_at_all_is_an_error() {
assert!(assemble_wireframe(&scalar_inputs(0.0, None)).is_err());
}
fn horizon_at(name: &str, elevation_m: f64) -> HorizonInput {
use crate::data::petekio::GridGeometry;
let geom = GridGeometry {
xori: 0.0,
yori: 0.0,
xinc: 100.0,
yinc: 100.0,
ncol: 2,
nrow: 2,
rotation_deg: 0.0,
yflip: false,
};
HorizonInput {
name: name.to_string(),
surface: Surface::constant(geom, elevation_m),
provenance: Provenance::Interpolated,
}
}
#[test]
fn top_is_structurally_shallowest_under_negative_down_elevation() {
let horizons = horizons_from(&[
horizon_at("ShallowTop", -2000.0),
horizon_at("DeepBase", -2100.0),
]);
assert_eq!(
horizons[0].role,
HorizonRole::Top,
"the structurally shallowest surface must be Top"
);
assert_eq!(
horizons[1].role,
HorizonRole::Base,
"the structurally deepest surface must be Base"
);
}
#[test]
fn roles_order_by_depth() {
assert_eq!(role_for(0, &[8000.0]), HorizonRole::Top);
let depths = [8200.0, 8000.0, 8100.0];
assert_eq!(role_for(1, &depths), HorizonRole::Top); assert_eq!(role_for(0, &depths), HorizonRole::Base); assert_eq!(role_for(2, &depths), HorizonRole::Intermediate);
}
#[test]
fn mean_defined_skips_nan() {
assert_eq!(mean_defined(&[8000.0, f64::NAN, 8200.0]), 8100.0);
assert!(mean_defined(&[f64::NAN, f64::NAN]).is_nan());
}
fn shoelace(ring: &[[f64; 2]]) -> f64 {
let mut s = 0.0;
for w in ring.windows(2) {
s += w[0][0] * w[1][1] - w[1][0] * w[0][1];
}
(s / 2.0).abs()
}
#[test]
fn boundary_reads_the_real_ring_not_the_bbox() {
use crate::data::petekio::PolygonSet;
use std::io::Write;
let path = std::env::temp_dir().join(format!("srs_data_ring_{}.irap", std::process::id()));
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "0 0 0\n100 0 0\n0 100 0").unwrap();
}
let poly = PolygonSet::load_irap_polygons(&path).unwrap();
let b = boundary_from(Some(&poly), 999.0).unwrap();
let _ = std::fs::remove_file(&path);
let area = shoelace(&b.ring);
assert!(
(area - 5000.0).abs() < 1.0,
"ring area {area} should be the triangle (5000), not the bbox (10000)"
);
assert!(
(area - poly.area()).abs() < 1.0,
"ring area tracks polygon area"
);
assert!(
area < 9000.0,
"ring must not collapse to the 10000 bbox rectangle"
);
assert_eq!(b.hardness, Hardness::Interpolated);
}
}