# petekIO — locked public API

> **This file is the contract.** The build must expose exactly these signatures
> (names, arguments, return types). Bodies are the implementer's; the *surface* is
> fixed. Changing a signature here requires sign-off. Rust is canonical; the
> Python (PyO3) section mirrors it. See `SPEC.md` for design/architecture.

Conventions: `Result<T> = std::result::Result<T, GeoError>`; arrays are
`ndarray` (`Array2<f64>` surfaces, `Array3<f32>` cubes); undefined = `NaN`.

---

## Format Detection

```rust
pub enum FormatKind {
    Cps3Grid,
    Cps3Lines,
    IrapClassicGrid,
    IrapClassicPoints,
    EarthVisionGrid,
    Las,
    WellPath,
    PetrelTops,
    CrsMetaXml,
    GeoJson,
    CsvPoints,
    Unknown,
}

/// Bounded content-sniffing detector. Reads only leading header bytes; file
/// extension is a fallback/tiebreaker, not the primary authority.
pub fn detect(path: impl AsRef<Path>) -> Result<FormatKind>;
```

## foundation

```rust
pub enum Unit { Feet, Metres }

pub struct Point3 { pub x: f64, pub y: f64, pub z: f64 }

pub struct BBox { pub xmin: f64, pub ymin: f64, pub xmax: f64, pub ymax: f64 }

/// Regular, rotatable areal lattice (IRAP/RMS model).
pub struct GridGeometry {
    pub xori: f64, pub yori: f64,     // origin (node 0,0)
    pub xinc: f64, pub yinc: f64,     // node spacing
    pub ncol: usize, pub nrow: usize, // node counts (i along x, j along y)
    pub rotation_deg: f64,            // CCW of the I-axis from East
    pub yflip: bool,
}
impl GridGeometry {
    pub fn node_xy(&self, i: usize, j: usize) -> (f64, f64);
    pub fn xy_to_ij(&self, x: f64, y: f64) -> Option<(f64, f64)>; // fractional node coords
    pub fn bbox(&self) -> BBox;
}

#[derive(thiserror::Error, Debug)]
pub enum GeoError { /* Io, Parse, Format, GeometryMismatch, GeometryInference, NotFound, OutOfRange, Unsupported, Unit, ... */ }
```

## Stats — the universal aggregation result

```rust
pub struct Stats {
    pub count: usize,
    pub mean: f64, pub min: f64, pub max: f64, pub std: f64, pub sum: f64,
    pub p10: f64, pub p50: f64, pub p90: f64,
}
impl Stats {
    pub fn of(values: &[f64]) -> Stats;                       // NaN-skipping
    pub fn weighted(values: &[f64], weights: &[f64]) -> Stats;
    pub fn geomean(values: &[f64]) -> f64;                    // geometric mean of positives (e.g. permeability)
    pub fn percentile(&self, p: f64) -> f64;                  // arbitrary p in [0,1]
}
```

## Surface

```rust
pub struct Surface { pub geom: GridGeometry /* values + attributes are private */ }

impl Surface {
    // construction / IO
    pub fn new(geom: GridGeometry, values: Array2<f64>) -> Result<Surface>;
    pub fn constant(geom: GridGeometry, value: f64) -> Surface;
    pub fn load_irap_classic(path: impl AsRef<Path>) -> Result<Surface>;   // FIRST format
    pub fn load_cps3_grid(path: impl AsRef<Path>) -> Result<Surface>;      // CPS-3 regular grid (row-major, south→north; origin at ymin, matches IRAP)
    pub fn save_irap_classic(&self, path: impl AsRef<Path>) -> Result<()>;

    // access
    pub fn values(&self) -> &Array2<f64>;
    pub fn sample(&self, x: f64, y: f64) -> Option<f64>;     // bilinear (petektools kernel); None outside grid or if NEAREST corner NaN; else renormalized over finite corners. Exact under rotation.
    pub fn attr(&self, name: &str) -> Option<&Array2<f64>>;
    pub fn set_attr(&mut self, name: &str, values: Array2<f64>) -> Result<()>;
    pub fn attr_names(&self) -> Vec<&str>;
    pub fn as_attr_surface(&self, name: &str) -> Option<Surface>;          // promote attr → ops

    // element-wise math (on the primary values; return a new Surface)
    pub fn ln(&self) -> Surface;
    pub fn log10(&self) -> Surface;
    pub fn exp(&self) -> Surface;
    pub fn sqrt(&self) -> Surface;
    pub fn abs(&self) -> Surface;
    pub fn powf(&self, n: f64) -> Surface;
    pub fn clamp_min(&self, lo: f64) -> Surface;
    pub fn clamp(&self, lo: f64, hi: f64) -> Surface;

    // surface ↔ surface (require equal geometry → GeometryMismatch otherwise)
    pub fn plus(&self, other: &Surface) -> Result<Surface>;
    pub fn minus(&self, other: &Surface) -> Result<Surface>;
    pub fn times(&self, other: &Surface) -> Result<Surface>;
    pub fn divided_by(&self, other: &Surface) -> Result<Surface>;
    pub fn thickness(top: &Surface, base: &Surface, clamp_zero: bool) -> Result<Surface>;

    // statistics / volumetrics
    pub fn stats(&self) -> Stats;
    pub fn area_below(&self, depth: f64) -> f64;             // Σ cell-area where value ≤ depth
    pub fn area_above(&self, depth: f64) -> f64;
    pub fn volume_between(&self, base: &Surface) -> Result<f64>;
    pub fn hypsometry(&self) -> Vec<(f64, f64)>;            // (depth, area) curve

    // resample (petektools kernel, Bilinear; one-home). Kernel NaN-corner policy
    // (see sample). Err(Unsupported) on a ROTATED source/target (axis-aligned kernel
    // only; yflip OK) pending suite grid-rotation. Was `-> Surface` (infallible)
    // pre-0.2.9.
    pub fn resample(&self, target: &GridGeometry) -> Result<Surface>;

    // filtering + outline
    pub fn smooth(&self, radius: usize) -> Surface;          // NaN-aware moving average; preserves the defined mask
    pub fn edge(&self) -> Option<PolygonSet>;                // convex hull of defined nodes; None if <3

    // cube extraction (Phase 3) → a surface attribute
    pub fn slice_cube(&self, cube: &Cube, sampling: Sampling) -> Surface;
    pub fn slice_cube_window(&self, cube: &Cube, above: f64, below: f64, agg: WindowAgg) -> Surface;
}

// operator overloads (scalar + surface), mirroring the methods
impl std::ops::Add<f64> for &Surface { type Output = Surface; /* + - * / */ }
impl std::ops::Sub<&Surface> for &Surface { type Output = Result<Surface>; }
```

## Wells: `Well` → `Sidetrack` → `Trajectory` (+ tops, logs)

```rust
pub struct Well { pub id: String, pub head: (f64, f64), pub kb: f64 /* crs + sidetracks private */ }
impl Well {
    pub fn new(id: impl Into<String>, head: (f64, f64), kb: f64) -> Well;
    pub fn crs(&self) -> Option<&str>;                  // CRS label (provenance; never reprojected)
    pub fn set_crs(&mut self, crs: impl Into<String>);
    pub fn sidetrack(&self, label: &str) -> Option<&Sidetrack>;
    pub fn sidetrack_mut(&mut self, label: &str) -> &mut Sidetrack;   // creates if missing
    pub fn main(&self) -> &Sidetrack;                                  // label ""
    pub fn sidetracks(&self) -> impl Iterator<Item = &Sidetrack>;
    pub fn bores(&self) -> impl Iterator<Item = &str>;                 // bore labels, main "" first
    pub fn bore_id(&self, label: &str) -> String;      // "<id> <bore>" (id for main) — the model_inputs key
    // Multi-bore = >1 bore carries a trajectory. Then the delegating accessors
    // below need an explicit bore: enumerate bores() + work per-bore via
    // sidetrack(), or set a default. NEVER silently pick one (silent-empty bug).
    pub fn is_multibore(&self) -> bool;
    pub fn set_default_bore(&mut self, label: &str) -> Result<()>;     // Err if no such bore
    pub fn default_bore(&self) -> Option<&str>;
    pub fn clear_default_bore(&mut self);
    // Delegate to the *resolved* bore, in order: (1) the **default bore** if set;
    // (2) the **single-trajectory rule** — the sole trajectory-bearing bore when
    // exactly one bore has a trajectory (a lone deviated sidetrack positions its
    // logs/tops through it regardless of label); (3) the main bore. A multi-bore
    // well with no default resolves through the (empty) main → None; select a bore.
    pub fn xyz(&self, md: f64) -> Option<Point3>;   // z = negative-down elevation (matches Surface z; = -tvd)
    pub fn tvd(&self, md: f64) -> Option<f64>;       // positive-down TVDSS
    pub fn md_at_tvd(&self, tvd: f64) -> Option<f64>;
    pub fn top(&self, name: &str) -> Option<Interval>;
    pub fn log(&self, mnemonic: &str) -> Option<LogView>;
    pub fn logs(&self) -> impl Iterator<Item = &Log>;   // all resolved-bore logs, insertion order
    pub fn mnemonics(&self) -> Vec<&str>;
    pub fn zones(&self) -> Vec<Interval>;            // formation zones, in strat order if set, else MD
    pub fn zone_stats(&self, mnemonic: &str) -> Vec<(String, Stats)>;  // per-zone average(mean)+sum
    pub fn contacts(&self) -> impl Iterator<Item = &FluidContact>;     // non-strat picks on the resolved bore
    pub fn contact(&self, name: &str) -> Option<&FluidContact>;
    pub fn set_strat_order(&mut self, order: &[String]);  // push lithostrat column into every bore
}

pub struct Sidetrack { pub label: String /* trajectories, logs, tops private */ }
impl Sidetrack {
    pub fn add_trajectory(&mut self, input: TrajectoryInput) -> Result<&mut Trajectory>; // → active
    pub fn set_active(&mut self, index: usize) -> Result<()>;
    pub fn active(&self) -> &Trajectory;
    pub fn trajectories(&self) -> &[Trajectory];
    pub fn add_log(&mut self, log: Log);
    pub fn add_tops(&mut self, tops: Vec<Top>);
    pub fn set_strat_order(&mut self, order: &[String]);  // strat order; coincident-MD tie interval → deepest member
    // Per-bore access is first-class: everything a consumer needs to treat a bore
    // as an independent positioned "well" (used per-bore for a multi-sidetrack well).
    pub fn xyz(&self, md: f64) -> Option<Point3>;
    pub fn tvd(&self, md: f64) -> Option<f64>;
    pub fn md_at_tvd(&self, tvd: f64) -> Option<f64>;
    pub fn top(&self, name: &str) -> Option<Interval>;
    pub fn log(&self, mnemonic: &str) -> Option<LogView>;
    pub fn logs(&self) -> impl Iterator<Item = &Log>;   // all logs on this bore, insertion order
    pub fn mnemonics(&self) -> Vec<&str>;               // curve mnemonics on this bore, insertion order
    pub fn zones(&self) -> Vec<Interval>;               // strat order when set (see set_strat_order), else MD
    pub fn zone_stats(&self, mnemonic: &str) -> Vec<(String, Stats)>;
    pub fn contacts(&self) -> impl Iterator<Item = &FluidContact>;
    pub fn contact(&self, name: &str) -> Option<&FluidContact>;
}

pub struct Station { pub md: f64, pub inc_deg: f64, pub azi_deg: f64 }
pub enum TrajectoryInput {
    Xyz(Vec<Point3>),
    MdIncAzi(Vec<Station>),                                  // → minimum-curvature
    Stations(Vec<Station>),
    PositionedSurvey(Vec<(Station, Point3)>),               // explicit positions; preserves MD (e.g. .wellpath)
    Hold  { from: Station, to_md: f64 },
    Steer { from: Station, build_per_100: f64, turn_per_100: f64, to_md: f64 },
}
pub struct Trajectory { /* normalized positioned path */ }
impl Trajectory {
    pub fn from_input(input: TrajectoryInput, head: (f64, f64), kb: f64) -> Result<Trajectory>;  // standalone build (min-curvature)
    pub fn xyz(&self, md: f64) -> Option<Point3>;   // min-curvature arc interp; z = negative-down elevation (= -tvd)
    pub fn tvd(&self, md: f64) -> Option<f64>;
    pub fn md_at_tvd(&self, tvd: f64) -> Option<f64>;
    pub fn md_range(&self) -> (f64, f64);
}

pub struct Top { pub name: String, pub md: f64 }
pub struct FluidContact { pub name: String, pub md: f64 } // OWC/GOC/FWL etc.; not a zone top
pub struct Interval { pub name: String, pub top_md: f64, pub base_md: f64 } // base = next top / TD
impl Interval {
    pub fn log(&self, mnemonic: &str) -> Option<LogView>;    // log clipped to this interval
    pub fn thickness_md(&self) -> f64;
}

pub enum LogKind { Log, Core }                            // continuous log vs core-derived
pub struct Log { pub mnemonic: String, pub unit: String /* kind, md, values private */ }
impl Log {
    pub fn kind(&self) -> LogKind;
    pub fn with_kind(self, kind: LogKind) -> Log;
    pub fn history(&self) -> &[String];
}
pub struct LogView<'a> { /* a possibly interval-clipped / filtered view */ }
impl<'a> LogView<'a> {
    pub fn stats(&self) -> Stats;                            // the `well.brent.ntg` result
    pub fn stats_weighted(&self, by: &LogView) -> Stats;     // e.g. PV-weighted Sw
    pub fn filter(&self, pred: impl Fn(f64) -> bool) -> LogView<'a>;
    pub fn at_md(&self, md: f64) -> Option<f64>;
    pub fn resample(&self, step: f64) -> Log;                // onto a regular grid of spacing `step`
    pub fn resample_onto(&self, targets: &[f64]) -> Vec<f64>; // onto arbitrary ASCENDING targets (O(n+k) merge-walk; NaN out-of-span)
    pub fn values(&self) -> &[f64];
    pub fn history(&self) -> &[String];
    pub fn md(&self) -> &[f64];
}
```

## PointSet & PolygonSet (simpler)

```rust
pub struct PointSet { /* N×3 coords + named attribute columns */ }
pub enum GeometryEdge { Occupied, ConvexHull, FullRect }
impl PointSet {
    pub fn from_coords(coords: Vec<[f64; 3]>) -> PointSet;    // in-memory, no file
    pub fn z_stats(&self) -> Stats;                          // stats over the z coordinate
    pub fn load_csv(path: impl AsRef<Path>, x: &str, y: &str, z: &str) -> Result<PointSet>;
    pub fn load_geojson(path: impl AsRef<Path>) -> Result<PointSet>;
    pub fn load_irap_points(path: impl AsRef<Path>) -> Result<PointSet>;   // plain X Y Z; header-sniffed (foreign → GeoError::Format)
    pub fn load_earthvision_grid(path: impl AsRef<Path>) -> Result<PointSet>;  // EarthVision grid ASCII (null nodes dropped)
    pub fn len(&self) -> usize;
    pub fn coords(&self) -> &[[f64; 3]];                     // raw [x,y,z] points, load order (read side of from_coords)
    pub fn filter(&self, pred: impl Fn(Point3) -> bool) -> PointSet;
    pub fn attr(&self, name: &str) -> Option<&[f64]>;
    pub fn stats(&self, attr: &str) -> Option<Stats>;
    pub fn bbox(&self) -> BBox;
    pub fn nearest(&self, x: f64, y: f64) -> Option<usize>;
    pub fn infer_geometry(&self, tolerance: f64) -> Result<GridGeometry>;
    pub fn infer_geometry_with_edge(&self, tolerance: f64, edge: GeometryEdge) -> Result<(GridGeometry, PolygonSet)>;
    pub fn to_surface(&self, geom: GridGeometry, method: GridMethod) -> Result<Surface>;
    pub fn regrid_min_curvature(&self, prior: &Surface) -> Result<Surface>;  // warm-started incremental re-grid on prior's lattice
}
pub enum GridMethod { Nearest, InverseDistance, MinimumCurvature }

pub struct PolygonSet { /* rings, optional Z */ }
impl PolygonSet {
    pub fn from_rings(rings: Vec<Vec<[f64; 3]>>) -> PolygonSet;   // in-memory, no file (Z ignored)
    pub fn load_geojson(path: impl AsRef<Path>) -> Result<PolygonSet>;
    pub fn load_irap_polygons(path: impl AsRef<Path>) -> Result<PolygonSet>;
    pub fn load_shapefile(path: impl AsRef<Path>) -> Result<PolygonSet>;
    pub fn load_cps3_lines(path: impl AsRef<Path>) -> Result<PolygonSet>;  // CPS-3 polyline blocks (`->`-separated)
    pub fn contains(&self, x: f64, y: f64) -> bool;
    pub fn area(&self) -> f64;
    pub fn bbox(&self) -> BBox;
    pub fn rings(&self) -> Vec<Vec<[f64; 3]>>;              // exterior outline vertices per polygon (z=0, closed)
    pub fn clip(&self, surface: &Surface) -> Surface;       // mask outside → NaN
}
```

## `GeoData` — the manager substrate

```rust
pub struct GeoData { pub unit: Unit /* surfaces, wells, points, polygons private */ }
impl GeoData {
    pub fn new(unit: Unit) -> GeoData;
    pub fn load_surface(&mut self, name: &str, path: impl AsRef<Path>) -> Result<&Surface>; // content-first detect(); Unknown falls back to extension
    pub fn load_well(&mut self, id: &str, head: (f64,f64), kb: f64,
                     files: impl AsRef<Path>) -> Result<&Well>;  // content-first tree walk: one .wellpath→main bore (co-locates logs/tops), many→one bore each; LAS→logs; .csv→tops; crsmeta.xml→Well::crs label
    pub fn set_curve_aliases(&mut self, aliases: NameMap);  // opt-in STICKY: canonicalize log mnemonics at load (map→table→vintage strip); off by default (raw preserved)
    pub fn load_well_with(&mut self, id: &str, head: (f64,f64), kb: f64, files: impl AsRef<Path>, aliases: Option<&NameMap>) -> Result<()>;  // per-call, NON-sticky aliases (project state preserved+restored); the IngestSpec seam
    pub fn load_well_tops(&mut self, path: impl AsRef<Path>) -> Result<usize>;  // Petrel multi-well tops → matching well+bore (well-name match folds /,-,space,case → variant-tolerant); derives strat_order across the file
    pub fn strat_order(&self) -> &[String];   // global lithostrat column from the last load_well_tops
    pub fn add_strat_hint(&mut self, above: &str, below: &str);  // soft hint; fills stalemates, data wins
    pub fn strat_hint(&mut self, spec: &str) -> Result<()>;      // shorthand: "A < B" (A above) / "A > B"
    pub fn add_strat_hints(&mut self, hints: &StratHints);       // apply a declarative StratHints value (the IngestSpec seam)
    pub fn load_points(&mut self, name: &str, path: impl AsRef<Path>) -> Result<&PointSet>;     // content-first detect(); Unknown falls back to extension
    pub fn load_polygons(&mut self, name: &str, path: impl AsRef<Path>) -> Result<&PolygonSet>; // content-first detect(); Unknown falls back to extension
    pub fn surface(&self, name: &str) -> Option<&Surface>;
    pub fn well(&self, id: &str) -> Option<&Well>;
    pub fn well_mut(&mut self, id: &str) -> Option<&mut Well>;   // in-place e.g. Well::set_default_bore
    pub fn points(&self, name: &str) -> Option<&PointSet>;
    pub fn polygons(&self, name: &str) -> Option<&PolygonSet>;
    pub fn surfaces(&self) -> impl Iterator<Item = &Surface>;
    pub fn surfaces_named(&self) -> impl Iterator<Item = (&str, &Surface)>;
    pub fn polygons_named(&self) -> impl Iterator<Item = (&str, &PolygonSet)>;
    pub fn wells(&self) -> WellsView;

    /// Model-ready inputs — the consumer contract (see below). Assembles
    /// normalize→validate→interpret→characterise across the project.
    /// Surface and PolygonSet are Clone (horizons/boundary are cloned out).
    pub fn model_inputs(&self) -> Result<ModelInputs>;

    // Persistence — a single structured .pproj file (see the persistence design).
    pub fn save(&self, path: impl AsRef<Path>) -> Result<()>;          // atomic whole-project write
    pub fn open(path: impl AsRef<Path>) -> Result<GeoData>;            // materialize; model/*+unknown kinds skipped
    pub fn inspect(path: impl AsRef<Path>) -> Result<ProjectInfo>;     // manifest only (list without loading)
    pub fn split(src: impl AsRef<Path>, dst: impl AsRef<Path>, names: &[&str]) -> Result<()>; // byte-lossless
    pub fn export(src: impl AsRef<Path>, dst: impl AsRef<Path>, tags: &[&str]) -> Result<()>; // tag-filtered subset
    pub fn merge(a: impl AsRef<Path>, b: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()>;
    pub fn owner(&self) -> Option<&str>;
    pub fn set_owner(&mut self, owner: impl Into<String>);
    pub fn tags(&self) -> &[String];
    pub fn set_tags(&mut self, tags: Vec<String>);
    pub fn set_element_tags(&mut self, name: impl Into<String>, tags: Vec<String>);
    // petekSim's opaque model sidecar (bytes petekIO never parses; per-section version):
    pub fn put_model_section(&mut self, name: impl Into<String>, tags: Vec<String>, version: u32, bytes: Vec<u8>);
    pub fn model_section_names(&self) -> Vec<String>;
    pub fn model_section(&self, name: &str) -> Option<(u32, Vec<u8>)>;
}
// Per element: Surface/Well/PointSet/PolygonSet each expose `save(path)`/`load(path)`
// (a standalone one-section .pproj). Human-readable export: PointSet::export_geojson/
// export_csv, PolygonSet::export_geojson, Surface::save_irap_classic.
// ProjectInfo { owner, tags, created, modified, unit, elements: Vec<(kind, name)> }.
pub struct WellsView<'a> { /* broadcastable, filterable */ }
impl<'a> WellsView<'a> {
    pub fn filter(&self, pred: impl Fn(&Well) -> bool) -> WellsView<'a>;
    pub fn iter(&self) -> impl Iterator<Item = &Well>;
    pub fn tops(&self, name: &str) -> WellsView<'a>;         // narrow to wells with that top
}
```

## Model-ready inputs — the consumer contract

> The seam petekSim (and other consumers) build to. `GeoData::model_inputs()`
> returns inputs already **normalized → validated → interpreted → uncertainty-
> characterised**, in canonical units and provenance-flagged — consumers map them
> into their domain and derive nothing. Assembly stages live in
> `analysis::{normalize, validate, model_inputs}`; GATE-0 locks this surface.

```rust
// foundation — uncertainty & provenance vocabulary
pub enum Provenance { HardData, Interpolated, Defaulted, Assumed }
pub enum Distribution {
    Deterministic,
    Uniform { lo: f64, hi: f64 },
    Triangular { lo: f64, mode: f64, hi: f64 },
    Normal { mean: f64, std: f64 },
    LogNormal { mu: f64, sigma: f64 },
}
pub struct Uncertain { pub value: f64, pub distribution: Distribution, pub provenance: Provenance }
impl Uncertain {
    pub fn hard(value: f64) -> Uncertain;       // Deterministic + HardData (measured point datum)
    pub fn defaulted(value: f64) -> Uncertain;  // Deterministic + Defaulted
    pub fn assumed(value: f64) -> Uncertain;    // Deterministic + Assumed
    pub fn uniform(lo: f64, hi: f64) -> Uncertain;            // midpoint estimate, Interpolated
    pub fn triangular(lo: f64, mode: f64, hi: f64) -> Uncertain;  // mode estimate
    pub fn normal(mean: f64, std: f64) -> Uncertain;         // mean estimate
    pub fn lognormal(mu: f64, sigma: f64) -> Uncertain;      // exp(mu) median estimate
    pub fn from_stats(stats: &Stats, provenance: Provenance) -> Uncertain;  // Normal, or Deterministic if <2 / no spread
    pub fn with_provenance(self, provenance: Provenance) -> Uncertain;
}

// foundation::Unit — areal conversion (project unit² → m²) backing area_m2
impl Unit { pub fn area_to_m2(self, area_in_unit_sq: f64) -> f64; }

// analysis — the contract (consumed by GeoData::model_inputs)
pub struct ModelInputs { pub summary: SummaryInputs, pub spatial: SpatialInputs }

pub struct SummaryInputs {              // scalars in base-SI units, each Uncertain
    pub area_m2: Uncertain,             // reservoir/drainage area, m²
    pub net_pay_m: Uncertain,           // net pay thickness, m
    pub porosity_frac: Uncertain,
    pub water_saturation_frac: Uncertain,
    pub net_to_gross_frac: Uncertain,
    pub owc_depth_m: Option<Uncertain>, // fluid contacts, positive-down depth in m
    pub goc_depth_m: Option<Uncertain>, // (petekStatic Contact.depth_m datum; GOC shallower than OWC)
}
pub struct SpatialInputs {              // for the 3D grid build + upscaling
    pub boundary: Option<PolygonSet>,
    pub horizons: Vec<HorizonInput>,    // surface.resample(&GridGeometry)? onto the consumer lattice (Result; Err(Unsupported) if the horizon geometry is rotated)
    pub well_curves: Vec<WellCurveInput>,  // ONE positioned curve-set PER BORE: a multi-sidetrack well emits each bore separately (well_id = "<id> <bore>")
}
pub struct HorizonInput { pub name: String, pub surface: Surface, pub provenance: Provenance }
pub struct WellCurveInput {
    pub well_id: String, pub mnemonic: String,  // bore-qualified id ("99/9-1 A"; id alone for the main bore); canonical mnemonic e.g. "PHIE"
    pub md: Vec<f64>, pub values: Vec<f64>,
    pub xyz: Vec<[f64; 3]>,                      // world (x,y,z=NEGATIVE-DOWN ELEVATION, matches Surface z) per sample, 1:1 with md; [NaN;3] if unpositioned (since 0.3.0; was TVDSS)
    pub provenance: Provenance,
}

// analysis::normalize — canonicalisation passes (the first half of the pipeline)
pub fn canonical_mnemonic(raw: &str) -> String;          // vendor LAS mnemonic → canonical (PHI→PHIE, PHIE_2025→PHIE…); SW≠SWT; unknown passes through (vintage-stripped)
pub fn canonical_mnemonic_with(raw: &str, aliases: &NameMap) -> String;  // user alias map first (resolves NTG_PhieLam vs NTG_VShale), then the table
pub fn parse_length_unit(s: &str) -> Option<Unit>;        // "m"/"ft"/… → Unit
pub fn is_percent_unit(s: &str) -> bool;
pub fn harmonise_fraction(value: f64, unit: &str) -> f64; // percent → fraction
pub fn harmonise_length(value: f64, from: Unit, to: Unit) -> f64;
pub struct NameMap { /* case-insensitive alias → canonical; identity for unknowns */ }  // Serialize/Deserialize + PartialEq + Display (sorted "alias -> canonical" rows) — a value type
impl NameMap {
    pub fn new() -> NameMap;
    pub fn from_pairs(pairs: impl IntoIterator<Item = (String, String)>) -> NameMap;
    pub fn insert(&mut self, alias: impl Into<String>, canonical: impl Into<String>);
    pub fn canonical(&self, name: &str) -> String;        // identity if unmapped
    pub fn get(&self, name: &str) -> Option<String>;      // None if unmapped (no identity fallback)
    pub fn pairs(&self) -> Vec<(String, String)>;         // sorted (alias, canonical) pairs (aliases lowercased)
    pub fn is_empty(&self) -> bool;
}

// Soft lithostratigraphic ordering hints, applied at well-tops load (names resolved at apply, loud error naming a bad token). The value behind the Python IngestSpec's strat_hints.
pub struct StratHints { /* (above, below) token pairs */ }  // Serialize/Deserialize + PartialEq + Display ("A < B" rows) — a value type
impl StratHints {
    pub fn new() -> StratHints;
    pub fn from_pairs(pairs: impl IntoIterator<Item = (String, String)>) -> StratHints;
    pub fn push(&mut self, above: impl Into<String>, below: impl Into<String>);
    pub fn push_spec(&mut self, spec: &str) -> Result<()>;  // "A < B" / "A > B" shorthand
    pub fn pairs(&self) -> &[(String, String)];
    pub fn is_empty(&self) -> bool;
}

// analysis::validate — physical validity ranges; out-of-range → NaN (undefined)
pub fn validity_range(canonical_mnemonic: &str) -> Option<(f64, f64)>;  // inclusive (lo,hi), None = unranged
pub fn in_range(canonical_mnemonic: &str, value: f64) -> bool;           // NaN never in range
pub fn mask_out_of_range(canonical_mnemonic: &str, values: &mut [f64]) -> usize;  // → NaN in place, returns count rejected
// Provenance is assigned at derivation (measured→HardData, gridded→Interpolated, default→Defaulted), not here.

// analysis::interpret — petrophysics (petekIO OWNS net_pay). Pure array kernels; manager supplies per-sample TVD.
pub struct Cutoffs { pub phi_min: f64, pub sw_max: f64, pub vsh_max: f64 }  // Default: 0.08 / 0.5 / 0.5 (flag Assumed). Serialize/Deserialize + PartialEq + Display ("phi>=0.080  Sw<=0.500  Vsh<=0.500") — the core value behind the Python NetSettings spec.
pub fn net_flags(phi: &[f64], sw: &[f64], vsh: Option<&[f64]>, cut: &Cutoffs) -> Vec<bool>;  // per-sample reservoir/pay flag
pub fn net_pay(depth: &[f64], net: &[bool]) -> f64;        // Σ representative (Voronoi) thickness over net samples; depth = TVD
pub fn net_to_gross(depth: &[f64], net: &[bool]) -> f64;   // net_pay / gross span
pub fn leverett_j(pc: f64, ift: f64, perm: f64, phi: f64) -> f64;  // (Pc/ift)·√(perm/phi), consistent units; NaN if phi≤0

// analysis::characterise — fit a Distribution + Provenance from samples (petekIO characterises, consumer samples)
pub enum DistributionShape { Normal, Triangular, LogNormal }  // Triangular = P10/P50/P90; LogNormal fitted on ln of positives
pub fn characterise(values: &[f64], shape: DistributionShape, provenance: Provenance) -> Uncertain;  // <2 defined → Deterministic

// algorithms::wells — type-light well-numeric kernels (one home per formula; the bindings call in)
pub fn dz_weights(md: &[f64]) -> Vec<f64>;                // per-sample midpoint MD-span weights (thickness weighting)

// analysis::well_tables — well-derived table/bundle crunch (pure Rust; the PyO3 bindings marshal the result)
pub enum ZoneTable { Tidy { .. }, Aggregate { .. } }      // per-(zone,bore) columns, ready for a DataFrame
pub struct NetCond<'a> { pub cut: Cutoffs, pub phi: &'a str, pub sw: &'a str, pub vsh: Option<&'a str> }  // optional net-conditioning for build_zone_table (the zone_table(cut=NetSettings) seam)
pub fn build_zone_table(bores: &[(String, &Sidetrack)], curve: &str, stats: &[&str], zones: Option<&[String]>, include_empty: bool, aggregate: bool, weighted: bool, net: Option<NetCond<'_>>) -> ZoneTable;  // net=Some → pool only net samples per cell
pub fn net_zone_samples(st: &Sidetrack, value: &str, phi: &str, sw: &str, vsh: Option<&str>, cut: &Cutoffs) -> Vec<(String, Vec<f64>)>;  // per-zone NET-conditioned kept samples
pub struct RawCurve { .. } pub struct RawZone { .. } pub struct RawWellLogs { .. }  // well.view() bundle payload
pub fn gather_raw_logs(st: &Sidetrack, kb: f64, filter: Option<&[String]>) -> RawWellLogs;  // master-MD-grid resample of a bore's logs
```

## Cube (Phase 3 — locked sketch)

```rust
pub enum Domain { Time, Depth }
pub enum PropertyKind { Amplitude, AcousticImpedance, VpVs, Porosity, Other(String) }
pub enum Sampling { Nearest, Trilinear }
pub enum WindowAgg { Min, Max, Rms, Mean }

pub struct CubeGeometry {
    pub areal: GridGeometry,
    pub zori: f64, pub zinc: f64, pub nz: usize,
    pub ilines: Vec<i32>, pub xlines: Vec<i32>,
    pub domain: Domain,
}
pub struct Cube { pub geom: CubeGeometry, pub property: PropertyKind, pub unit: String }
impl Cube {
    pub fn load_segy(path: impl AsRef<Path>, iline_byte: u16, xline_byte: u16) -> Result<Cube>;
    pub fn sample(&self, x: f64, y: f64, z: f64) -> Option<f32>;
    pub fn inline(&self, label: i32) -> Option<Surface>;
    pub fn zslice(&self, z: f64) -> Option<Surface>;
    pub fn stats(&self) -> Stats;
    pub fn resample(&self, target: &CubeGeometry) -> Cube;
}
```

---

## Python (PyO3) surface — the same contract, fluent

```python
import petekio

project = petekio.Project.import_data("Data", aliases={
    "por": ["PHIE", "PORO", "PorE_BC"],   # canonical -> raw names is accepted
})
project.inventory()                       # counts + loaded names + stable skipped reasons
project.surfaces["Top reservoir"]         # named access over the underlying GeoData
project.well("15/9-A1")
project.geodata                           # the underlying GeoData substrate
project.rename_surface("Top reservoir", "structure/top agat")
project.surfaces.structure.top_agat       # folder view + unique leaf lookup
project.surfaces.all_names()              # canonical names with folders
project.delete_surface("structure/top agat")
project.save("field.pproj")               # compact .pproj write
pproj_project = petekio.Project.load("field.pproj")  # compact .pproj read

geo = petekio.GeoData(unit="ft")
geo.load_surface("top", "top.irap")       # or top.CPS3grid (CPS-3 grid)
# real-format dispatch by extension: .CPS3grid→surface, .CPS3lines→polygons,
# .EarthVisionGrid/.IrapClassicPoints→points. Also as classmethods:
# Surface.load_cps3_grid, PolygonSet.load_cps3_lines, PointSet.load_earthvision_grid
top, base = geo.surface("top"), geo.surface("base")

thick = (base - top).clamp_min(0)        # operator overloads
trend = top.attr["seismic"].ln()
top.stats.p50                            # Stats fields as attributes
top.area_below(8240)
ongrid = top.resample(grid_geom)

geo.load_well("15/9-A1", wellhead=(1200, 1500), kb=82, files="wells/A1/")
w = geo.well("15/9-A1")
w.xyz(2450)                              # interpolated position at MD

# Multi-bore wells (a Petrel export tree → one bore per .wellpath) + tops + zone stats:
geo.load_well("15/9-A1", files="wells/")  # head/kb optional — the .wellpath header fills them
geo.load_well("15/9-A1", files="wells/", ingest=petekio.IngestSpec(aliases={"PHIE_2025": "PHIE"}))  # declarative, per-call (non-sticky); then log("PHIE") resolves
geo.load_well("15/9-A1", files="wells/", aliases={"PHIE_2025": "PHIE"})  # DEPRECATED (sticky project state; DeprecationWarning) — use ingest=IngestSpec(aliases=...)
# ingest XOR aliases: passing both is a loud error.
geo.load_well_tops("WellTops.tops", ingest=petekio.IngestSpec(strat_hints=[("Base Shale", "Upper Sand")]))  # declarative order hints applied at this load
geo.strat_hint("Base Shale < Upper Sand")    # DEPRECATED (sticky) — use load_well_tops(ingest=IngestSpec(strat_hints=[...])); still: A<B = A above B, or above=/below=

# Persistence — one .pproj file (splittable/mergeable/tag-filterable):
geo.set_owner("kk"); geo.set_tags(["field-a"]); geo.set_element_tags("15/9-A1", ["field-a"])
geo.save("field.pproj")
info = petekio.GeoData.inspect("field.pproj")     # dict: owner/tags/unit/elements — no decode
geo = petekio.GeoData.open("field.pproj")
petekio.GeoData.export("field.pproj", "share.pproj", ["field-a"])   # tagged subset, one binary
petekio.GeoData.split("field.pproj", "wells.pproj", ["15/9-A1"])
petekio.GeoData.merge("a.pproj", "b.pproj", "both.pproj")
geo.put_model_section("model/seg/props", ["field-a"], 1, payload_bytes)   # petekSim's opaque sidecar
v, data = geo.model_section("model/seg/props")    # (version, bytes)
geo.load_well_tops("WellTops.tops")      # Horizon picks → well+bore; derives the strat column
geo.strat_order                          # ["Top A", "Sand A", "Top B", ...] global lithostrat column
w.crs; w.bores()                         # CRS label; e.g. ["", "A", "B", "ST2"]
w.contact("OWC"); bore.contacts()        # fluid contacts as (name, md), not formation zones
w.is_multibore                           # True → the top-level accessors below RAISE until a bore is chosen
w.xyz(2450)                              # ValueError "well '99/9-1' has 3 bores (A, B, ST2) — use .sidetrack(name) or .set_default_bore(name)"
w.set_default_bore("A"); w.default_bore  # route w.xyz/tvd/log/top through bore A ("A"); clears via load
bore = w.sidetrack("A")                  # per-bore access is first-class + complete (never needs a default):
bore.xyz(1200); bore.tvd(1200); bore.md_range()    # positioned by THIS bore's trajectory
bore.mnemonics(); bore.log_stats("PHIE").mean      # whole-bore curve + stats
bore.log("PHIE").values(); bore.log("PERM").geomean()   # per-sample view on a named bore; geometric-mean perm
lv = bore.log("PHIE"); lv.at_md_many([2400,2410,2420]); lv.values_md()  # batched: one chain-resolution for a slice / (values, md) together
bore.net_zone_stats("PHIE")                        # [(zone, Stats)] over NET samples (φ/Sw cutoff-conditioned)
bore.net_zone_stats("PHIE", cut=petekio.NetSettings(phi_min=0.10))  # a NetSettings supplies the cutoffs
bore.net_zone_stats("PHIE", cut=cut, phi_min=0.12) # scalar kwargs stay as per-call overrides ON TOP of cut
bore.net_zone_stats("PERM", geomean=True)          # [(zone, float)] net geometric-mean permeability
petekio.Stats.geomean([1.0, 4.0])                  # 2.0
ps = petekio.PointSet.from_xyz(xs, ys, zs); ps.z_stats().mean   # in-memory points + z-range
poly = petekio.PolygonSet.from_rings([[[0,0],[10,0],[10,10],[0,10]]])  # in-memory polygons
bore.zones()                             # [(name, top_md, base_md), ...] in lithostrat order
bore.zone_stats("PHIE")                  # [(name, Stats), ...] in lithostrat order
bore.zone_stats("PHIE", "Top A").mean    # one zone's Stats directly (None if absent)

# Tidy per-zone×bore table (needs pandas: pip install petekio[pandas]):
w.zone_table("PHIE", stats=("mean","p50"))            # tidy [zone, bore, mean, p50]; zone = ordered Categorical
w.zone_table("PHIE", pivot=True, decimals=3)          # wide: zone index × bore cols, rounded
w.zone_table("PHIE", aggregate=True)                  # grouped (zone,bore); pooled "all" row first per zone
w.zone_table("PHIE", stats=("mean","gross","samples")) # also: gross (zone MD thickness), samples (count)
w.zone_table("PHIE", zones=("Top A","Top B"))         # keep only these zones (case-insensitive)
# averages are thickness-weighted by default (weighted=False for plain sample mean)
w.zone_table("PHIE", cut=petekio.NetSettings(phi_min=0.10))  # net-condition each cell (phi/sw/vsh name the curves; default PHIE/SW/none — inert without cut)
geo.wells.zone_table("PHIE")                          # multi-well; bore = "<well> <sidetrack>"

# Standalone trajectory from a directional survey (no project needed):
traj = petekio.Trajectory.from_stations(      # [(md, inc_deg, azi_deg), ...]
    [(0, 0, 145), (1200, 0, 145), (1900, 57, 145)], head=(1000, 2000), kb=27.3)
traj.tvd(1655.81)                        # subsea TVD (RKB = + kb); xyz / md_at_tvd / md_range too
w.brent.ntg                             # -> Stats  (dynamic: __getattr__ top → log)
w.brent.phie.mean
geo.wells.filter(field="Gullfaks").tops("Brent")   # broadcast view

# Curve-name authority (canonical LAS mnemonic; petekio is the family namer):
petekio.canonical_mnemonic("suwi")       # -> "SW"   (case-insensitive, vintage-stripped)
petekio.detect("surface_without_extension") == petekio.FormatKind.IrapClassicGrid

# Standalone log correlation viewer — the WellLogBundle producer + a logs-only
# viewer session (kind "wells_logs", schema_version 4). Builds the bundle from a
# well's own logs + trajectory and hands it to the viewer unit (petektools.viewer,
# an OPTIONAL runtime dependency, imported lazily). Seam:
# petekSuite/dev-docs/designs/well-log-bundle-seam.md.
w.view()                                 # serve the well's logs (non-blocking); -> LogSession
w.view(tops=True)                        # include the well's tops/zones + a flatten pick
w.view(curves=("PHIE","SW"), tops=["Upper Sand"])   # select curves; a subset of tops (legacy per-call kwargs)
w.view(spec=petekio.ViewSpec(curves=("PHIE","SW"), tops=["Upper Sand"], cutoff=petekio.NetSettings(phi_min=0.10)))  # declarative WHAT
w.view(spec=petekio.ViewSpec(...), settings=petekio.ViewSettings(save="well.html"))  # WHAT + HOW; spec XOR legacy WHAT kwargs (loud on both)
w.view(save="well.html")                 # export one self-contained HTML file instead (legacy)
sess = w.view(settings=petekio.ViewSettings(serve=False)); sess.bundle()   # build only; inspect the payload dict
geo.wells.view(settings=petekio.ViewSettings(serve=False))   # multi-well logs-only session (same surface)
# legacy kwargs (spec/settings absent): curves=None, tops=None|True|[names], flatten_default=None,
#   phie_cutoff=0.08, flags=None, serve=True, save=None.  Standalone bundles carry NO ties.
petekio.build_well_log_bundle(raws, spec=petekio.ViewSpec(tops=True))   # pure-Python producer (spec= or legacy kwargs; testable)
petekio.encode_lane([0.2, None, 0.4])    # one f32 base64 lane block {dtype, shape, data}

# Spec value-objects — declarative, frozen; each: to_dict/from_dict (a "spec"-tagged
# JSON-durable dict), value equality, .replace(**overrides) derivation, domain-table repr.
petekio.NetSettings(phi_min=0.08, sw_max=0.5, vsh_max=0.5)   # φ/Sw/Vsh reservoir cutoffs (wraps core Cutoffs)
high = base.replace(phi_min=0.10)                            # a derived scenario spec
petekio.IngestSpec(aliases={"PHIE_2025":"PHIE"}, strat_hints=[("A","B"), "C < D"], unit="m")  # load-time canonicalization
petekio.ViewSpec(curves=("PHIE",), tops=True, flatten_default=None, flags=None, cutoff=0.08|NetSettings)  # WHAT view() shows
petekio.ViewSettings(serve=True, save=None)                  # HOW view() delivers
```

Python rules: `Stats` fields exposed as read-only attributes; operators (`+ - * /`)
on `Surface`; `surface.attr["name"]` indexed access; `surface.edge` and
`surface.geometry.edge` expose matching `PolygonSet` outlines; `PointSet`
exposes `infer_geometry(tolerance=1e-3, edge="occupied"|"convex_hull"|"full_rect")`;
`well.<top>.<log>` resolves via `__getattr__` (top interval → log → `Stats`).
Bindings are thin: every method delegates to the Rust API above. The spec
value-objects (`NetSettings`, `IngestSpec`, `ViewSpec`, `ViewSettings`) follow
the family house spec pattern (declarative WHAT / HOW, applied at explicit
moments; spec XOR legacy kwargs).
