petektools 0.1.0

Standalone numerics & geostatistics kernels for Rust: scattered-data gridding (minimum-curvature, IDW, nearest) and a curated numeric front-door. Pure leaf; PyO3 bindings planned.
Documentation
# petekTools — locked public API (GATE 0)

> **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. See `SPEC.md` for the
> design constitution.

Conventions: `Result<T> = std::result::Result<T, AlgoError>`; grids are
`ndarray::Array2<f64>` shaped `(ncol, nrow)`; undefined node = `NaN`. Kernels are
**type-agnostic** — they speak `Lattice` + `[[f64; 3]]`, never a consumer type.

---

## foundation

```rust
pub type Result<T> = std::result::Result<T, AlgoError>;

#[derive(Debug, thiserror::Error)]
pub enum AlgoError {
    EmptyInput(&'static str),       // a kernel got no data
    InvalidGeometry(&'static str),  // degenerate lattice (e.g. zero spacing)
    Io(#[from] std::io::Error),     // container: file open/read/write
    Parse(String),                  // container: corrupt / unparsable bytes
    NotFound(String),               // container: no such section
}

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

/// Regular, rotatable areal lattice (IRAP/RMS model). Field- and behaviour-
/// identical to petekio's `GridGeometry` so adoption is a 1:1 map.
pub struct Lattice {
    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 Lattice {
    pub fn regular(xori: f64, yori: f64, xinc: f64, yinc: f64,
                   ncol: usize, nrow: usize) -> Lattice;  // unrotated, unflipped
    pub fn yflip_factor(&self) -> f64;
    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; None if degenerate
    pub fn bbox(&self) -> BBox;
}
```

## gridding

```rust
pub enum GridMethod { Nearest, InverseDistance, MinimumCurvature }

/// Interpolate scattered `[x, y, z]` rows onto `lattice`. Returns the
/// `(ncol × nrow)` node array (NaN where undefined). Errs only on empty input.
pub fn grid(coords: &[[f64; 3]], lattice: &Lattice, method: GridMethod)
    -> Result<ndarray::Array2<f64>>;
```

The three methods mirror petekio's so its `PointSet::to_surface(geom, method)`
can later delegate here by mapping `GridGeometry → Lattice`.

## units

Domain-agnostic oilfield-unit conversion constants and helpers (moved from
petekSim's `srs-units`). Pure `f64` arithmetic — no I/O, no domain types, no
error surface. Namespaced under `units` (not re-exported at the crate root).

```rust
pub const ACRE_TO_FT2: f64;   // 43_560.0 (square feet per acre)
pub const FT3_PER_BBL: f64;   // 5.614_583_333_333_333 (cubic feet per reservoir bbl)

pub fn acres_to_ft2(acres: f64) -> f64;      // area:   acres    -> ft^2
pub fn acre_ft_to_ft3(acre_ft: f64) -> f64;  // volume: acre-ft  -> ft^3
pub fn ft3_to_acre_ft(ft3: f64) -> f64;      // volume: ft^3     -> acre-ft
pub fn ft3_to_rb(ft3: f64) -> f64;           // volume: ft^3     -> reservoir bbl
pub fn rb_to_ft3(bbl: f64) -> f64;           // volume: bbl      -> ft^3
pub fn degf_to_degr(degf: f64) -> f64;       // temp:   °F       -> °R
```

## container

A **domain-agnostic** single-file section container: file magic + a JSON header
+ per-section `zstd`-compressed opaque `payload` blobs. Round-trips tagged,
versioned, kinded byte blobs with partial reads and byte-lossless
`filter_to` / `merge_to` (compressed blobs copied verbatim — never re-encoded).
Knows nothing about any caller domain. Lifted from petekio's `.pproj` framing;
the **on-disk format is unchanged** (magic `PIO\x01`). petekio layers its GeoData
element DTOs on top; an opaque `model/*` sidecar rides through untouched.
Namespaced under `container` (not re-exported at the crate root).

```rust
pub struct Section {                 // caller's view: metadata + UNcompressed payload
    pub kind: String, pub name: String, pub tags: Vec<String>,
    pub version: u32, pub payload: Vec<u8>,
}
pub struct Entry {                   // one header-index row (metadata only, no payload)
    pub kind: String, pub name: String, pub tags: Vec<String>,
    pub version: u32, pub offset: u64, pub size: u64,
}
pub struct Reader { /* private: file handle + parsed header */ }

/// Write a container, compressing each section payload.
pub fn write(path: &Path, app: &serde_json::Value, data_version: u32,
             sections: &[Section]) -> Result<()>;
/// Open a container (header only; blobs pulled on demand).
pub fn open(path: &Path) -> Result<Reader>;
impl Reader {
    pub fn data_version(&self) -> u32;
    pub fn app(&self) -> &serde_json::Value;
    pub fn entries(&self) -> &[Entry];      // list without reading a blob
    pub fn read(&mut self, name: &str) -> Result<Section>;  // decompress one section
}
/// Copy `src` → `dst` keeping sections whose `Entry` passes `keep` (byte-lossless).
pub fn filter_to(src: &Path, dst: &Path, keep: impl Fn(&Entry) -> bool) -> Result<()>;
/// Merge `a` + `b` → `dst` (on kind+name clash `b` wins); blobs copied verbatim.
pub fn merge_to(a: &Path, b: &Path, dst: &Path) -> Result<()>;
```

---

## gridding — warm-start (LOCKED; signed off by petekio + petekSim 2026-06-29)

> Requested firm by both consumers (petekio L1, petekSim L2) and **signed off by
> both** on 2026-06-29 — now part of the locked surface. **Additive and
> non-breaking**`grid()` is unchanged. See
> `dev-docs/designs/warm-start-gridding.md`.

```rust
// L1 — seeded minimum-curvature primitive (parity with petekio's
// `grid_min_curvature(.., seed)`). Relax the SOR from `seed` (lattice-shaped)
// instead of the cold IDW seed; `None`/wrong-shape → current cold behaviour.
// Re-solves the WHOLE field from the seed (no region restriction) — this is
// what guarantees warm == cold to tolerance + determinism.
pub fn grid_min_curvature_seeded(
    coords: &[[f64; 3]], lattice: &Lattice, seed: Option<&ndarray::Array2<f64>>,
) -> Result<ndarray::Array2<f64>>;

// L2 — stateful convergent gridder for interactive/iterative re-gridding
// (petekSim's refinement loop). Holds the lattice, the current solved field,
// and the node-indexed control set; each add warm-starts from the held field
// via L1. NB: re-solves the whole field warm (fast: far fewer iters), NOT a
// region-restricted solve — diverges from the "affected region only" wording
// to preserve the warm == cold continuity guarantee.
pub struct ConvergentGridder { /* private: Lattice + Array2<f64> + controls */ }
impl ConvergentGridder {
    pub fn new(coords: &[[f64; 3]], lattice: &Lattice) -> Result<ConvergentGridder>;
    pub fn add_control(&mut self, ip: usize, jp: usize, z: f64) -> &ndarray::Array2<f64>;
    pub fn add_controls(&mut self, controls: &[(usize, usize, f64)]) -> &ndarray::Array2<f64>;
    pub fn field(&self) -> &ndarray::Array2<f64>;
}
```

## Roadmap (NOT yet locked — sketches for direction; see `SPEC.md`)

```rust
// Pluggable backends via a trait, once kriging/RBF land:
//   pub trait Gridder { fn grid(&self, coords: &[[f64;3]], lattice: &Lattice) -> Result<Array2<f64>>; }

// Curated front-door modules (re-exporting / thinly wrapping mature crates):
//   stats     — weighted stats, percentiles (over statrs)
//   sampling  — distribution sampling (over rand_distr)
```