use crate::core::log::{Log, LogView};
use crate::core::tops::{FluidContact, Interval, Top};
use crate::core::trajectory::{Trajectory, TrajectoryInput};
use crate::foundation::{GeoError, Point3, Result, Stats};
use indexmap::IndexMap;
use std::collections::HashMap;
const MAIN: &str = "";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Well {
pub id: String,
pub head: (f64, f64),
pub kb: f64,
crs: Option<String>,
sidetracks: IndexMap<String, Sidetrack>,
#[serde(default)]
default_bore: Option<String>,
}
impl Well {
pub fn new(id: impl Into<String>, head: (f64, f64), kb: f64) -> Well {
let mut sidetracks = IndexMap::new();
sidetracks.insert(MAIN.to_string(), Sidetrack::new(MAIN.to_string(), head, kb));
Well {
id: id.into(),
head,
kb,
crs: None,
sidetracks,
default_bore: None,
}
}
pub fn crs(&self) -> Option<&str> {
self.crs.as_deref()
}
pub fn set_crs(&mut self, crs: impl Into<String>) {
self.crs = Some(crs.into());
}
pub fn sidetrack(&self, label: &str) -> Option<&Sidetrack> {
self.sidetracks.get(label)
}
pub fn sidetrack_mut(&mut self, label: &str) -> &mut Sidetrack {
let (head, kb) = (self.head, self.kb);
self.sidetracks
.entry(label.to_string())
.or_insert_with(|| Sidetrack::new(label.to_string(), head, kb))
}
pub fn main(&self) -> &Sidetrack {
self.sidetracks
.get(MAIN)
.expect("the main sidetrack is always present")
}
fn primary(&self) -> &Sidetrack {
if let Some(label) = &self.default_bore {
if let Some(st) = self.sidetracks.get(label) {
return st;
}
}
let mut with_traj = self
.sidetracks
.values()
.filter(|s| !s.trajectories().is_empty());
match (with_traj.next(), with_traj.next()) {
(Some(only), None) => only,
_ => self.main(),
}
}
pub fn is_multibore(&self) -> bool {
self.sidetracks
.values()
.filter(|s| !s.trajectories().is_empty())
.count()
> 1
}
pub fn set_default_bore(&mut self, label: &str) -> Result<()> {
if !self.sidetracks.contains_key(label) {
return Err(GeoError::NotFound(format!(
"well '{}' has no bore '{label}' (bores: {})",
self.id,
self.bore_list()
)));
}
self.default_bore = Some(label.to_string());
Ok(())
}
pub fn default_bore(&self) -> Option<&str> {
self.default_bore.as_deref()
}
pub fn clear_default_bore(&mut self) {
self.default_bore = None;
}
pub fn bores(&self) -> impl Iterator<Item = &str> {
self.sidetracks.values().map(|s| s.label.as_str())
}
fn bore_list(&self) -> String {
self.bores()
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(", ")
}
pub fn bore_id(&self, label: &str) -> String {
if label.is_empty() {
self.id.clone()
} else {
format!("{} {}", self.id, label)
}
}
pub fn sidetracks(&self) -> impl Iterator<Item = &Sidetrack> {
self.sidetracks.values()
}
pub fn xyz(&self, md: f64) -> Option<Point3> {
self.primary().xyz(md)
}
pub fn tvd(&self, md: f64) -> Option<f64> {
self.primary().tvd(md)
}
pub fn md_at_tvd(&self, tvd: f64) -> Option<f64> {
self.primary().md_at_tvd(tvd)
}
pub fn top(&self, name: &str) -> Option<Interval<'_>> {
self.primary().top(name)
}
pub fn log(&self, mnemonic: &str) -> Option<LogView<'_>> {
self.primary().log(mnemonic)
}
pub fn logs(&self) -> impl Iterator<Item = &Log> {
self.primary().logs()
}
pub fn mnemonics(&self) -> Vec<&str> {
self.primary().logs().map(|l| l.mnemonic.as_str()).collect()
}
pub fn zones(&self) -> Vec<Interval<'_>> {
self.primary().zones()
}
pub fn zone_stats(&self, mnemonic: &str) -> Vec<(String, Stats)> {
self.primary().zone_stats(mnemonic)
}
pub fn contacts(&self) -> impl Iterator<Item = &FluidContact> {
self.primary().contacts()
}
pub fn contact(&self, name: &str) -> Option<&FluidContact> {
self.primary().contact(name)
}
pub fn set_strat_order(&mut self, order: &[String]) {
for st in self.sidetracks.values_mut() {
st.set_strat_order(order);
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Sidetrack {
pub label: String,
head: (f64, f64),
kb: f64,
trajectories: Vec<Trajectory>,
active: usize,
logs: Vec<Log>,
tops: Vec<Top>,
#[serde(default)]
contacts: Vec<FluidContact>,
strat_order: Vec<String>,
}
impl Sidetrack {
fn new(label: String, head: (f64, f64), kb: f64) -> Sidetrack {
Sidetrack {
label,
head,
kb,
trajectories: Vec::new(),
active: 0,
logs: Vec::new(),
tops: Vec::new(),
contacts: Vec::new(),
strat_order: Vec::new(),
}
}
pub fn add_trajectory(&mut self, input: TrajectoryInput) -> Result<&mut Trajectory> {
let traj = Trajectory::from_input(input, self.head, self.kb)?;
self.trajectories.push(traj);
self.active = self.trajectories.len() - 1;
Ok(self
.trajectories
.last_mut()
.expect("just pushed a trajectory"))
}
pub fn set_active(&mut self, index: usize) -> Result<()> {
if index >= self.trajectories.len() {
return Err(GeoError::OutOfRange(format!(
"trajectory index {index} out of range (have {})",
self.trajectories.len()
)));
}
self.active = index;
Ok(())
}
pub fn active(&self) -> &Trajectory {
self.trajectories
.get(self.active)
.expect("active() requires at least one trajectory")
}
pub fn trajectories(&self) -> &[Trajectory] {
&self.trajectories
}
fn active_traj(&self) -> Option<&Trajectory> {
self.trajectories.get(self.active)
}
pub fn xyz(&self, md: f64) -> Option<Point3> {
self.active_traj().and_then(|t| t.xyz(md))
}
pub fn tvd(&self, md: f64) -> Option<f64> {
self.active_traj().and_then(|t| t.tvd(md))
}
pub fn md_at_tvd(&self, tvd: f64) -> Option<f64> {
self.active_traj().and_then(|t| t.md_at_tvd(tvd))
}
pub fn add_log(&mut self, log: Log) {
self.logs.push(log);
}
pub fn retain_logs_except(&mut self, mnemonic: &str) {
self.logs
.retain(|l| !l.mnemonic.eq_ignore_ascii_case(mnemonic));
}
pub fn add_tops(&mut self, tops: Vec<Top>) {
self.tops.extend(tops);
self.sort_tops();
}
pub fn add_contacts(&mut self, contacts: Vec<FluidContact>) {
self.contacts.extend(contacts);
}
pub fn set_strat_order(&mut self, order: &[String]) {
self.strat_order = order.to_vec();
self.sort_tops();
}
fn sort_tops(&mut self) {
if self.strat_order.is_empty() {
self.tops.sort_by(|a, b| a.md.total_cmp(&b.md));
return;
}
let rank = strat_rank(&self.strat_order);
let rank_of = |t: &Top| rank.get(t.name.as_str()).copied().unwrap_or(usize::MAX);
self.tops
.sort_by(|a, b| a.md.total_cmp(&b.md).then(rank_of(a).cmp(&rank_of(b))));
}
pub fn top(&self, name: &str) -> Option<Interval<'_>> {
let i = self
.tops
.iter()
.position(|t| t.name.eq_ignore_ascii_case(name))?;
let top = &self.tops[i];
let base = self
.tops
.get(i + 1)
.map(|n| n.md)
.or_else(|| self.active_traj().map(|t| t.md_range().1))
.unwrap_or(f64::NAN);
Some(Interval::new(top.name.clone(), top.md, base, &self.logs))
}
pub fn log(&self, mnemonic: &str) -> Option<LogView<'_>> {
self.logs
.iter()
.find(|l| l.mnemonic.eq_ignore_ascii_case(mnemonic))
.map(|l| l.view())
}
pub fn contacts(&self) -> impl Iterator<Item = &FluidContact> {
self.contacts.iter()
}
pub fn contact(&self, name: &str) -> Option<&FluidContact> {
self.contacts
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
}
pub fn logs(&self) -> impl Iterator<Item = &Log> {
self.logs.iter()
}
pub fn mnemonics(&self) -> Vec<&str> {
self.logs.iter().map(|l| l.mnemonic.as_str()).collect()
}
pub fn zones(&self) -> Vec<Interval<'_>> {
let td = self.active_traj().map(|t| t.md_range().1);
let mut zones: Vec<Interval<'_>> = self
.tops
.iter()
.enumerate()
.map(|(i, top)| {
let base = self
.tops
.get(i + 1)
.map(|n| n.md)
.or(td)
.unwrap_or(f64::NAN);
Interval::new(top.name.clone(), top.md, base, &self.logs)
})
.collect();
if !self.strat_order.is_empty() {
let rank = strat_rank(&self.strat_order);
zones.sort_by_key(|z| rank.get(z.name.as_str()).copied().unwrap_or(usize::MAX));
}
zones
}
pub fn zone_stats(&self, mnemonic: &str) -> Vec<(String, Stats)> {
self.zones()
.into_iter()
.filter_map(|z| z.log(mnemonic).map(|lv| (z.name.clone(), lv.stats())))
.collect()
}
}
fn strat_rank(order: &[String]) -> HashMap<&str, usize> {
order
.iter()
.enumerate()
.map(|(i, n)| (n.as_str(), i))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::trajectory::Station;
use approx::assert_relative_eq;
fn vertical(md_top: f64, md_bot: f64) -> TrajectoryInput {
TrajectoryInput::Stations(vec![
Station::new(md_top, 0.0, 0.0),
Station::new(md_bot, 0.0, 0.0),
])
}
#[test]
fn new_well_has_empty_main() {
let w = Well::new("15/9-A1", (1000.0, 2000.0), 80.0);
assert_eq!(w.id, "15/9-A1");
assert_eq!(w.main().label, "");
assert!(w.main().trajectories().is_empty());
assert!(w.xyz(1000.0).is_none());
}
#[test]
fn add_trajectory_makes_newest_active() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 1000.0)).unwrap();
st.add_trajectory(vertical(0.0, 2000.0)).unwrap();
assert_eq!(st.trajectories().len(), 2);
assert_eq!(st.active().md_range(), (0.0, 2000.0));
assert!(st.xyz(1500.0).is_some());
}
#[test]
fn set_active_switches_and_bounds_check() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 1000.0)).unwrap();
st.add_trajectory(vertical(0.0, 2000.0)).unwrap();
st.set_active(0).unwrap();
assert_eq!(st.active().md_range(), (0.0, 1000.0));
assert!(st.set_active(5).is_err());
}
#[test]
fn well_delegates_to_main_active() {
let mut w = Well::new("w", (500.0, 600.0), 30.0);
w.sidetrack_mut("")
.add_trajectory(vertical(0.0, 1000.0))
.unwrap();
let p = w.xyz(400.0).unwrap();
assert_relative_eq!(p.x, 500.0, epsilon = 1e-9);
assert_relative_eq!(p.y, 600.0, epsilon = 1e-9);
assert_relative_eq!(p.z, 30.0 - 400.0, epsilon = 1e-9); assert_relative_eq!(w.tvd(400.0).unwrap(), 370.0, epsilon = 1e-9); assert_relative_eq!(w.md_at_tvd(370.0).unwrap(), 400.0, epsilon = 1e-9);
}
fn ntg_log() -> Log {
Log::new(
"NTG",
"v/v",
vec![
2400.0, 2410.0, 2420.0, 2430.0, 2440.0, 2450.0, 2460.0, 2470.0, 2480.0, 2490.0,
2500.0,
],
vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.0],
)
.unwrap()
}
#[test]
fn top_resolves_interval_to_next_top() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 3000.0)).unwrap();
st.add_tops(vec![Top::new("Brent", 2400.0), Top::new("Dunlin", 2450.0)]);
st.add_log(ntg_log());
let brent = w.top("Brent").unwrap();
assert_eq!(brent.top_md, 2400.0);
assert_eq!(brent.base_md, 2450.0); assert_eq!(brent.thickness_md(), 50.0);
let v = brent.log("NTG").unwrap();
assert_eq!(v.md(), &[2400.0, 2410.0, 2420.0, 2430.0, 2440.0]);
let s = v.stats();
assert_eq!(s.count, 5);
assert_relative_eq!(s.mean, 0.3, epsilon = 1e-12);
}
#[test]
fn zone_stats_average_and_sum_per_zone() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 2500.0)).unwrap(); st.add_tops(vec![Top::new("Brent", 2400.0), Top::new("Dunlin", 2450.0)]);
st.add_log(ntg_log());
let zs = w.zone_stats("NTG");
assert_eq!(zs.len(), 2);
assert_eq!(zs[0].0, "Brent");
assert_relative_eq!(zs[0].1.mean, 0.3, epsilon = 1e-12);
assert_relative_eq!(zs[0].1.sum, 1.5, epsilon = 1e-12);
assert_eq!(zs[1].0, "Dunlin");
assert_relative_eq!(zs[1].1.mean, 0.8, epsilon = 1e-12);
assert_relative_eq!(zs[1].1.sum, 4.0, epsilon = 1e-12);
let zones = w.zones();
assert_eq!(zones.len(), 2);
assert_eq!(zones[0].name, "Brent");
}
#[test]
fn strat_order_assigns_coincident_interval_to_deepest_member() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 2500.0)).unwrap(); st.add_tops(vec![
Top::new("A", 2400.0),
Top::new("B", 2420.0),
Top::new("Sand", 2420.0),
]);
let base = |w: &Well, name: &str| {
w.zones()
.into_iter()
.find(|z| z.name == name)
.map(|z| (z.top_md, z.base_md))
};
let md_order: Vec<_> = w.zones().iter().map(|z| z.name.clone()).collect();
assert_eq!(md_order, ["A", "B", "Sand"]);
assert_eq!(base(&w, "Sand"), Some((2420.0, 2500.0)));
assert_eq!(base(&w, "B"), Some((2420.0, 2420.0)));
w.set_strat_order(&["A".to_string(), "Sand".to_string(), "B".to_string()]);
let strat: Vec<_> = w.zones().iter().map(|z| z.name.clone()).collect();
assert_eq!(strat, ["A", "Sand", "B"]);
assert_eq!(base(&w, "B"), Some((2420.0, 2500.0))); assert_eq!(base(&w, "Sand"), Some((2420.0, 2420.0))); assert_eq!(base(&w, "A"), Some((2400.0, 2420.0)));
w.set_strat_order(&["Sand".to_string(), "A".to_string()]);
let mixed: Vec<_> = w.zones().iter().map(|z| z.name.clone()).collect();
assert_eq!(mixed, ["Sand", "A", "B"]);
}
#[test]
fn deepest_top_runs_to_td() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 2500.0)).unwrap(); st.add_tops(vec![Top::new("Brent", 2400.0), Top::new("Dunlin", 2450.0)]);
let dunlin = w.top("Dunlin").unwrap();
assert_eq!(dunlin.top_md, 2450.0);
assert_eq!(dunlin.base_md, 2500.0); }
#[test]
fn enumerate_logs_and_mnemonics() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_log(ntg_log());
st.add_log(Log::new("GR", "GAPI", vec![2400.0, 2410.0], vec![40.0, 60.0]).unwrap());
assert_eq!(w.logs().count(), 2);
assert_eq!(w.mnemonics(), vec!["NTG", "GR"]); }
#[test]
fn ergonomic_chain_stats() {
let mut w = Well::new("15/9-A1", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_trajectory(vertical(0.0, 3000.0)).unwrap();
st.add_tops(vec![Top::new("Brent", 2400.0), Top::new("Dunlin", 2450.0)]);
st.add_log(ntg_log());
let stats = w.top("Brent").unwrap().log("NTG").unwrap().stats();
assert_relative_eq!(stats.mean, 0.3, epsilon = 1e-12);
assert!(w.top("brent").unwrap().log("ntg").is_some());
assert!(w.top("Nope").is_none());
}
#[test]
fn well_log_returns_full_curve() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
let st = w.sidetrack_mut("");
st.add_log(ntg_log());
let v = w.log("NTG").unwrap();
assert_eq!(v.md().len(), 11);
assert!(w.log("GR").is_none());
}
#[test]
fn sidetrack_mut_creates_named_bore_lazily() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
assert!(w.sidetrack("T2").is_none());
w.sidetrack_mut("T2")
.add_trajectory(vertical(0.0, 500.0))
.unwrap();
assert_eq!(w.sidetrack("T2").unwrap().label, "T2");
assert_eq!(w.sidetracks().count(), 2);
assert!(w.sidetrack("T2").unwrap().xyz(250.0).is_some());
assert!(w.xyz(250.0).is_some());
}
#[test]
fn single_trajectory_bore_resolves_regardless_of_label() {
let mut w = Well::new("99/9-1 A", (500.0, 600.0), 30.0);
let st = w.sidetrack_mut("A");
st.add_trajectory(vertical(0.0, 3000.0)).unwrap();
st.add_tops(vec![Top::new("Brent", 2400.0), Top::new("Dunlin", 2450.0)]);
st.add_log(ntg_log());
assert!(w.main().trajectories().is_empty()); let p = w.xyz(400.0).unwrap();
assert_relative_eq!(p.z, 30.0 - 400.0, epsilon = 1e-9); assert_relative_eq!(w.tvd(400.0).unwrap(), 370.0, epsilon = 1e-9);
assert!(w.log("NTG").is_some());
assert_eq!(w.mnemonics(), vec!["NTG"]);
let brent = w.top("Brent").unwrap();
assert_eq!(brent.base_md, 2450.0); assert_eq!(w.zones().len(), 2);
}
#[test]
fn multibore_resolution_falls_back_to_main() {
let mut w = Well::new("w", (0.0, 0.0), 0.0);
w.sidetrack_mut("A")
.add_trajectory(vertical(0.0, 1000.0))
.unwrap();
w.sidetrack_mut("A").add_log(ntg_log());
w.sidetrack_mut("B")
.add_trajectory(vertical(0.0, 2000.0))
.unwrap();
assert!(w.is_multibore());
assert!(w.xyz(500.0).is_none());
assert!(w.log("NTG").is_none());
assert!(w.sidetrack("A").unwrap().xyz(500.0).is_some());
assert!(w.sidetrack("A").unwrap().log("NTG").is_some());
assert_eq!(w.sidetrack("A").unwrap().mnemonics(), vec!["NTG"]);
}
#[test]
fn default_bore_routes_well_level_accessors() {
let mut w = Well::new("99/9-1", (0.0, 0.0), 0.0);
w.sidetrack_mut("A")
.add_trajectory(vertical(0.0, 3000.0))
.unwrap();
w.sidetrack_mut("A").add_log(ntg_log());
w.sidetrack_mut("B")
.add_trajectory(vertical(0.0, 2000.0))
.unwrap();
assert!(w.is_multibore());
assert_eq!(w.default_bore(), None);
assert!(w.log("NTG").is_none());
w.set_default_bore("A").unwrap();
assert_eq!(w.default_bore(), Some("A"));
assert!(w.xyz(500.0).is_some()); assert!(w.log("NTG").is_some());
assert_eq!(w.mnemonics(), vec!["NTG"]);
assert!(w.set_default_bore("Z").is_err());
w.clear_default_bore();
assert!(w.log("NTG").is_none());
}
#[test]
fn bores_and_bore_id() {
let mut w = Well::new("99/9-1", (0.0, 0.0), 0.0);
w.sidetrack_mut("A")
.add_trajectory(vertical(0.0, 1000.0))
.unwrap();
w.sidetrack_mut("ST2")
.add_trajectory(vertical(0.0, 1000.0))
.unwrap();
assert_eq!(w.bores().collect::<Vec<_>>(), vec!["", "A", "ST2"]);
assert_eq!(w.bore_id(""), "99/9-1");
assert_eq!(w.bore_id("A"), "99/9-1 A");
assert_eq!(w.bore_id("ST2"), "99/9-1 ST2");
}
}