use crate::core::{
FluidContact, Log, LogKind, PointSet, PolygonSet, Station, Surface, Top, TrajectoryInput, Well,
};
use crate::foundation::{GeoError, Point3, Result};
use crate::manager::GeoData;
use crate::FormatKind;
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
fn ext_of(path: &Path) -> String {
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase()
}
#[derive(Debug, Clone)]
struct ClassifiedFile {
path: PathBuf,
kind: FormatKind,
}
fn classify(path: &Path) -> Result<FormatKind> {
crate::io::detect::detect(path)
}
impl GeoData {
pub fn load_surface(&mut self, name: &str, path: impl AsRef<Path>) -> Result<&Surface> {
let path = path.as_ref();
let surface = match classify(path)? {
FormatKind::IrapClassicGrid => Surface::load_irap_classic(path)?,
FormatKind::Cps3Grid => Surface::load_cps3_grid(path)?,
FormatKind::Unknown => match ext_of(path).as_str() {
"irap" | "gri" | "" => Surface::load_irap_classic(path)?,
"cps3grid" => Surface::load_cps3_grid(path)?,
other => {
return Err(GeoError::Parse(format!(
"load_surface: unsupported surface extension '.{other}' for '{}'",
path.display()
)))
}
},
other => {
return Err(GeoError::Format(format!(
"load_surface: '{}' is {other:?}, not a supported surface format",
path.display()
)))
}
};
let entry = self.surfaces.entry(name.to_string());
Ok(entry.or_insert(surface))
}
pub fn load_well(
&mut self,
id: &str,
head: (f64, f64),
kb: f64,
files: impl AsRef<Path>,
) -> Result<&Well> {
let root = files.as_ref();
let aliases = self.curve_aliases.clone();
let mut paths: Vec<std::path::PathBuf> = if root.is_dir() {
let mut entries = Vec::new();
collect_files(root, &mut entries)?;
entries.sort();
entries
} else {
vec![root.to_path_buf()]
};
paths.retain(|p| p.is_file());
let mut files: Vec<ClassifiedFile> = paths
.into_iter()
.map(|path| classify(&path).map(|kind| ClassifiedFile { path, kind }))
.collect::<Result<_>>()?;
let id_key = normalize_id(id);
let matches: Vec<bool> = files
.iter()
.map(|f| file_matches_id(&f.path, &id_key))
.collect();
if matches.iter().any(|&m| m) {
let mut keep = matches.iter();
files.retain(|f| *keep.next().unwrap() || f.kind == FormatKind::CrsMetaXml);
}
let wellpaths: Vec<_> = files
.iter()
.filter(|f| f.kind == FormatKind::WellPath)
.map(|f| f.path.clone())
.collect();
let las: Vec<_> = files
.iter()
.filter(|f| f.kind == FormatKind::Las)
.map(|f| f.path.clone())
.collect();
let mut tops: Vec<Top> = Vec::new();
for file in &files {
if file.kind == FormatKind::CsvPoints && ext_of(&file.path) == "csv" {
tops.extend(Top::load_csv(&file.path, "name", "md")?);
}
}
let crs = files
.iter()
.filter(|f| f.kind == FormatKind::CrsMetaXml)
.find_map(|f| crate::io::crsmeta::load_label(&f.path).ok());
let mut well = Well::new(id, head, kb);
if let Some(label) = crs {
well.set_crs(label);
}
if wellpaths.is_empty() {
let mut logs = Vec::new();
for p in &las {
logs.extend(load_tagged_logs(p).ok().into_iter().flatten());
}
let st = well.sidetrack_mut("");
if let Some((lo, hi)) = log_md_span(&logs) {
st.add_trajectory(TrajectoryInput::Stations(vec![
Station::new(lo, 0.0, 0.0),
Station::new(hi, 0.0, 0.0),
]))?;
}
for log in logs {
st.add_log(canonicalize_log(log, aliases.as_ref()));
}
} else {
let labels = bore_labels(&wellpaths);
for (i, (wp_path, label)) in labels.iter().enumerate() {
let wp = crate::io::wellpath::load(wp_path)?;
if i == 0 {
well.head = wp.head;
well.kb = wp.kb;
if let Some(c) = &wp.crs {
well.set_crs(c.clone());
}
}
let rows: Vec<(Station, Point3)> = wp
.rows
.iter()
.map(|r| {
(
Station::new(r.md, r.inc_deg, r.azi_deg),
Point3::new(r.x, r.y, r.tvd - wp.kb),
)
})
.collect();
well.sidetrack_mut(label)
.add_trajectory(TrajectoryInput::PositionedSurvey(rows))?;
}
let label_list: Vec<String> = labels.iter().map(|(_, l)| l.clone()).collect();
for p in &las {
let bore = route_bore(p, &label_list);
let st = well.sidetrack_mut(&bore);
for log in load_tagged_logs(p).ok().into_iter().flatten() {
st.add_log(canonicalize_log(log, aliases.as_ref()));
}
}
}
if !tops.is_empty() {
well.sidetrack_mut("").add_tops(tops);
}
let entry = self.wells.entry(id.to_string());
Ok(entry.or_insert(well))
}
pub fn load_well_tops(&mut self, path: impl AsRef<Path>) -> Result<usize> {
let recs = crate::io::petrel_tops::load(path.as_ref())?;
let order = {
let mut by_well: IndexMap<&str, Vec<(f64, &str)>> = IndexMap::new();
let mut names: Vec<&str> = Vec::new();
for r in &recs {
if r.kind.eq_ignore_ascii_case("Horizon") {
by_well
.entry(r.well.as_str())
.or_default()
.push((r.md, r.surface.as_str()));
if !names.contains(&r.surface.as_str()) {
names.push(r.surface.as_str());
}
}
}
let resolved: Vec<(String, String)> = self
.strat_hints
.iter()
.map(|(a, b)| Ok((resolve_top_name(a, &names)?, resolve_top_name(b, &names)?)))
.collect::<Result<_>>()?;
let hints: Vec<(&str, &str)> = resolved
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
let seqs: Vec<Vec<(f64, &str)>> = by_well.into_values().collect();
crate::algorithms::wells::merge_strat_order(&seqs, &hints)
};
let ids: Vec<String> = self.wells.keys().cloned().collect();
let mut added = 0;
for r in recs {
let Some(id) = ids.iter().find(|id| well_name_matches(id, &r.well)) else {
continue;
};
let suffix = bore_suffix(id, &r.well);
let well = self.wells.get_mut(id).expect("id came from this map");
let label = if well.sidetrack(&suffix).is_some() {
suffix
} else {
String::new()
};
let st = well.sidetrack_mut(&label);
if r.kind.eq_ignore_ascii_case("Horizon") {
st.add_tops(vec![Top::new(r.surface, r.md)]);
added += 1;
} else if r.kind.eq_ignore_ascii_case("Other") {
st.add_contacts(vec![FluidContact::new(r.surface, r.md)]);
}
}
for well in self.wells.values_mut() {
well.set_strat_order(&order);
}
self.strat_order = order;
Ok(added)
}
pub fn load_points(&mut self, name: &str, path: impl AsRef<Path>) -> Result<&PointSet> {
let path = path.as_ref();
let points = match classify(path)? {
FormatKind::GeoJson => PointSet::load_geojson(path)?,
FormatKind::CsvPoints => PointSet::load_csv(path, "x", "y", "z")?,
FormatKind::EarthVisionGrid => PointSet::load_earthvision_grid(path)?,
FormatKind::IrapClassicPoints => PointSet::load_irap_points(path)?,
FormatKind::Unknown => match ext_of(path).as_str() {
"geojson" | "json" => PointSet::load_geojson(path)?,
"csv" => PointSet::load_csv(path, "x", "y", "z")?,
"earthvisiongrid" => PointSet::load_earthvision_grid(path)?,
"xyz" | "irap" | "dat" | "irapclassicpoints" | "" => {
PointSet::load_irap_points(path)?
}
other => {
return Err(GeoError::Parse(format!(
"load_points: unsupported point extension '.{other}' for '{}'",
path.display()
)))
}
},
other => {
return Err(GeoError::Format(format!(
"load_points: '{}' is {other:?}, not a supported point format",
path.display()
)))
}
};
let entry = self.points.entry(name.to_string());
Ok(entry.or_insert(points))
}
pub fn load_points_with_topology(
&mut self,
name: &str,
path: impl AsRef<Path>,
topology_path: impl AsRef<Path>,
) -> Result<&PointSet> {
let points = PointSet::load_irap_points_with_topology(path, topology_path)?;
let entry = self.points.entry(name.to_string());
Ok(entry.or_insert(points))
}
pub fn load_polygons(&mut self, name: &str, path: impl AsRef<Path>) -> Result<&PolygonSet> {
let path = path.as_ref();
let polygons = match classify(path)? {
FormatKind::GeoJson => PolygonSet::load_geojson(path)?,
FormatKind::Cps3Lines => PolygonSet::load_cps3_lines(path)?,
FormatKind::IrapClassicPoints => PolygonSet::load_irap_polygons(path)?,
FormatKind::Unknown => match ext_of(path).as_str() {
"geojson" | "json" => PolygonSet::load_geojson(path)?,
"shp" => PolygonSet::load_shapefile(path)?,
"cps3lines" => PolygonSet::load_cps3_lines(path)?,
"pol" | "xyz" | "irap" | "" => PolygonSet::load_irap_polygons(path)?,
other => {
return Err(GeoError::Parse(format!(
"load_polygons: unsupported polygon extension '.{other}' for '{}'",
path.display()
)))
}
},
other => {
return Err(GeoError::Format(format!(
"load_polygons: '{}' is {other:?}, not a supported polygon format",
path.display()
)))
}
};
let entry = self.polygons.entry(name.to_string());
Ok(entry.or_insert(polygons))
}
}
fn log_md_span(logs: &[Log]) -> Option<(f64, f64)> {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for log in logs {
let md = log.view();
let md = md.md();
if let (Some(&first), Some(&last)) = (md.first(), md.last()) {
lo = lo.min(first);
hi = hi.max(last);
}
}
(lo.is_finite() && hi.is_finite() && hi > lo).then_some((lo, hi))
}
fn bore_labels(wellpaths: &[std::path::PathBuf]) -> Vec<(std::path::PathBuf, String)> {
if wellpaths.len() < 2 {
return wellpaths
.iter()
.map(|p| (p.clone(), String::new()))
.collect();
}
let stems: Vec<String> = wellpaths
.iter()
.map(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
})
.collect();
let prefix = shared_underscore_prefix(&stems);
wellpaths
.iter()
.zip(&stems)
.map(|(p, stem)| {
let label = stem.strip_prefix(&prefix).unwrap_or(stem).to_string();
(p.clone(), label)
})
.collect()
}
fn shared_underscore_prefix(stems: &[String]) -> String {
if stems.len() < 2 {
return String::new();
}
let first = &stems[0];
let mut prefix = String::new();
for (i, _) in first.match_indices('_') {
let cand = &first[..=i]; if stems.iter().all(|s| s.starts_with(cand)) {
prefix = cand.to_string();
}
}
prefix
}
fn resolve_top_name(token: &str, names: &[&str]) -> Result<String> {
let t = token.trim();
if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(t)) {
return Ok(n.to_string());
}
let with_top = format!("{t} top");
if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(&with_top)) {
return Ok(n.to_string());
}
let lc = t.to_ascii_lowercase();
let hits: Vec<&str> = names
.iter()
.copied()
.filter(|n| n.to_ascii_lowercase().contains(&lc))
.collect();
match hits.as_slice() {
[one] => Ok(one.to_string()),
[] => Err(GeoError::Parse(format!(
"strat hint entry '{token}': no loaded well top matches it (from the \
IngestSpec strat_hints / strat_hint hints)"
))),
many => Err(GeoError::Parse(format!(
"strat hint entry '{token}' is ambiguous — matches {} (disambiguate the \
IngestSpec strat_hints / strat_hint entry)",
many.join(", ")
))),
}
}
fn collect_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_files(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
fn normalize_id(s: &str) -> String {
s.trim().to_ascii_lowercase().replace(['/', '-', ' '], "_")
}
fn file_matches_id(path: &Path, id_key: &str) -> bool {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let norm = normalize_id(stem);
norm == *id_key
|| norm
.strip_prefix(id_key)
.is_some_and(|r| r.starts_with('_'))
}
fn canonicalize_log(mut log: Log, aliases: Option<&crate::analysis::NameMap>) -> Log {
if let Some(map) = aliases {
log.mnemonic = crate::analysis::normalize::canonical_mnemonic_with(&log.mnemonic, map);
}
log
}
fn load_tagged_logs(path: &Path) -> Result<Vec<Log>> {
let is_core = path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.to_ascii_lowercase().contains("core"));
let logs = Log::load_las_all(path)?;
Ok(if is_core {
logs.into_iter()
.map(|l| l.with_kind(LogKind::Core))
.collect()
} else {
logs
})
}
fn well_name_matches(id: &str, record_well: &str) -> bool {
let nid = normalize_id(id);
let nrec = normalize_id(record_well);
nrec == nid
|| nrec
.strip_prefix(&nid)
.is_some_and(|rest| rest.starts_with('_'))
}
fn bore_suffix(id: &str, record_well: &str) -> String {
let rec = record_well.trim();
let nid = normalize_id(id);
if normalize_id(rec).starts_with(&nid) && rec.is_char_boundary(nid.len()) {
rec[nid.len()..].trim_matches([' ', '_', '-']).to_string()
} else {
String::new()
}
}
fn route_bore(path: &Path, labels: &[String]) -> String {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let tokens: Vec<&str> = stem.split(['_', '-', '.', ' ']).collect();
labels
.iter()
.find(|label| !label.is_empty() && tokens.iter().any(|t| t.eq_ignore_ascii_case(label)))
.cloned()
.unwrap_or_default()
}