use super::spec::TrendSpec;
use crate::units::SrsError;
use petekio::{
BBox, GeoData, GridGeometry, GridMethod, Interval, LogView, NameMap, Point3, Sidetrack,
Surface, Unit,
};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
mod helpers;
use helpers::*;
#[derive(Debug, Clone, Default)]
pub struct Inventory {
pub surfaces: Vec<String>,
pub polygons: Vec<String>,
pub points: Vec<String>,
pub wells: Vec<String>,
pub tops: Vec<String>,
pub merged: Vec<(String, String)>,
pub skipped: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct TopsPick {
pub name: String,
pub picks: Vec<(String, f64)>,
pub level_m: f64,
pub spread_m: f64,
}
pub struct BoreWell<'a> {
pub id: String,
bore: &'a Sidetrack,
}
impl BoreWell<'_> {
pub fn top(&self, name: &str) -> Option<Interval<'_>> {
self.bore.top(name)
}
pub fn xyz(&self, md: f64) -> Option<Point3> {
self.bore.xyz(md)
}
pub fn trajectory(&self) -> Vec<[f64; 3]> {
if self.bore.trajectories().is_empty() {
return Vec::new();
}
let (md0, md1) = self.bore.active().md_range();
if md1 <= md0 {
return Vec::new();
}
const N: usize = 48;
(0..=N)
.filter_map(|s| {
let md = md0 + (md1 - md0) * (s as f64 / N as f64);
self.bore.xyz(md).map(|p| [p.x, p.y, -p.z])
})
.collect()
}
pub fn log(&self, mnemonic: &str) -> Option<LogView<'_>> {
self.bore.log(mnemonic)
}
pub fn zones(&self) -> Vec<Interval<'_>> {
self.bore.zones()
}
}
pub struct Project {
geo: GeoData,
inventory: Inventory,
crs: Option<String>,
contacts: Vec<(String, String, f64)>,
}
const SURFACE_EXTS: &[&str] = &["irap", "gri", "cps3grid"];
const POLYGON_EXTS: &[&str] = &["pol", "shp", "cps3lines"];
const POINT_EXTS: &[&str] = &["xyz", "dat", "irapclassicpoints", "earthvisiongrid"];
impl Project {
pub fn load(
root: impl AsRef<Path>,
crs: Option<String>,
aliases: Option<Vec<(String, String)>>,
) -> Result<Self, SrsError> {
let root = root.as_ref();
if !root.is_dir() {
return Err(SrsError::InvalidInput(format!(
"project root '{}' is not a directory",
root.display()
)));
}
let mut geo = GeoData::new(Unit::Metres);
if let Some(pairs) = aliases {
geo.set_curve_aliases(NameMap::from_pairs(pairs));
}
let mut inv = Inventory::default();
let mut tops_files: Vec<PathBuf> = Vec::new();
for wells_dir in child_dirs_named(root, "wells") {
let ids = discover_well_ids(&wells_dir);
for id in &ids {
if let Err(e) = geo.load_well(id, (0.0, 0.0), 0.0, &wells_dir) {
inv.skipped.push((
format!("{} [well '{id}']", wells_dir.display()),
e.to_string(),
));
}
}
record_core_merges(&geo, &wells_dir, &ids, &mut inv.merged);
}
let mut files = Vec::new();
collect_files(root, &mut files);
for path in &files {
if under_dir(path, "wells") {
continue; }
let ext = ext_of(path);
let parent = parent_name(path);
if is_tops_file(path, &ext) {
tops_files.push(path.clone());
} else if SURFACE_EXTS.contains(&ext.as_str()) {
load_named(&mut geo, path, &mut inv, Kind::Surface);
} else if ext == "geojson" || ext == "json" {
if dir_is(&parent, &["point", "scatter"]) {
load_named(&mut geo, path, &mut inv, Kind::Points);
} else {
load_named(&mut geo, path, &mut inv, Kind::Polygons);
}
} else if POLYGON_EXTS.contains(&ext.as_str()) {
load_named(&mut geo, path, &mut inv, Kind::Polygons);
} else if ext == "csv" || POINT_EXTS.contains(&ext.as_str()) {
load_named(&mut geo, path, &mut inv, Kind::Points);
} else if ext == "las" || ext == "wellpath" {
inv.skipped.push((
path.display().to_string(),
"well file outside a wells/<id>/ directory".into(),
));
} else {
inv.skipped.push((
path.display().to_string(),
format!("unrecognised extension '.{ext}'"),
));
}
}
let mut contacts: Vec<(String, String, f64)> = Vec::new();
for tp in tops_files {
if let Err(e) = geo.load_well_tops(&tp) {
inv.skipped.push((tp.display().to_string(), e.to_string()));
}
contacts.extend(parse_other_contacts(&tp));
}
inv.tops = discover_tops(&geo, &contacts);
inv.surfaces.sort();
inv.surfaces.dedup();
let mut this = Self {
geo,
inventory: inv,
crs,
contacts,
};
this.inventory.wells = this.wells().iter().map(|bw| bw.id.clone()).collect();
Ok(this)
}
pub fn inventory(&self) -> &Inventory {
&self.inventory
}
pub fn crs(&self) -> Option<&str> {
self.crs.as_deref()
}
pub fn wells(&self) -> Vec<BoreWell<'_>> {
let mut out = Vec::new();
for well in self.geo.wells().iter() {
for bore in well.sidetracks() {
if bore.trajectories().is_empty() {
continue; }
out.push(BoreWell {
id: well.bore_id(&bore.label),
bore,
});
}
}
out
}
pub fn surface(&self, name: &str) -> Option<&Surface> {
self.geo.surface(name)
}
pub fn surface_value_at(&self, name: &str, x: f64, y: f64) -> Result<f64, SrsError> {
let s = self.require_surface(name)?;
Ok(nearest_node_value(s, x, y))
}
pub fn surface_values_at(&self, name: &str, points: &[[f64; 2]]) -> Result<Vec<f64>, SrsError> {
let s = self.require_surface(name)?;
Ok(points
.iter()
.map(|p| nearest_node_value(s, p[0], p[1]))
.collect())
}
pub fn collocated_trend(
&self,
name: &str,
corr: f64,
as_depth: bool,
) -> Result<TrendSpec, SrsError> {
let s = self.require_surface(name)?;
let g = &s.geom;
let (ncol, nrow) = (g.ncol, g.nrow);
let vals = s.values();
let mut values = vec![f64::NAN; ncol * nrow];
let mut min_finite = f64::INFINITY;
for j in 0..nrow {
for i in 0..ncol {
let v = if as_depth {
-vals[[i, j]]
} else {
vals[[i, j]]
};
values[j * ncol + i] = v;
if v.is_finite() {
min_finite = min_finite.min(v);
}
}
}
if min_finite.is_finite() && min_finite < 0.0 {
for v in values.iter_mut() {
if v.is_finite() {
*v -= min_finite;
}
}
}
TrendSpec::collocated(ncol, nrow, values, g.xori, g.yori, g.xinc, g.yinc, corr)
}
fn require_surface(&self, name: &str) -> Result<&Surface, SrsError> {
self.geo
.surface(name)
.ok_or_else(|| SrsError::InvalidInput(format!("surface '{name}' not loaded")))
}
pub fn horizon_surface(
&self,
name: &str,
lattice: Option<&GridGeometry>,
cell_size_m: Option<f64>,
) -> Result<Surface, SrsError> {
if let Some(s) = self.geo.surface(name) {
return Ok(s.clone());
}
if let Some(ps) = self.geo.points(name) {
if ps.is_empty() {
return Err(SrsError::InvalidInput(format!(
"horizon point-set '{name}' has no points to grid"
)));
}
let geom = lattice
.cloned()
.unwrap_or_else(|| lattice_from_bbox(&ps.bbox(), cell_size_m));
return ps
.to_surface(geom, GridMethod::MinimumCurvature)
.map_err(SrsError::from);
}
Err(SrsError::InvalidInput(format!(
"horizon '{name}' is neither a loaded surface nor a loaded point-set"
)))
}
pub fn horizon_geom(
&self,
name: &str,
cell_size_m: Option<f64>,
) -> Result<GridGeometry, SrsError> {
if let Some(s) = self.geo.surface(name) {
return Ok(s.geom.clone());
}
if let Some(ps) = self.geo.points(name) {
if ps.is_empty() {
return Err(SrsError::InvalidInput(format!(
"horizon point-set '{name}' has no points to grid"
)));
}
return Ok(lattice_from_bbox(&ps.bbox(), cell_size_m));
}
Err(SrsError::InvalidInput(format!(
"horizon '{name}' is neither a loaded surface nor a loaded point-set"
)))
}
pub fn geo(&self) -> &GeoData {
&self.geo
}
pub fn pick(&self, name: &str, wells: Option<&[String]>) -> Option<TopsPick> {
let keep = |id: &str| match wells {
None | Some([]) => true,
Some(ws) => ws
.iter()
.any(|w| id == w || id.starts_with(&format!("{w} "))),
};
let mut picks: Vec<(String, f64)> = Vec::new();
for bw in self.wells() {
if !keep(&bw.id) {
continue;
}
if let Some(iv) = bw.top(name) {
if let Some(d) = bw.xyz(iv.top_md).map(|p| -p.z) {
picks.push((bw.id.clone(), d));
}
}
}
for (surf, well, depth) in &self.contacts {
if surf.eq_ignore_ascii_case(name) && keep(well) {
picks.push((well.clone(), *depth));
}
}
if picks.is_empty() {
return None;
}
let n = picks.len() as f64;
let level = picks.iter().map(|(_, d)| *d).sum::<f64>() / n;
let spread = if picks.len() < 2 {
0.0
} else {
let var = picks.iter().map(|(_, d)| (d - level).powi(2)).sum::<f64>() / (n - 1.0);
var.sqrt()
};
Some(TopsPick {
name: name.to_string(),
picks,
level_m: level,
spread_m: spread,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_tmp(name: &str, body: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("srscore_f3_{}_{name}", std::process::id()));
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn cell_size_derives_lattice_from_extent() {
let b = BBox {
xmin: 500_000.0,
xmax: 530_000.0, ymin: 6_700_000.0,
ymax: 6_720_000.0, };
let g = lattice_from_bbox(&b, Some(100.0));
assert_eq!(g.ncol, 301, "30 km / 100 m → 300 cells → 301 nodes");
assert_eq!(g.nrow, 201, "20 km / 100 m → 200 cells → 201 nodes");
assert!((g.xinc - 100.0).abs() < 1e-9 && (g.yinc - 100.0).abs() < 1e-9);
let f = lattice_from_bbox(&b, None);
assert_eq!((f.ncol, f.nrow), (101, 101));
}
#[test]
fn cell_size_caps_loudly_at_the_axis_maximum() {
let b = BBox {
xmin: 0.0,
xmax: 1_000_000.0,
ymin: 0.0,
ymax: 10.0,
};
let g = lattice_from_bbox(&b, Some(0.5));
assert_eq!(g.ncol, MAX_CELLS_PER_AXIS + 1, "x-axis capped");
assert_eq!(
g.nrow, 21,
"small y-axis uncapped (10 m / 0.5 m = 20 cells)"
);
}
#[test]
fn parse_other_contacts_surfaces_goc_fwl() {
let body = "\
# Petrel well tops
VERSION 2
BEGIN HEADER
X
Y
Z
MD
Type
Surface
Well
END HEADER
431600 6521600 -2000.0 2025.0 Horizon \"Top Reservoir\" \"99/9-1 A\"
431600 6521600 -2111.0 2248.0 Other \"GOC\" \"99/9-1 A\"
431600 6521600 -2190.0 2325.0 Other \"FWL\" \"99/9-1 A\"
";
let p = write_tmp("tops.tops", body);
let contacts = parse_other_contacts(&p);
std::fs::remove_file(&p).ok();
assert_eq!(contacts.len(), 2, "{contacts:?}");
let goc = contacts.iter().find(|(s, _, _)| s == "GOC").unwrap();
assert!((goc.2 - 2111.0).abs() < 1e-9, "GOC depth {}", goc.2);
assert_eq!(goc.1, "99/9-1 A"); let fwl = contacts.iter().find(|(s, _, _)| s == "FWL").unwrap();
assert!((fwl.2 - 2190.0).abs() < 1e-9);
}
#[test]
fn split_petrel_tokens_keeps_quoted_fields() {
let toks = split_petrel_tokens("1 2 3 Other \"Field Main top\" \"99/9-1 ST2\"");
assert_eq!(
toks,
["1", "2", "3", "Other", "Field Main top", "99/9-1 ST2"]
);
}
#[test]
fn parse_other_contacts_decodes_latin1() {
let mut body: Vec<u8> =
b"BEGIN HEADER\nX\nY\nZ\nMD\nType\nSurface\nWell\nEND HEADER\n".to_vec();
body.extend_from_slice(b"431600 6521600 -2111.0 2248.0 Other \"");
body.extend_from_slice(&[b'B', b'l', 0xE5, b'b', 0xE6, b'r']); body.extend_from_slice(b"\" \"15/9-A1\"\n");
let p = std::env::temp_dir().join(format!("srscore_rb_{}.tops", std::process::id()));
std::fs::write(&p, &body).unwrap();
let contacts = parse_other_contacts(&p);
std::fs::remove_file(&p).ok();
assert_eq!(contacts.len(), 1, "{contacts:?}");
assert_eq!(contacts[0].0, "Blåbær"); assert!((contacts[0].2 - 2111.0).abs() < 1e-9);
}
}