use crate::foundation::{GeoError, Result, Unit};
use std::collections::HashMap;
pub fn canonical_mnemonic(raw: &str) -> String {
let stem = strip_vintage(raw.trim());
let key = stem.to_ascii_uppercase();
let canonical = match key.as_str() {
"PHIE" | "PHI" | "PHI_E" | "EFFPHI" | "PHIEF" => "PHIE",
"PHIT" | "PHI_T" | "TOTPHI" => "PHIT",
"NPHI" | "TNPH" | "NEU" | "CNL" => "NPHI",
"SW" | "SWE" | "SUWI" | "SW_E" => "SW",
"SWT" | "SW_T" | "SWTOT" => "SWT",
"GR" | "GRC" | "SGR" | "CGR" | "GAMMA" => "GR",
"VSH" | "VCL" | "VSHALE" | "VCLAY" | "VSHGR" => "VSH",
"PERM" | "K" | "KLOGH" | "KH" | "PERMH" => "PERM",
"RHOB" | "DEN" | "DENS" | "ZDEN" => "RHOB",
"DT" | "AC" | "SONIC" | "DTCO" => "DT",
"RT" | "RES" | "ILD" | "LLD" | "RDEP" => "RT",
"NTG" | "NET_GROSS" | "N_G" => "NTG",
_ => return stem.to_string(),
};
canonical.to_string()
}
pub fn canonical_mnemonic_with(raw: &str, aliases: &NameMap) -> String {
aliases.get(raw).unwrap_or_else(|| canonical_mnemonic(raw))
}
fn strip_vintage(s: &str) -> &str {
if let Some((head, tail)) = s.rsplit_once('_') {
if tail.len() == 4 && tail.bytes().all(|b| b.is_ascii_digit()) {
return head;
}
}
s
}
pub fn parse_length_unit(s: &str) -> Option<Unit> {
match s.trim().to_ascii_lowercase().as_str() {
"m" | "metre" | "metres" | "meter" | "meters" => Some(Unit::Metres),
"ft" | "f" | "feet" | "foot" => Some(Unit::Feet),
_ => None,
}
}
pub fn is_percent_unit(s: &str) -> bool {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"%" | "percent" | "pct" | "p.u." | "pu"
)
}
pub fn harmonise_fraction(value: f64, unit: &str) -> f64 {
if is_percent_unit(unit) {
value / 100.0
} else {
value
}
}
pub fn harmonise_length(value: f64, from: Unit, to: Unit) -> f64 {
from.convert(value, to)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct NameMap {
map: HashMap<String, String>,
}
impl std::fmt::Display for NameMap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.map.is_empty() {
return write!(f, "(no aliases)");
}
let mut rows: Vec<(&String, &String)> = self.map.iter().collect();
rows.sort_by(|a, b| a.0.cmp(b.0));
let body = rows
.iter()
.map(|(k, v)| format!("{k} -> {v}"))
.collect::<Vec<_>>()
.join(", ");
write!(f, "{body}")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StratHints {
pairs: Vec<(String, String)>,
}
impl StratHints {
pub fn new() -> Self {
Self::default()
}
pub fn from_pairs(pairs: impl IntoIterator<Item = (String, String)>) -> Self {
Self {
pairs: pairs.into_iter().collect(),
}
}
pub fn push(&mut self, above: impl Into<String>, below: impl Into<String>) {
self.pairs.push((above.into(), below.into()));
}
pub fn push_spec(&mut self, spec: &str) -> Result<()> {
let (above, below) = if let Some((l, r)) = spec.split_once('<') {
(l.trim(), r.trim())
} else if let Some((l, r)) = spec.split_once('>') {
(r.trim(), l.trim())
} else {
return Err(GeoError::Parse(format!(
"strat hint '{spec}' must contain '<' (above) or '>' (below)"
)));
};
if above.is_empty() || below.is_empty() {
return Err(GeoError::Parse(format!(
"strat hint '{spec}' has an empty side"
)));
}
self.push(above, below);
Ok(())
}
pub fn pairs(&self) -> &[(String, String)] {
&self.pairs
}
pub fn is_empty(&self) -> bool {
self.pairs.is_empty()
}
}
impl std::fmt::Display for StratHints {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.pairs.is_empty() {
return write!(f, "(no hints)");
}
let body = self
.pairs
.iter()
.map(|(a, b)| format!("{a} < {b}"))
.collect::<Vec<_>>()
.join(", ");
write!(f, "{body}")
}
}
impl NameMap {
pub fn new() -> Self {
Self::default()
}
pub fn from_pairs(pairs: impl IntoIterator<Item = (String, String)>) -> Self {
let mut m = Self::new();
for (alias, canonical) in pairs {
m.insert(alias, canonical);
}
m
}
pub fn insert(&mut self, alias: impl Into<String>, canonical: impl Into<String>) {
self.map
.insert(alias.into().trim().to_ascii_lowercase(), canonical.into());
}
pub fn canonical(&self, name: &str) -> String {
self.get(name).unwrap_or_else(|| name.trim().to_string())
}
pub fn get(&self, name: &str) -> Option<String> {
self.map
.get(name.trim().to_ascii_lowercase().as_str())
.cloned()
}
pub fn pairs(&self) -> Vec<(String, String)> {
let mut rows: Vec<(String, String)> = self
.map
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
rows
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn namemap_value_affordances() {
let nm = NameMap::from_pairs([
("PHIE_2025".to_string(), "PHIE".to_string()),
("SW_2025".to_string(), "SW".to_string()),
]);
assert_eq!(
nm.pairs(),
vec![
("phie_2025".to_string(), "PHIE".to_string()),
("sw_2025".to_string(), "SW".to_string()),
]
);
assert_eq!(format!("{nm}"), "phie_2025 -> PHIE, sw_2025 -> SW");
assert_eq!(format!("{}", NameMap::new()), "(no aliases)");
let json = serde_json::to_string(&nm).unwrap();
assert_eq!(serde_json::from_str::<NameMap>(&json).unwrap(), nm);
}
#[test]
fn strat_hints_value_affordances() {
let mut h = StratHints::from_pairs([("A".to_string(), "B".to_string())]);
h.push_spec("C < D").unwrap();
h.push_spec("F > E").unwrap(); assert_eq!(
h.pairs(),
vec![
("A".to_string(), "B".to_string()),
("C".to_string(), "D".to_string()),
("E".to_string(), "F".to_string()),
]
);
assert_eq!(format!("{h}"), "A < B, C < D, E < F");
assert!(StratHints::new().is_empty());
assert!(h.push_spec("no operator").is_err());
let json = serde_json::to_string(&h).unwrap();
assert_eq!(serde_json::from_str::<StratHints>(&json).unwrap(), h);
}
#[test]
fn mnemonic_aliases_resolve_case_insensitively() {
assert_eq!(canonical_mnemonic("phi"), "PHIE");
assert_eq!(canonical_mnemonic(" Suwi "), "SW");
assert_eq!(canonical_mnemonic("VCL"), "VSH");
assert_eq!(canonical_mnemonic("NPHI"), "NPHI");
assert_eq!(canonical_mnemonic("PHIT"), "PHIT");
assert_eq!(canonical_mnemonic("NTG_PhieLam"), "NTG_PhieLam");
}
#[test]
fn vintage_suffix_is_stripped() {
assert_eq!(canonical_mnemonic("PHIE_2025"), "PHIE");
assert_eq!(canonical_mnemonic("SW_2025"), "SW");
assert_eq!(canonical_mnemonic("VShale_2025"), "VSH");
assert_eq!(canonical_mnemonic("SWT_2025"), "SWT");
assert_eq!(canonical_mnemonic("PHIT_2025"), "PHIT");
assert_eq!(canonical_mnemonic("PERM_Lam"), "PERM_Lam");
}
#[test]
fn user_alias_map_resolves_the_unguessable() {
let aliases = NameMap::from_pairs([
("NTG_PhieLam".to_string(), "NTG".to_string()),
("PERM_Lam_2025".to_string(), "PERM".to_string()),
]);
assert_eq!(canonical_mnemonic_with("NTG_PhieLam", &aliases), "NTG");
assert_eq!(canonical_mnemonic_with("perm_lam_2025", &aliases), "PERM"); assert_eq!(canonical_mnemonic_with("PHIE_2025", &aliases), "PHIE");
assert_eq!(NameMap::new().get("nope"), None);
}
#[test]
fn length_units_parse() {
assert_eq!(parse_length_unit("M"), Some(Unit::Metres));
assert_eq!(parse_length_unit("feet"), Some(Unit::Feet));
assert_eq!(parse_length_unit("furlong"), None);
}
#[test]
fn percent_harmonises_to_fraction() {
assert!(is_percent_unit("%"));
assert!(!is_percent_unit("v/v"));
assert_relative_eq!(harmonise_fraction(25.0, "%"), 0.25);
assert_relative_eq!(harmonise_fraction(0.25, "v/v"), 0.25);
assert!(harmonise_fraction(f64::NAN, "%").is_nan());
}
#[test]
fn length_harmonises_via_unit() {
assert_relative_eq!(harmonise_length(100.0, Unit::Feet, Unit::Metres), 30.48);
}
#[test]
fn name_map_is_identity_for_unknowns() {
let m = NameMap::from_pairs([
("Brent Gp".to_string(), "Brent".to_string()),
("DUNLIN GROUP".to_string(), "Dunlin".to_string()),
]);
assert_eq!(m.canonical("brent gp"), "Brent"); assert_eq!(m.canonical("Dunlin Group"), "Dunlin");
assert_eq!(m.canonical(" Statfjord "), "Statfjord"); }
}