# petekStatic — public API
> **Status: WORKING CONTRACT.** The geomodel crates are green and the headline
> **`StaticModel`** surface (`srs-model`) has **LANDED** per the ratified seams
> (SPEC §7/§8: `decision_layer_charters`, `decision_staticmodel_regen_seam`).
> This file is the contract: changing a cross-library signature (the StaticModel
> accessors, `RealizationDraw`, the regeneration API) requires coordinator +
> consumer sign-off; library-internal signatures are petekStatic's own call. The
> file locks fully at the 0.1 release. Rust is canonical. Python exposes both
> the compact flat-model surface and the first petekStatic-owned static workflow
> facade (`Grid.from_project(...).geometry(...).horizons(...).zones(...).layers(...)`).
> The workflow facade is currently a declarative/spec layer with in-memory
> property arrays, petekTools formula evaluation, deterministic simple
> volumetrics, and lowering of `upscale(...).sgs(...)` property declarations to
> `PropertyPipelineSpec`; it is not yet the full Rust corner-point grid
> construction path.
Conventions (house style): `Result<T> = std::result::Result<T, StaticError>`;
per-cell cubes are `Vec<f64>` indexed by linear cell index; `NaN` = undefined;
depths are metres, positive-down (petekIO's negative-down elevation is flipped at
the `srs-data` ingest boundary).
---
## Python workflow facade — first vertical slice
```python
import petekstatic as pst
grid = (
pst.Grid.from_project(project)
.geometry(cell=(50.0, 50.0), orient=0.0, outline="ModelEdge")
.horizons(
[
{
"name": "Top reservoir",
"surface": "Top reservoir input surface",
"well top": "well tops/Top reservoir",
"zone": {
"name": "Reservoir",
"sub-zones": [
{"zone": "Top Reservoir", "type": "constant"},
{"name": "Intra Shale", "well top": "Top Lower Reservoir"},
{"name": "Lower Reservoir", "type": "isochore"},
],
},
},
"Base reservoir",
{"name": "Custom model horizon name", "surface": "input surface"},
],
well_tie={"influence_radius": 800},
)
.layers({"Top Reservoir": pst.Layering(n=2), "Lower Reservoir": pst.Layering(n=2)})
)
p = grid.properties
p.ntg = 0.80
p.por = p.ntg * 0.85
p.sw = 0.20
p["PermXY_BC"].set(100.0)
p["PorE_BC"].set(0.25)
p.calc(
["RQI = $lambda * sqrt(PermXY_BC / PorE_BC)"],
params={"lambda": 0.0314},
)
case = grid.volumes(ntg="NTG", por="POR", sw="Sw", fluid="oil", fvf=1.30)
result = case.run(progress=True)
```
Exported Python names:
```python
Grid, HorizonSpec, WellTie, Layering, Spherical,
PropertyStore, PropertyHandle, PropertyPipelineSpec,
Var, WellLogSpec, DistributionSpec, CoKriging,
UpscaleRecipeBuilder, SgsRecipe, distributions, upscale,
WellLog, PropertyPipeline, VolumeCase, VolumeResult,
StaticModel, build_flat_model, __version__
```
Current behavior:
- `Grid.from_project(project)` accepts the petekIO `Project` facade or a
project-like fixture.
- `geometry(..., outline=...)` and `horizons([...])` validate names against
`Project.inventory()` and mapping-like project collections. Missing assets
raise `ValueError` with available names.
- `grid.properties` stores dense in-memory property vectors. Handles are
available as `p["NAME"]` and convenience attributes like `p.ntg`, `p.por`,
`p.sw`. Constants can be written as scalars (`p.ntg = 0.8` or
`p.ntg.set(0.8)`) and are broadcast to the declared cell count; pass an
iterable when assigning explicit per-cell values. Property handles also
compose into assignment expressions (`p.por = p.ntg * 0.85`), routed through
the same formula evaluator as `p.calc(...)`.
- `p.calc([...], params={...})` delegates the whole formula block to
`petektools.evaluate_formula` and writes outputs only after the block succeeds.
- `pst.Var(model, major, minor, vertical, azimuth, sill=None, nugget=None)`
records the canonical anisotropic variogram spec. `pst.Spherical(range_m)` is
accepted as isotropic shorthand and converts to `Var`.
- `pst.distributions.from_logs()` records that the SGS target distribution is
estimated from the resolved log samples. It is also the default for log-channel
sources when no distribution is supplied.
- Assigning an `pst.upscale(source).sgs(...)` recipe, for example
`p.por = pst.upscale(logs.PHIE(logs.NetSand > 0.50)).sgs(...)`, records the
declaration and lowers it to `PropertyPipelineSpec`. Inspect declarations with
`p.declarations("por")`, inspect lowered specs with `p.pipelines("por")`, and
access the object with `p.pipeline_spec("por")`.
- `grid.volumes(...).run(progress=True|callback)` computes deterministic simple
GRV/HCPV/in-place volumes from the declared cell size, horizon thickness, layer
count, and named `ntg`/`por`/`sw` arrays. `VolumeResult.summary()` and
`VolumeResult.by_zone()` return dictionaries.
Property recipe example:
```python
logs = project.logs
vgm = pst.Var(
"spherical",
major=1500,
minor=700,
vertical=20,
azimuth=35,
sill=1.2,
nugget=0.05,
)
p.por = pst.upscale(logs.PHIE(logs.NetSand > 0.50)).sgs(
variogram=vgm,
distribution=pst.distributions.from_logs(),
seed=12,
)
lowered = p.pipelines("por")
```
Executable recipe example, when `logs.NetSand` or the project resolver returns
positioned well logs:
```python
iso = pst.Var("spherical", major=1500, minor=1500, vertical=1500, azimuth=0)
p.ntg = pst.upscale(logs.NetSand).sgs(
variogram=iso,
distribution=pst.distributions.from_logs(),
seed=11,
)
pipe = p.execute_pipeline("ntg")
config = pipe.config()
smoke_model = pipe.apply_to_flat_model()
```
Execution boundary:
- If `source.to_well_logs(project)`, `source.resolve_well_logs(project)`,
`project.resolve_log_expression(source)`, `project.resolve_well_logs(source)`,
or `project.resolve_log_source(source_dict)` returns positioned logs, the
lowered spec stores `WellLogSpec` inputs.
- `p.execute_pipeline("por")` returns a Rust-backed `pst.PropertyPipeline` handle
only when positioned wells are available, no cokriging/trend is bound,
`distribution=pst.distributions.from_logs()`, and the variogram is isotropic
for current Rust execution (`major == minor == vertical`, `azimuth == 0`).
- `PropertyPipeline.apply_to_flat_model(...)` is the current smoke execution
path. Applying a pipeline to an arbitrary mutable production grid is not
exposed through Python yet.
- Lazy unresolved log expressions, cokriging/trend binding, non-`from_logs`
distributions, and anisotropic Rust execution raise explicit
`NotImplementedError`s. Anisotropic `Var` specs are still serialized intact and
`PropertyPipelineSpec.to_petektools_variogram()` lowers them to the petekTools
anisotropic variogram object when petekTools is installed.
---
## `error`
```rust
pub enum StaticError {
InvalidInput(String),
Grid(String),
OutOfRange(String),
CrossedSurfaces { nodes: usize, worst_m: f64 }, // R1: base crosses above the top (GRV-collapsing); opt out via with_clamp_base_to_top
Geo(#[from] petekio::GeoError), // DATA→GEOMODEL seam: `?` chains through
}
pub type Result<T> = std::result::Result<T, StaticError>;
```
Downstream composes it: `SrsError::Static(#[from] petekstatic::error::StaticError)`
(which transitively reaches `GeoError` too).
## `srs-grid` — the i,j,k corner-point grid
```rust
pub struct Grid { /* geom + properties + layers, all private */ }
impl Grid {
pub fn new(geom: CornerPointGeom) -> Grid;
pub fn dims(&self) -> Dims;
pub fn cell_count(&self) -> usize;
pub fn cell(&self, c: Ijk) -> Cell;
pub fn cells(&self) -> impl Iterator<Item = Cell> + '_;
pub fn bulk_volume(&self) -> f64; // gross rock volume, m³
pub fn properties(&self) -> &Properties;
pub fn properties_mut(&mut self) -> &mut Properties;
pub fn layers(&self) -> &[KLayer];
}
pub struct Cell { pub ijk: Ijk, pub corners: [Point3; 8] }
impl Cell {
pub fn volume(&self) -> f64;
pub fn centroid(&self) -> Point3;
pub fn top_depth(&self) -> f64; // mean of 4 top corners
pub fn bottom_depth(&self) -> f64; // mean of 4 bottom corners
pub fn dz(&self) -> f64;
}
pub struct Property { pub name: String, pub values: Vec<f64> }
impl Property { pub fn constant(name: impl Into<String>, value: f64, cell_count: usize) -> Property; }
pub struct Properties { /* private */ }
impl Properties {
pub fn new(cell_count: usize) -> Properties;
pub fn set(&mut self, prop: Property) -> Result<()>; // errors if len != cell_count
pub fn get(&self, name: &str) -> Option<&Property>;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
pub fn names(&self) -> impl Iterator<Item = &str>; // unordered
}
pub struct Dims { /* ni, nj, nk */ } pub struct Ijk { /* i, j, k */ }
pub struct CornerPointGeom { /* pillars + node z's */ } pub struct Pillar { /* ... */ }
pub struct KLayer { /* ... */ } pub struct Point3 { pub x: f64, pub y: f64, pub z: f64 }
pub struct Segment { /* ... */ }
pub fn build_box(spec: BoxSpec) -> Result<Grid>; pub struct BoxSpec { /* ... */ }
pub fn hexahedron_volume(corners: &[Point3; 8]) -> f64;
```
## `srs-wireframe` — the constraining framework
```rust
pub enum Hardness { Hard, Interpolated, Assumed }
pub enum HorizonRole { Top, Base, Intermediate }
pub enum ContactKind { Owc, Goc, Gwc }
pub struct Boundary { pub ring: Vec<[f64; 2]>, pub hardness: Hardness }
pub struct GriddedDepth { pub ncol: usize, pub nrow: usize, pub depth_m: Vec<f64>, pub is_control: Vec<bool> }
pub struct Horizon { pub name: String, pub role: HorizonRole, pub surface: GriddedDepth }
pub struct Contact { pub kind: ContactKind, pub depth_m: f64, pub hardness: Hardness }
pub struct Wireframe { pub boundary: Boundary, pub horizons: Arc<Vec<Horizon>>, pub contacts: Vec<Contact> } // horizons Arc-shared: realization-invariant, O(1) clone per MC realize (mutate via Arc::make_mut)
impl Wireframe { pub fn from_boundary(boundary: Boundary) -> Wireframe; }
```
## `srs-gridder` — the convergent gridder
```rust
pub struct Control { pub ip: usize, pub jp: usize, pub z: f64 }
pub struct SolveOpts { pub tension: f64, pub omega: f64, pub tol: f64, pub max_iter: usize }
impl Default for SolveOpts { /* tension .25, omega 1.5, tol 1e-6, max_iter 20_000 */ }
pub struct Surface { /* nx, ny, z — private */ }
impl Surface {
pub fn constant(nx: usize, ny: usize, z: f64) -> Surface;
pub fn offset_by(&self, dz: f64) -> Surface; // conformable base at constant thickness
pub fn offset_by_field(&self, dz_m: &[f64]) -> Result<Surface>; // per-node offset (row-major jp*nx+ip) — real base relief
pub fn nx(&self) -> usize; pub fn ny(&self) -> usize; pub fn z(&self, ip: usize, jp: usize) -> f64;
// Order-repair family (SPEC §3 R1/R-c + §4a precedence). `guard_below` /
// `repair_min_thickness` move the LOWER (base yields, top preserved); the
// repair-precedence twins `guard_above` / `repair_min_thickness_from_below`
// move the UPPER up (a derived surface yields to a mapped one).
pub fn guard_below(&self, top: &Surface, clamp: bool) -> Result<Surface>;
pub fn repair_min_thickness(&self, top: &Surface, min_thickness_m: f64) -> Result<(Surface, usize, f64)>;
pub fn guard_above(&self, lower: &Surface, clamp: bool) -> Result<Surface>;
pub fn repair_min_thickness_from_below(&self, lower: &Surface, min_thickness_m: f64) -> Result<(Surface, usize, f64)>;
}
pub fn solve_surface(nx: usize, ny: usize, controls: &[Control], opts: SolveOpts) -> Result<Surface>;
// Extrapolation policy beyond the data hull (SPEC §3, audit S3): explicit,
// owner-visible; default DecayToData { start_cells: 2, decay_cells: 4 }.
pub enum ExtrapolationPolicy { NaturalDip, DecayToData { start_cells: f64, decay_cells: f64 } }
impl Default for ExtrapolationPolicy { /* DecayToData { 2.0, 4.0 } */ }
impl Surface {
pub fn taper_beyond_data(&self, controls: &[Control], policy: ExtrapolationPolicy) -> Surface;
}
// The structure-build solve entry (SPEC §3, audit S2): plane detrending (kills
// the kernel's slow affine mode) + fixed-point restarts; hard controls exact.
pub fn solve_surface_converged(nx: usize, ny: usize, controls: &[Control]) -> Result<KernelSurface>;
// The petekTools-kernel-space surface — the ONLY admissible warm-start seed.
// Sources: `flat` (constant field — a fixed point of both kernels, the safe
// bootstrap) or a prior `solve_surface_seeded` output. Deliberately NO
// `From<Surface>`: seeding the warm kernel from the cold solver's output is the
// kernel-space violation (`decision_gridder_kernel_unification`) and is now a
// compile error.
pub struct KernelSurface { /* private Surface */ }
impl KernelSurface {
pub fn flat(nx: usize, ny: usize, z: f64) -> KernelSurface;
pub fn surface(&self) -> &Surface;
pub fn nx(&self) -> usize; pub fn ny(&self) -> usize; pub fn z(&self, ip: usize, jp: usize) -> f64;
}
// Warm-start refine (SPEC §7a): delegates the seeded SOR to petekTools'
// ConvergentGridder kernel; ~14x faster than a cold solve for a one-control
// perturbation at 50x50. No SolveOpts — petekTools owns its solver parameters.
pub fn solve_surface_seeded(seed: &KernelSurface, controls: &[Control]) -> Result<KernelSurface>;
// Layering conformity. Proportional honours `nk`; the Follow styles derive `nk`
// from geometry (ceil(max column thickness / dz_m), capped at MAX_NK) and drape
// each layer surface parallel to the top (resp. base) at a constant dz_m, with
// the deep (resp. shallow) layers TRUNCATING against the pinch-out horizon.
// A truncated cell collapses to zero thickness (zero volume — excluded from
// volumetrics, NaN-marked in the view bundles); there is no active-mask array.
pub const MAX_NK: usize = 200;
pub enum Conformity { Proportional, FollowTop { dz_m: f64 }, FollowBase { dz_m: f64 } }
// The layered grid + its report. `grid.dims().nk == nk`. `nk` is dz-derived for
// a Follow style, the passed `nk` for Proportional. `truncated_cells` counts the
// zero-thickness collapses; `nk_capped` = the derived count hit MAX_NK.
pub struct LayeredGrid { pub grid: Grid, pub nk: usize, pub truncated_cells: usize, pub nk_capped: bool }
// `nk` honoured for Proportional, ignored (dz-derived) for the Follow styles.
pub fn layer_grid(top: &Surface, base: &Surface, dx: f64, dy: f64, nk: usize, conf: Conformity) -> Result<LayeredGrid>;
```
## `srs-petro` — log upscaling
```rust
pub struct WeightedSample { pub weight: f64, pub value: f64 }
pub fn arithmetic_mean(s: &[WeightedSample]) -> f64;
pub fn geometric_mean(s: &[WeightedSample]) -> f64;
pub fn harmonic_mean(s: &[WeightedSample]) -> f64;
pub fn power_law_mean(s: &[WeightedSample], p: f64) -> f64;
pub struct NetSample { /* ... */ } pub struct SwSample { pub length: f64, pub porosity: f64, pub water_saturation: f64 }
pub fn upscale_porosity(samples: &[WeightedSample]) -> Result<f64>; // length-weighted
pub fn upscale_sw(samples: &[SwSample]) -> Result<f64>; // pore-volume-weighted
pub fn net_to_gross(samples: &[NetSample]) -> f64;
pub fn perm_bounds(/* ... */) -> (f64, f64); // Cardwell-Parsons bracket
```
## `srs-data` — the thin petekIO adapter
```rust
pub mod adapter; // hardness_of, InputScalar, ModelScalars::from_summary
pub mod logs; // petro_samples(&[WellCurveInput]) -> Vec<(f64,f64,f64)> (tvd, φ, Sw)
pub mod wireframe; // assemble_wireframe(&ModelInputs) -> Result<Wireframe>
pub mod petekio; // re-export of the real petekio crate (the upstream seam)
```
## `srs-volumetrics` — GRV, in-place, FVF (relocated from petekSim 2026-07-03)
```rust
pub const PORO: &str; pub const NTG: &str; pub const SW: &str; // canonical cube names
pub struct ConstantPriors { pub porosity: f64, pub net_to_gross: f64, pub water_saturation: f64 }
pub fn populate_constant(grid: &mut Grid, priors: ConstantPriors) -> Result<()>;
pub struct ZoneVolumes { pub grv_m3: f64, pub hcpv_m3: f64, pub cells: usize } // one HC zone
pub struct InPlace {
pub grv_m3: f64, pub hcpv_m3: f64, // whole HC column (gas+oil)
pub cells_in_column: usize, pub per_cell_hcpv: Vec<f64>,
pub gas: Option<ZoneVolumes>, // Some only for a two-contact column
pub oil: Option<ZoneVolumes>,
}
impl InPlace { // volumes m³ internal; SI reporting scales
pub fn grv_mcm(&self) -> f64; // GRV in mcm (1e6 m³)
pub fn ooip_sm3(&self, boi: OilFvf) -> f64; // whole column, Sm³
pub fn oil_msm3(&self, boi: OilFvf) -> f64; // whole column, MSm³ (oil report scale)
pub fn ogip_sm3(&self, bgi: GasFvf) -> f64; // whole column, Sm³
pub fn gas_bcm(&self, bgi: GasFvf) -> f64; // whole column, bcm (1e9 Sm³, gas report scale)
pub fn gas_zone_ogip_sm3(&self, bgi: GasFvf) -> f64; // gas cap only, Sm³ (0 if none)
pub fn oil_zone_ooip_sm3(&self, boi: OilFvf) -> f64; // oil leg only, Sm³ (0 if none)
}
pub fn compute_in_place(grid: &Grid, contact_depth_m: f64) -> Result<InPlace>; // single contact
pub fn compute_in_place_summary(grid: &Grid, contact_depth_m: f64) -> Result<InPlace>; // V7: same aggregates, no per-cell HCPV cube
// Two-contact (gas cap + oil rim): partitions cells gas (z<GOC) / oil
// (GOC<=z<OWC) / water. Geometry + per-zone in-place ONLY — no PVT (solution
// gas / gas-cap expansion stay in petekSim). Err if GOC deeper than OWC.
pub fn compute_in_place_two_contact(grid: &Grid, goc_m: f64, owc_m: f64, sw_gas: Option<f64>) -> Result<InPlace>; // sw_gas: gas-cap connate-water override (R3)
pub fn compute_in_place_two_contact_summary(grid: &Grid, goc_m: f64, owc_m: f64, sw_gas: Option<f64>) -> Result<InPlace>; // V7 summary
// FVF value types — FVF crosses the layer seam as a validated scalar INPUT
// (no PVT code); petekSim's srs-pvt keeps its own copies for the dynamic work.
// Boi/Bgi are dimensionless Rm³/Sm³ (== legacy rb/STB, rcf/scf numerically — a relabel).
pub struct OilFvf; impl OilFvf { pub fn new(rm3_per_sm3: f64) -> Result<OilFvf>; pub fn value(self) -> f64; } // finite, >= 1
pub struct GasFvf; impl GasFvf { pub fn new(rm3_per_sm3: f64) -> Result<GasFvf>; pub fn value(self) -> f64; } // finite, in (0,1)
// Physical-range predicates (H2): typed errors, never silent garbage.
pub fn validate_fraction(what: &str, x: f64) -> Result<()>; // finite, in [0,1]
pub fn validate_positive(what: &str, x: f64) -> Result<()>; // finite, > 0
```
## `srs-uncertainty` — the Monte Carlo toolkit (relocated from petekSim 2026-07-03)
```rust
pub enum Distribution {
Constant(f64),
Uniform { min: f64, max: f64 },
Triangular { min: f64, mode: f64, max: f64 },
Normal { mean: f64, sd: f64 },
Lognormal { mu: f64, sigma: f64 },
}
impl Distribution {
pub fn uniform(min: f64, max: f64) -> Result<Distribution>; // validated ctors
pub fn triangular(min: f64, mode: f64, max: f64) -> Result<Distribution>;
pub fn normal(mean: f64, sd: f64) -> Result<Distribution>;
pub fn lognormal(mu: f64, sigma: f64) -> Result<Distribution>;
pub fn quantile(self, u: f64) -> f64; // inverse CDF
pub fn sample(self, rng: &mut SplitMix64) -> f64;
}
pub struct SplitMix64; impl SplitMix64 { pub fn new(seed: u64) -> SplitMix64; pub fn next_u64(&mut self) -> u64; pub fn next_f64(&mut self) -> f64; }
pub fn inverse_normal_cdf(p: f64) -> f64; // Acklam probit
pub struct Realizations { pub values: Vec<f64> }
impl Realizations { pub fn summary(&self) -> Result<PercentileSummary>; } // Err on empty (H1)
pub fn run<F: FnMut(&mut SplitMix64) -> f64>(n: usize, seed: u64, trial: F) -> Realizations;
pub struct PercentileSummary { pub p90: f64, pub p50: f64, pub p10: f64, pub mean: f64 }
impl PercentileSummary {
pub fn from_realizations(values: &[f64]) -> Result<PercentileSummary>; // Err on empty (H1)
pub fn swanson_mean(&self) -> f64; // delegates the P90/P50/P10 digest + type-7 percentile to petektools::sampling::reservoir_summary
}
```
## `srs-model` — the `StaticModel` + the regeneration seam (LANDED 2026-07-03)
The ratified headline contract (`decision_staticmodel_regen_seam` + amendments;
`decision_layer_charters`). Re-exports the volumetrics/P-curve output surface:
`ConstantPriors`, `InPlace`, `ZoneVolumes`, `OilFvf`, `GasFvf`,
`PercentileSummary`.
### The aggregate
```rust
/// A populated static reservoir model. Owned, Clone, Send (compile-checked);
/// construction guarantees the invariants (SPEC §2) — consumers never re-validate.
pub struct StaticModel { /* framework, grid, zones, provenance — all private */ }
impl StaticModel {
// --- read-only accessors (SPEC §7c) ---
pub fn grid(&self) -> &Grid;
pub fn framework(&self) -> &Wireframe;
pub fn contacts(&self) -> &[Contact];
pub fn property(&self, name: &str) -> Option<&Property>;
pub fn property_names(&self) -> Vec<&str>;
pub fn zones(&self) -> &ZoneTable;
pub fn provenance(&self) -> &Provenance;
pub fn georef(&self) -> Option<Georef>; // registered WORLD frame (None = local degenerate: synthetic square/box)
pub fn bulk_volume(&self) -> f64; // pure geometry (whole grid)
// --- the volumetrics output surface (the model owns volumes) ---
// Clipped vs the model's contact(s). A GOC + lower (OWC/FWL/GWC) contact
// auto-returns the gas-cap + oil-rim split (InPlace::gas / ::oil); a lone
// contact stays a generic column.
pub fn in_place(&self) -> Result<InPlace>;
pub fn in_place_summary(&self) -> Result<InPlace>; // V7: same aggregates, per_cell_hcpv empty (MC hot path)
// --- multi-zone surface (SPEC §4a; from_horizon_stack) ---
pub fn in_place_by_zone(&self) -> Result<ZonedInPlace>; // per-zone in-place vs EACH zone's contacts + rollup total (sum == total)
pub fn zone_stats(&self, property: &str) -> Result<Vec<ZoneStat>>; // per-zone count/mean/min/max over active cells
}
pub struct ZoneInPlace { pub zone: String, pub in_place: InPlace }
pub struct ZonedInPlace { pub zones: Vec<ZoneInPlace>, pub total: InPlace } // total = summary rollup (per_cell_hcpv empty)
pub struct ZoneStat { pub zone: String, pub count: usize, pub mean: f64, pub min: f64, pub max: f64 } // NaN aggregates when count==0
```
### Zones (SPEC §4)
```rust
pub struct Zone { pub name: String, pub color: Option<String>, pub top_horizon: String, pub base_horizon: String, pub k_range: std::ops::Range<usize> } // color: viewer hint from StackZone.color
pub struct ZoneTable { /* ordered top→base; k-ranges partition [0, nk) */ }
impl ZoneTable {
pub fn single(nk: usize) -> ZoneTable; // whole-column degenerate zone
pub fn from_stack(horizon_names: &[String], zone_names: &[String], zone_colors: &[Option<String>], per_zone_nk: &[usize]) -> ZoneTable; // real multi-zone
pub fn zones(&self) -> &[Zone];
pub fn get(&self, name: &str) -> Option<&Zone>;
}
```
### Multi-zone horizon stack (SPEC §4a; `task_petekstatic_multizone`)
```rust
pub struct Pick { pub ip: usize, pub jp: usize, pub depth_m: f64 } // a well top on the node lattice
pub struct WorldPoint { pub x: f64, pub y: f64, pub depth_m: f64 } // a raw scatter obs in WORLD coords (positive-down depth)
pub enum HorizonSource {
Scatter(Vec<WorldPoint>), // RAW world-coord scatter — the engine grids it (bilinear-conditioned, voids left NaN); only via from_scatter_stack
Mapped(GriddedDepth), // a mapped surface ALREADY on the lattice — bypasses the engine's solve/conditioning fidelity (loaded grids only; raw points belong in Scatter)
TopsOnly(Vec<Pick>), // no surface — draped conformally from the nearest mapped horizon above at pick-controlled thickness
}
pub struct StackHorizon { pub name: String, pub source: HorizonSource }
pub struct StackZone { pub name: String, pub color: Option<String>, pub conformity: Conformity, pub nk: usize, pub contacts: Vec<Contact> } // name+optional colour (folds the old zone_names) + per-zone layering + its OWN contacts (empty = contactless)
impl StackZone {
pub fn new(name: impl Into<String>, conformity: Conformity, nk: usize, contacts: Vec<Contact>) -> StackZone; // colour = None
pub fn with_color(self, color: impl Into<String>) -> StackZone; // viewer hint -> section bundle zones[{name,color}]
}
pub struct HorizonStack { // N horizons top→down -> N-1 zones
pub horizons: Vec<StackHorizon>, // first (top) must be Mapped (or Scatter, via from_scatter_stack)
pub zone_layers: Vec<StackZone>, // N-1 (each carries its own name/colour/layering/contacts)
}
// The areal lattice + world frame a Scatter stack conditions onto (from_scatter_stack).
pub struct StackFrame { pub ni: usize, pub nj: usize, pub georef: Georef } // ni = √area_m2 / georef.spacing_x
// P8 per-horizon well tie: world (x,y) marker on control node (ip,jp) + measured tops per horizon.
pub struct WellTie { pub id: String, pub x: f64, pub y: f64, pub ip: usize, pub jp: usize, pub tops: Vec<(String, f64)> }
impl WellTie {
pub fn new(id: impl Into<String>, x: f64, y: f64, ip: usize, jp: usize) -> WellTie;
pub fn with_top(self, horizon: impl Into<String>, depth_m: f64) -> Self; // measured formation top (positive-down)
}
// Builder entry: StaticModelBuilder::from_horizon_stack(HorizonStack, BuildOpts) — see below.
// srs-gridder: the stacked layering primitive.
pub struct ZoneLayerSpec { pub conformity: Conformity, pub requested_nk: usize }
pub struct StackedZone { pub nk: usize, pub k_start: usize, pub conformity: Conformity, pub truncated_cells: usize, pub collapsed_cells: usize }
pub struct StackedLayeredGrid { pub grid: Grid, pub nk: usize, pub zones: Vec<StackedZone>, pub truncated_cells: usize, pub collapsed_cells: usize, pub nk_capped: bool }
pub fn layer_grid_stack(surfaces: &[&Surface], dx: f64, dy: f64, zone_specs: &[ZoneLayerSpec], collapse_below_m: Option<f64>) -> Result<StackedLayeredGrid>;
// Buffer-recycling core of layer_grid_stack (the realize_into hot path): fills the caller's
// COORD + ZCORN buffers + reusable scratch in place (all cleared + fully overwritten).
pub struct LayerScratch { /* private raw/snap columns; ::new() */ }
pub struct StackLayering { pub dims: Dims, pub nk: usize, pub zones: Vec<StackedZone>, pub truncated_cells: usize, pub collapsed_cells: usize, pub nk_capped: bool }
pub fn layer_grid_stack_into(surfaces: &[&Surface], dx: f64, dy: f64, zone_specs: &[ZoneLayerSpec], collapse_below_m: Option<f64>, scratch: &mut LayerScratch, coord: &mut Vec<Pillar>, zcorn: &mut Vec<f64>) -> Result<StackLayering>;
// srs-grid recycling primitives: Grid::{take_geometry_buffers, install_geometry}, Properties::take_values, CornerPointGeom::{take_buffers, install}.
```
### Provenance (SPEC §6)
```rust
pub enum PopulationMode { Priors, Logs }
pub enum BuildWarning { // non-blocking build advisory
UnusedHorizon { name: String, role: HorizonRole, reason: String },
ThinColumnsRepaired { columns: usize, worst_m: f64 }, // R-c: with_min_thickness_m pulled thin/crossing base columns to the floor
LayersTruncated { cells: usize }, // Follow conformity collapsed `cells` deep/shallow layers to zero thickness (informational; volume-invariant)
CellsCollapsed { cells: usize }, // with_collapse_below_m: sub-threshold cells merged into a thicker zone-interior neighbour (volume-conserving)
LayerCountCapped { nk: usize }, // dz-derived nk hit MAX_NK — thickest columns' deepest part not layered (coarsen dz or accept)
}
pub struct Provenance {
pub inputs_ref: String,
pub solve_opts: SolveOpts,
pub conformity: Conformity,
pub nk: usize, // EFFECTIVE nk (dz-derived under a Follow conformity; total across zones on the stack path)
pub population: PopulationMode,
pub realization: Option<RealizationDraw>, // Some(_) for an MC realization
pub warnings: Vec<BuildWarning>, // e.g. supplied horizons unused by the build
pub property_reports: Vec<PropertyReport>, // P5: one per attached PropertyPipeline (upscale QC + propagated)
pub stack: Option<StackProvenance>, // the multi-zone stack record (from_horizon_stack); None on the Top+Base path
pub well_ties: Vec<WellTieRecord>, // P8: per-horizon tie residuals (with_well_ties); empty otherwise
}
// Multi-zone build record (SPEC §4a):
pub struct ZoneProvenance { pub name: String, pub top_horizon: String, pub base_horizon: String, pub conformity: Conformity, pub nk: usize, pub k_start: usize, pub truncated_cells: usize }
pub struct InterfaceRepair { pub interface: usize, pub columns: usize, pub worst_m: f64 }
// P8 per-horizon well ties (residual = measured_depth_m − model_depth_m at the well node, pre-tie):
pub struct HorizonTieResidual { pub horizon: String, pub measured_depth_m: f64, pub model_depth_m: f64, pub residual_m: f64 }
pub struct WellTieRecord { pub id: String, pub x: f64, pub y: f64, pub ip: usize, pub jp: usize, pub residuals: Vec<HorizonTieResidual> }
pub struct StackProvenance { pub horizons: Vec<String>, pub zones: Vec<ZoneProvenance>, pub interface_repairs: Vec<InterfaceRepair> }
```
### Per-property geostatistical pipeline (SPEC §3c; P5)
```rust
// A positioned well log for one property: world (x, y) + (tvd_m, value) samples.
pub struct WellLog { pub x: f64, pub y: f64, pub samples: Vec<(f64, f64)> }
impl WellLog { pub fn new(x: f64, y: f64, samples: Vec<(f64, f64)>) -> WellLog; }
pub enum UpscaleMethod { Arithmetic, Harmonic, Geometric } // in-cell log average
// Monte-Carlo behaviour across realizations (decision_mc_composition).
// LevelShift adds the drawn shift to the cached pattern per cell; the fraction
// cubes PORO/NTG/SW are shift-then-clamped to [0,1] so boundary cells (NTG=0,
// SW=1) saturate instead of escaping range and tripping the per-cell H2 check
// (F9). H2 still validates the drawn INPUTS at the seam.
pub enum McMode { LevelShift /* default */, Resimulate }
// The SGS propagation spec (`ps.gaussian(...)`): data-space variogram + seed +
// optional moving-neighbourhood search + optional collocated trend cokriging.
pub struct Gaussian { /* private */ }
impl Gaussian {
pub fn new(variogram: petektools::Variogram, seed: u64) -> Gaussian;
// DEFAULT search is BOUNDED: 16 nodes within max(1.5*variogram.range, 4*node_spacing).
// (Old whole-grid default -> >15 min/cube on real lattices; bounded matches it within
// simulation tolerance since beyond-range kriging weight is ~0.)
pub fn with_search(self, max_neighbours: usize, radius_m: f64) -> Self;
pub fn with_unbounded_search(self) -> Self; // opt back into the legacy whole-grid window
pub fn allow_mean_fill(self) -> Self; // opt into filling a data-less simulated layer with the
// conditioned mean; DEFAULT is a hard InvalidInput naming
// the property (+ zone, via the caller) — no silent constant fill
pub fn with_trend(self, trend: TrendSurface, corr: f64) -> Self; // collocated (Markov-1); corr=0 == plain SGS
// A world-georeferenced trend is resampled at each column's WORLD position via the
// MODEL georef (build with with_georef); on a local model it stays local/index-space.
// A secondary covering < 50% of the model frame (a georef mismatch) is a hard
// InvalidInput, NOT a silent per-node fallback to plain SGS.
}
// Model a property ONE AT A TIME: upscale logs -> per-cell conditioned values,
// then SGS-propagate (per k-layer) conditioned on those cells.
pub struct PropertyPipeline { /* private */ }
impl PropertyPipeline {
pub fn new(name: impl Into<String>) -> PropertyPipeline;
pub fn upscale(self, wells: Vec<WellLog>, method: UpscaleMethod) -> Self;
pub fn propagate(self, gaussian: Gaussian) -> Self;
pub fn name(&self) -> &str;
// The visible upscale step: per-cell conditioned field (NaN where no log) + QC.
// Each sample is binned against the cell interval interpolated AT THE WELL's (x,y)
// (bilinear), not the column-centroid mean — so an off-centroid well on a dipping
// zone boundary assigns its samples to the zone their depth truly falls in.
pub fn upscale_cells(&self, grid: &Grid) -> Result<(Vec<f64>, UpscaleQc)>;
// Full run against a grid (upscale + propagate), setting the cube.
pub fn apply(&self, grid: &mut Grid) -> Result<PropertyReport>;
// P8: run restricted to one zone's k-range, MERGING into the cube already on the
// grid (other zones' slices untouched). apply_in_zone(0..nk) == apply().
pub fn apply_in_zone(&self, grid: &mut Grid, k_range: Range<usize>) -> Result<PropertyReport>;
}
pub struct UpscaleQc { // upscaled-vs-log QC (Clone, Debug, PartialEq)
pub property: String,
pub conditioned_cells: usize, pub log_samples: usize,
pub log_mean: f64, pub upscaled_mean: f64, pub upscaled_min: f64, pub upscaled_max: f64,
}
pub struct PropertyReport { pub property: String, pub upscale: UpscaleQc, pub propagated: bool }
```
> Regular / axis-aligned column lattices (the `layer_grid` box/conformable grids);
> rotated pillars are future work. Supersedes the interim `TrendSurface` multiplier
> hook below (which now only serves the deterministic `with_areal_trend` path and,
> via the shared `petektools::resample` kernel, the collocated-secondary resample).
### Areal trend (external-drift-lite; INTERIM)
> **INTERIM mechanism** (coordinator design `python-model-build-api.md`): this
> multiplier hook is the stop-gap trend conditioning until collocated cokriging
> inside per-property propagation lands (P5
> `task_petekstatic_property_modelling`), which supersedes it. Expect the
> surface to be re-ratified then.
```rust
// A gridded areal multiplier field: nearest-node resampled to the model column
// lattice, mean-normalized (field-mean preserved), applied per-column to NTG
// (and PORO if flagged) after population — lateral SHAPE only. NaN node -> 1.0.
pub struct TrendSurface { /* ncol, nrow, values, apply_porosity, georef — private */ }
impl TrendSurface {
pub fn new(ncol: usize, nrow: usize, values: Vec<f64>) -> Result<TrendSurface>; // finite >= 0; NaN ok
pub fn with_porosity(self) -> Self; // also modulate PORO (NTG always)
pub fn with_georef(self, origin_x: f64, origin_y: f64, node_dx: f64, node_dy: f64) -> Self; // R4: resample by world (x,y), not index fraction
pub fn is_georeferenced(&self) -> bool;
pub fn applies_to_porosity(&self) -> bool;
}
```
### Construction — deterministic builder + the regeneration template
```rust
pub type PetroSample = (f64, f64, f64); // (tvd_m, φ, Sw) — population input
pub struct BuildOpts {
pub area_m2: f64, pub gross_height_m: f64,
pub nk: usize, pub conformity: Conformity, pub solve_opts: SolveOpts, // nk honoured by Proportional only; Follow styles derive it from conformity dz_m
pub priors: ConstantPriors,
}
// THE ONE declarative build configuration (task_petekstatic_spec_mirror), consumed
// by BOTH StaticModelBuilder and StaticModelTemplate: every with_* setter on either
// is thin sugar mutating an internal BuildSpec; with_spec installs a whole one.
// #[non_exhaustive] + #[serde(default)] (additive forward-compat — the per-zone
// isochore structural leg lands as a new optional field). Serde round-trips as a
// scenario file; compares by value. The WHOLE config layer derives serde
// (HorizonStack family incl. Scatter/WorldPoint, WellTie, Pick, BuildOpts, Georef,
// RealizationDraw, StructuralPerturbation, ZoneDraw, BuildSpec, TieSettings,
// McSettings + the srs-wireframe/-gridder/-volumetrics value types they carry).
// McInputs is EXCLUDED until petekTools' Sampler/Clamped derive serde (queued).
#[non_exhaustive]
pub struct BuildSpec {
pub inputs_ref: Option<String>, // None = the entry default ("flat-box"/"wireframe"/"horizon-stack")
pub georef: Option<Georef>, pub boundary: Option<Vec<[f64; 2]>>,
pub extrapolation: ExtrapolationPolicy,
pub clamp_base_to_top: bool, pub min_thickness_m: Option<f64>, pub collapse_below_m: Option<f64>,
pub sugar_cube: bool, pub sw_gas: Option<f64>,
pub well_ties: Vec<WellTie>, pub ties: TieSettings,
}
impl BuildSpec { // Default = every knob at the historical default; with_* sugar mirrors the consumers'
pub fn new() -> BuildSpec;
pub fn with_inputs_ref(self, r: impl Into<String>) -> Self; pub fn with_georef(self, ox: f64, oy: f64, sx: f64, sy: f64) -> Self;
pub fn with_boundary(self, ring: Vec<[f64; 2]>) -> Self; pub fn with_extrapolation(self, p: ExtrapolationPolicy) -> Self;
pub fn with_clamp_base_to_top(self, c: bool) -> Self; pub fn with_min_thickness_m(self, m: f64) -> Self;
pub fn with_collapse_below_m(self, m: f64) -> Self; pub fn with_sugar_cube(self, s: bool) -> Self;
pub fn with_sw_gas(self, s: f64) -> Self; pub fn with_well_ties(self, t: Vec<WellTie>) -> Self;
pub fn with_tie_settings(self, t: TieSettings) -> Self;
}
// How well ties fold into the mapped datum grids (the settings mirror over the
// datum-substitution tie machinery). Default = Replace (today's behaviour).
pub struct TieSettings { pub method: TieMethod }
impl TieSettings { pub fn replace() -> Self; pub fn radius(radius_m: f64) -> Self; }
pub enum TieMethod {
Replace, // measured top REPLACES the map datum at the tie node; radius 0 on a dense lattice
Radius { radius_m: f64 }, // bounded locality: residual decays linearly 1→0 over radius_m across DEFINED datums; beyond = bit-untouched; voids stay the solve's to taper; finite>0 or typed error
}
/// Deterministic single-shot build — the relocated RefiningModel pipeline.
pub struct StaticModelBuilder { /* ... */ }
impl StaticModelBuilder {
pub fn flat(ni: usize, nj: usize, top_depth_m: f64, contact_depth_m: f64, opts: BuildOpts) -> Result<StaticModelBuilder>;
pub fn from_wireframe(wf: &Wireframe, opts: BuildOpts) -> Result<StaticModelBuilder>; // wires a Base horizon's real relief; else gross_height_m offset
pub fn from_horizon_stack(stack: HorizonStack, opts: BuildOpts) -> Result<StaticModelBuilder>; // SPEC §4a: N horizons -> N-1 zones (per-zone conformity + contacts); opts.nk/conformity/gross_height_m unused here
pub fn from_scatter_stack(stack: HorizonStack, opts: BuildOpts, frame: StackFrame) -> Result<StaticModelBuilder>; // SPEC §4a: raw-scatter horizons — the engine conditions them onto `frame` (bilinear, voids NaN) then resolves as from_horizon_stack; registers frame.georef. THE single scatter-gridding authority
pub fn condition_scatter_stack(stack: HorizonStack, frame: &StackFrame) -> Result<HorizonStack>; // dedup seam (task_suite_scatter_perf): condition raw scatter ONCE -> the conditioned all-Mapped handle; feed to BOTH from_horizon_stack(+with_georef) and StaticModelTemplate::from_horizon_stack(+with_georef) to build a model + its MC template without re-solving the cold bilinear conditioning. Bit-identical to conditioning inside from_scatter_stack
pub fn with_collapse_below_m(self, collapse_below_m: f64) -> Self; // Petrel-style cell-collapse: sub-threshold cells merge volume-conservingly into a thicker zone-interior neighbour; warns CellsCollapsed
pub fn with_logs(self, samples: Vec<PetroSample>) -> Self;
pub fn with_areal_trend(self, trend: TrendSurface) -> Self; // INTERIM lateral NTG (+φ) shape
pub fn with_property(self, pipeline: PropertyPipeline) -> Self; // P5: per-property upscale + SGS, overwriting the cube (-> Provenance.property_reports)
pub fn with_zone_property(self, zone: impl Into<String>, pipeline: PropertyPipeline) -> Self; // P8: per-zone upscale+SGS restricted to the zone's k-range (own variogram/trend/logs); unknown zone = build-time InvalidInput
pub fn with_zone_priors(self, zone: impl Into<String>, priors: ConstantPriors) -> Self; // P8: override PORO/NTG/SW across one zone's k-range (per-zone level); unknown zone = build-time InvalidInput
pub fn with_well_ties(self, ties: Vec<WellTie>) -> Self; // P8: per-horizon ties per the spec's TieSettings (Replace default / Radius bounded locality) + re-run the stack resolution; pre-tie residuals -> Provenance.well_ties + map wells[].ties (from_horizon_stack)
pub fn with_tie_settings(self, ties: TieSettings) -> Self; // how the ties fold in; read (with the ties) at build() time — order vs with_well_ties irrelevant here
pub fn with_clamp_base_to_top(self, clamp: bool) -> Self; // R1: clamp a crossed base to zero gross instead of erroring (default: error)
pub fn with_min_thickness_m(self, min_thickness_m: f64) -> Self; // R-c: post-gridding repair — pull thin/crossing base to top+min (top preserved); warns ThinColumnsRepaired; precedence over clamp
pub fn with_extrapolation(self, policy: ExtrapolationPolicy) -> Self; // stack path: beyond-data behaviour; default DecayToData (never silent unbounded natural-dip)
pub fn with_sw_gas(self, sw_gas: f64) -> Self; // R3: gas-cap connate water for a two-contact in_place split
pub fn with_inputs_ref(self, inputs_ref: impl Into<String>) -> Self;
pub fn with_georef(self, origin_x: f64, origin_y: f64, spacing_x: f64, spacing_y: f64) -> Self; // register the model's WORLD frame (column (0,0) centroid + spacing) so view bundles emit ONE world frame; non-positive spacing ignored; grid geometry untouched
pub fn with_boundary(self, ring: Vec<[f64; 2]>) -> Self; // from_horizon_stack: world outline ring for the map bundle; omitted -> a world-extent rectangle from the georef (never the unit square vs a world frame)
pub fn with_sugar_cube(self, sugar_cube: bool) -> Self; // section rendering: false (default) = dip-following trapezoids; true = flat boxes -> IntersectionBundle.sugar_cube + flattened edge arrays
pub fn with_spec(self, spec: BuildSpec) -> Self; // install the WHOLE declarative config in one call (the with_* above are the same sugar); bit-identical results, pinned by tests/spec_conformance.rs
pub fn add_top_control(&mut self, ip: usize, jp: usize, depth_m: f64); // live refine
pub fn control_count(&self) -> usize;
pub fn build(&self) -> Result<StaticModel>; // warm kernel space (unified with the template, R2); base-above-top → CrossedSurfaces
}
/// The MC-regeneration template (SPEC §7a): built once, holds the reusable
/// warm-start state IN petekTools KERNEL SPACE. Send; one per worker.
pub struct StaticModelTemplate { /* ... */ }
impl StaticModelTemplate {
// A Base horizon in `wf` is extracted as the nominal gross field g(x,y)
// (decision_template_gross_scaling): per draw, gross = g * gross_height_m
// / mean(g) — draw sets the LEVEL, g the SHAPE; a draw at mean(g)
// reproduces the deterministic build on ALL column shapes (R2: builder +
// template share one warm kernel path). No Base = constant offset (as before).
pub fn new(wf: &Wireframe, opts: BuildOpts) -> Result<StaticModelTemplate>;
// P8 stack-aware MC (task_petekstatic_multizone_2): resolves the multi-horizon
// framework ONCE (surfaces are draw-invariant); realize varies per draw only the
// areal footprint (spacing), the per-zone contacts, and the per-zone property
// levels (RealizationDraw.zones) — bit-deterministic, realize_into-recyclable.
pub fn from_horizon_stack(stack: HorizonStack, opts: BuildOpts) -> Result<StaticModelTemplate>;
pub fn from_scatter_stack(stack: HorizonStack, opts: BuildOpts, frame: StackFrame) -> Result<StaticModelTemplate>; // raw-scatter MC template — conditions + resolves scatter byte-identically to StaticModelBuilder::from_scatter_stack
pub fn with_logs(self, samples: Vec<PetroSample>) -> Self;
pub fn with_areal_trend(self, trend: TrendSurface) -> Self; // INTERIM per-realization lateral shape
// P5 property pipelines per realization (decision_mc_composition):
pub fn with_property(self, pipeline: PropertyPipeline) -> Self; // default McMode::LevelShift
pub fn with_property_mode(self, pipeline: PropertyPipeline, mode: McMode) -> Self;
// Zone-scoped pipelines (stack templates): realize honours the with_zone_property
// upscale+SGS cube over the per-zone priors (level shift on top) — a zero-spread
// zoned MC reproduces in_place_by_zone on every piped zone. Unknown zone = realize InvalidInput.
pub fn with_zone_property(self, zone: impl Into<String>, pipeline: PropertyPipeline) -> Self; // default McMode::LevelShift
pub fn with_zone_property_mode(self, zone: impl Into<String>, pipeline: PropertyPipeline, mode: McMode) -> Self;
// Well ties (stack templates): draw-invariant, applied ONCE at construction
// (re-solve + repair); every draw inherits tied geometry at zero per-draw cost.
// Err if not stack-aware / bad tie node / unknown horizon.
pub fn with_well_ties(self, ties: Vec<WellTie>) -> Result<StaticModelTemplate>;
pub fn with_tie_settings(self, ties: TieSettings) -> Self; // set BEFORE with_well_ties (ties apply at that call); same tie authority as the builder — node-for-node identical
pub fn with_sugar_cube(self, sugar_cube: bool) -> Self; // section rendering (see StaticModelBuilder::with_sugar_cube)
pub fn with_clamp_base_to_top(self, clamp: bool) -> Self; // R1: clamp a crossed base per realization instead of erroring
pub fn with_min_thickness_m(self, min_thickness_m: f64) -> Self; // R-c: per-realization post-gridding repair to top+min (top preserved); warns ThinColumnsRepaired
pub fn with_extrapolation(self, policy: ExtrapolationPolicy) -> Result<StaticModelTemplate>; // stack templates: re-resolve surfaces under the policy (default DecayToData)
pub fn with_collapse_below_m(self, collapse_below_m: f64) -> Self; // per-realization cell-collapse (volume-conserving); warns CellsCollapsed
pub fn with_inputs_ref(self, inputs_ref: impl Into<String>) -> Self;
pub fn with_georef(self, origin_x: f64, origin_y: f64, spacing_x: f64, spacing_y: f64) -> Self; // stamp the WORLD frame onto every realized model (see StaticModelBuilder::with_georef)
pub fn with_boundary(self, ring: Vec<[f64; 2]>) -> Self; // stack-aware: world outline ring stamped onto each realized map bundle (see StaticModelBuilder::with_boundary)
pub fn with_spec(self, spec: BuildSpec) -> Result<StaticModelTemplate>; // install the WHOLE declarative config (the SAME BuildSpec the builder consumes); re-applies extrapolation + ties through the setters above — bit-identical, pinned
/// The CHEAP per-realization call: warm-started solve, re-layer, re-populate.
/// `&mut self` advances the warm chain; Result per draw.
pub fn realize(&mut self, draw: &RealizationDraw) -> Result<StaticModel>; // = new-empty-then-realize_into (allocating convenience)
/// The buffer-recycling hot path (ratified amendment 3): realize into a REUSED
/// model, overwriting its ZCORN + cube allocations in place (~100 MB/draw at 1M
/// cells). Bit-identical to `realize` on the same draw + chain. Prefer on the MC loop.
pub fn realize_into(&mut self, draw: &RealizationDraw, model: &mut StaticModel) -> Result<()>;
/// A fresh empty model to drive a `realize_into` loop against — one per MC worker.
pub fn reusable_model(&self) -> StaticModel;
}
/// The per-realization sampled input set — petekStatic's neutral type, filled by
/// the sampler. #[non_exhaustive]: construct via ::new + with_*. fvf EXCLUDED
/// (applied at the volumetrics output surface, never rides the draw).
#[non_exhaustive]
pub struct RealizationDraw { // Clone + Debug
pub area_m2: f64, pub gross_height_m: f64, pub contact_depth_m: f64, // contact = OWC/FWL (lower) when a GOC is set; gross = level multiplier over the template's base-relief shape when a Base horizon exists
pub goc_depth_m: Option<f64>, // Some(_) => two-contact (gas cap + oil rim)
pub porosity: f64, pub net_to_gross: f64, pub water_saturation: f64,
pub seed_index: u64,
pub structural: Option<StructuralPerturbation>, // MVP explicit control-node depth shifts (2-surface path)
pub top_structural: Option<PerturbationField>, // correlated TOP-surface DEPTH field this draw (both paths); decision_structural_uncertainty_isochore
pub sw_gas: Option<f64>, // R3: gas-cap connate-water override (with a GOC)
pub property_shifts: Vec<(String, f64)>, // P5: per-property additive level shift for McMode::LevelShift
pub zones: Vec<ZoneDraw>, // P8: per-zone contacts + property levels (stack-aware from_horizon_stack MC); empty on the 2-surface path
}
// P8 per-zone draw for a stack-aware template. A ZoneDraw's contacts REPLACE the
// template's static contacts for that zone (neither GOC nor OWC => explicitly
// contactless: GRV, zero HC). porosity/ntg/sw override the base priors in the zone.
#[non_exhaustive] // additive forward-compat (per-zone isochore leg)
pub struct ZoneDraw { // Clone + Debug + PartialEq + serde
pub zone: usize, // zone index (top->down)
pub goc_depth_m: Option<f64>, pub owc_depth_m: Option<f64>,
pub porosity: Option<f64>, pub net_to_gross: Option<f64>, pub water_saturation: Option<f64>,
pub isochore_structural: Option<PerturbationField>, // correlated THICKNESS field for this zone (moves its DEEPER horizon); clamped >=0, zero-masked at exact merges; decision_structural_uncertainty_isochore
}
impl ZoneDraw {
pub fn new(zone: usize) -> ZoneDraw; // no contacts / overrides
pub fn with_owc(self, owc_depth_m: f64) -> Self; pub fn with_goc(self, goc_depth_m: f64) -> Self;
pub fn with_priors(self, porosity: f64, net_to_gross: f64, water_saturation: f64) -> Self;
pub fn with_isochore_structural(self, field: PerturbationField) -> Self; // attach this zone's isochore perturbation field
}
impl RealizationDraw {
pub fn new(area_m2: f64, gross_height_m: f64, contact_depth_m: f64,
porosity: f64, net_to_gross: f64, water_saturation: f64,
seed_index: u64) -> RealizationDraw; // goc None, structural None
pub fn with_zone_draw(self, zone: ZoneDraw) -> Self; // P8: attach/replace a per-zone draw (stack-aware MC)
pub fn with_structural(self, p: StructuralPerturbation) -> Self;
pub fn with_top_structural(self, field: PerturbationField) -> Self; // attach the correlated TOP-surface depth perturbation field
pub fn with_property_shift(self, property: impl Into<String>, delta: f64) -> Self; // P5 level shift
pub fn property_shift(&self, property: &str) -> f64; // 0.0 if none
pub fn with_goc(self, goc_depth_m: f64) -> Self; // gas cap above; must be shallower than the OWC
pub fn with_sw_gas(self, sw_gas: f64) -> Self; // R3: gas-cap connate water (effective only with a GOC)
// named scalar setters (V8/R6 ergonomics) — fluent field-by-field overrides; ::new stays for compat
pub fn with_area(self, area_m2: f64) -> Self;
pub fn with_gross(self, gross_height_m: f64) -> Self;
pub fn with_contact(self, contact_depth_m: f64) -> Self;
pub fn with_porosity(self, porosity: f64) -> Self;
pub fn with_ntg(self, net_to_gross: f64) -> Self;
pub fn with_sw(self, water_saturation: f64) -> Self;
}
pub struct StructuralPerturbation { // Clone + Debug + Default
pub control_shifts: Vec<(usize, usize, f64)>, // (ip, jp, dz_m)
}
// A correlated structural perturbation FIELD for one horizon/isochore of a draw
// (decision_structural_uncertainty_isochore): an unconditional Gaussian random field
// (petektools sgs_unconditional) with marginal N(0, sd_m^2) and the variogram's
// spatial continuity, generated on the areal node lattice at realize() time and
// added to a TOP DEPTH surface (RealizationDraw.top_structural) or a zone THICKNESS
// (ZoneDraw.isochore_structural). The mean is variogram-INDEPENDENT (every node's
// marginal is N(0,sd^2) regardless of correlation) — only the field's shape/range
// depends on the variogram. Seed = draw.seed_index salted by the horizon index
// (bit-reproducible per seed, independent across horizons); SGS neighbourhood derived
// from the variogram range. sd_m <= 0 = a no-op (zero field). Perturbation is pinned
// to zero at well-tie nodes. Copy + Clone + Debug + PartialEq + serde.
pub struct PerturbationField { pub sd_m: f64, pub variogram: Variogram }
impl PerturbationField { pub fn new(sd_m: f64, variogram: Variogram) -> PerturbationField; }
```
```rust
// Compile-checked (ratified amendment 1):
static_assertions::assert_impl_all!(StaticModelTemplate: Send);
static_assertions::assert_impl_all!(StaticModel: Send);
```
### Structured Monte-Carlo driver (`task_peteksim_mc_structured`, LANDED)
The driver over the template: samples every uncertain input from a petekTools
`Sampler`, realizes each draw, and KEEPS the per-draw output vectors (W17). `boi`
/ `bgi` are sampled like any input but applied at the **volumetrics surface**,
never on the draw (ratified). Re-exports `Sampler` / `Correlation` /
`ReservoirSummary` from petekTools so a caller needs no direct petekTools import.
```rust
pub enum Input { Plain(Sampler), Clamped(Clamped) } // Copy + Debug; From<Sampler>/From<Clamped>
impl Input {
pub fn plain(sampler: Sampler) -> Input;
pub fn clamped(sampler: Sampler, lo: f64, hi: f64) -> Result<Input>; // petekTools .clamped (hard limiter)
}
pub struct McInputs { /* one Input per quantity; Clone + Debug */
pub area_m2, gross_height_m, contact_depth_m, porosity, net_to_gross,
water_saturation, boi: Input, // the load-bearing set
pub goc_depth_m, sw_gas, bgi: Option<Input>, // two-contact / gas-FVF
pub property_shifts: Vec<(String, Input)>, // per McMode::LevelShift property
}
impl McInputs {
pub fn new(area_m2, gross_height_m, contact_depth_m, porosity,
net_to_gross, water_saturation, boi: Input) -> McInputs;
pub fn with_goc(self, Input) -> Self; pub fn with_sw_gas(self, Input) -> Self;
pub fn with_bgi(self, Input) -> Self;
pub fn with_property_shift(self, property: impl Into<String>, shift: Input) -> Self;
pub fn realize(&self, n: usize, seed: u64) -> Result<RealizedInputs>; // Err on n == 0
}
pub struct RealizedInputs { /* per-field Vec<f64> of length n; Clone + Debug */ } // retained for tornado reuse
pub struct McResult { /* Clone + Debug */
pub oil_sm3: Vec<f64>, // primary metric — two-contact oil leg, else whole column
pub gas_sm3: Vec<f64>, // gas cap of a two-contact run (needs bgi); else 0
pub grv_m3: Vec<f64>, // hydrocarbon-column GRV per draw
}
impl McResult {
pub fn len(&self) -> usize; pub fn is_empty(&self) -> bool;
pub fn realized_inputs(&self) -> &RealizedInputs;
pub fn summary(&self) -> Result<ReservoirSummary>; // oil P90/P50/P10 (petekTools reservoir_summary)
pub fn gas_summary(&self) -> Result<ReservoirSummary>;
pub fn grv_summary(&self) -> Result<ReservoirSummary>;
}
// THE one structured-MC entry (task_petekstatic_spec_mirror) — the run-resources
// settings value consolidating the four historical fns (now DEPRECATED thin
// wrappers, pinned bit-identical by test). workers=1 serial (default); >1 = the
// rayon-sharded driver (sharded == serial contract). spill_dir None = in-core
// (default); Some(dir) = the spilled mode's per-shard reused f32 store under dir
// (pass std::env::temp_dir() for the old spilled(None)). Serde + PartialEq;
// #[non_exhaustive] (construct via ::new + with_*).
#[non_exhaustive]
pub struct McSettings { pub n: usize, pub seed: u64, pub workers: usize, pub spill_dir: Option<PathBuf> }
impl McSettings {
pub fn new(n: usize, seed: u64) -> McSettings; // serial, in-core
pub fn with_workers(self, workers: usize) -> Self; // clamp [1, n]; default_mc_workers() recommended
pub fn with_spill_dir(self, dir: impl Into<PathBuf>) -> Self; // selects the spilled mode
}
/// Seeded, reproducible: same (inputs, settings) -> bit-identical vectors.
/// FAIL-FAST error policy: the first failing draw surfaces as
/// StaticError::McDraw { index, source } (H2 typed error + the draw index).
pub fn run_mc(tmpl: &mut StaticModelTemplate, inputs: &McInputs,
settings: &McSettings) -> Result<McResult>;
#[deprecated] pub fn run_structured_mc(..., n, seed) -> Result<McResult>; // = run_mc(McSettings::new(n, seed))
#[deprecated] pub fn run_structured_mc_parallel(..., n, seed, workers) -> Result<McResult>; // = ….with_workers(workers)
#[deprecated] pub fn run_structured_mc_spilled(..., n, seed, spill_dir) -> Result<McResult>; // = ….with_spill_dir(dir | temp_dir())
#[deprecated] pub fn run_structured_mc_parallel_spilled(...) -> Result<McResult>; // = both
/// Field aggregation over segments' oil vectors, delegating to petekTools
/// `aggregate`: Independent (narrow) vs Comonotonic (wide downside) bracket.
pub fn aggregate_field(segments: &[&McResult], corr: Correlation) -> Vec<f64>;
```
### Tornado sensitivity (`task_peteksim_tornado`, LANDED)
One-at-a-time swings of **oil in-place Sm³**: each input pivoted at the
**realized** lo/hi percentiles (others at P50), re-realized through the template,
pre-sorted by swing descending. Pass the same `(n, seed)` as the MC run to reuse
its exact draws.
```rust
pub struct TornadoBar { pub input: String, pub lo_val, hi_val, out_lo, out_hi, swing: f64 } // Clone + PartialEq
pub fn tornado(tmpl: &mut StaticModelTemplate, inputs: &McInputs,
n: usize, seed: u64, lo_pct: f64, hi_pct: f64) -> Result<Vec<TornadoBar>>;
```
---
### Out-of-core — memory budget + spilled `StaticModel` (`task_petekstatic_slab_streaming`; SPEC §10)
The engine chooses its **backing-storage mode** against a declared budget (out-of-core
rulings R2/R3/R4/R5). Below budget → today's in-core path, **byte-identical**. Above
budget → geometry (ZCORN) + cubes spill to a petekTools `store` (f32 lanes, R4; COORD
resident) and the volumetric surface reads through **windowed mmap views**, streaming
one k-slab at a time (f64 accumulation). Loud switch, never OOM, temp cleanup on drop.
> **Crate origin (P10 organize wave):** these types live in the `srs-spill` crate,
> split out of `srs-model` 2026-07-05 (`task_petekstatic_organize`). `srs-model`
> re-exports the whole surface, so every path below resolves unchanged as
> `srs_model::{MemoryBudget, decide_mode, spill_grid, SpillBacking, …}`.
```rust
// The declared budget (default: DEFAULT_BUDGET_FRACTION of physical RAM).
pub const DEFAULT_BUDGET_FRACTION: f64; // 0.5
pub struct MemoryBudget { /* private */ } // Copy + Eq
impl MemoryBudget {
pub fn bytes(limit_bytes: u64) -> Self;
pub fn fraction_of_physical(fraction: f64) -> Self; // clamped [0,1]
pub fn unlimited() -> Self; // never spill (force in-core)
pub fn limit_bytes(&self) -> u64; pub fn is_unlimited(&self) -> bool;
}
impl Default for MemoryBudget { /* fraction_of_physical(DEFAULT_BUDGET_FRACTION) */ }
pub enum BuildMode { InCore, Spilled } // Copy + Eq
pub fn decide_mode(dims: Dims, n_cubes: usize, budget: MemoryBudget) -> (BuildMode, u64);
pub struct SpillNotice { pub cells: usize, pub budget_bytes: u64, // the loud advisory (R5)
pub estimate_bytes: u64, pub store_path: PathBuf }
impl SpillNotice { pub fn warn(&self); } // → stderr on every spilled build
// Builder / template opt-in (both StaticModelBuilder and — same setters — the flow):
impl StaticModelBuilder {
pub fn with_memory_budget(self, budget: MemoryBudget) -> Self; // default MemoryBudget::default()
pub fn with_spill_dir(self, dir: impl Into<PathBuf>) -> Self; // default std::env::temp_dir()
pub fn with_spill_persist(self, persist: bool) -> Self; // keep the store past drop
}
// StaticModel: spilled is a backing-storage MODE, not a new type. in_place /
// in_place_summary / bulk_volume / in_place_by_zone route to the store when spilled;
// grid() / property() return a placeholder (read a spilled model through its
// volumetric methods). in_place_by_zone streams per zone (zones are contiguous
// k-bands) — v2, item 3. zone_stats stays in-core-only (typed error spilled).
impl StaticModel {
pub fn is_spilled(&self) -> bool;
pub fn spill_store_path(&self) -> Option<&Path>;
pub fn dims(&self) -> Dims; // real dims, in-core or spilled
}
// The ONE GRV/HCPV loop (srs-volumetrics, v2 item 2): a SlabSource abstracts the
// two backings (contiguous f64 Grid vs mmap f32 slab); `compute_clipped` is the
// generic streaming core (three monomorphic loops over a Clip). GridSource is the
// in-core backing; SpillBacking::source() is the spilled one. grv.rs's public
// compute_* fns are thin wrappers over this (signatures unchanged).
pub trait SlabSource { fn dims(&self) -> Dims; fn require_cubes(&self) -> Result<()>;
type Slab<'a>: CellSlab; fn slab(&self, k: usize) -> Result<Self::Slab<'_>>; }
pub trait CellSlab { fn centroid_z(&self, l: usize) -> f64; fn cell_volume(&self, l: usize) -> f64;
fn poro(&self, l: usize) -> f64; fn ntg(&self, l: usize) -> f64; fn sw(&self, l: usize) -> f64; }
pub enum Clip { Bulk, Single(f64), Two { goc: f64, owc: f64, sw_gas: Option<f64> } }
pub fn compute_clipped<S: SlabSource>(src: &S, clip: Clip, k_range: Range<usize>, per_cell: bool) -> Result<InPlace>;
pub struct GridSource<'a> { /* &Grid + cube slices */ } impl GridSource { pub fn new(grid: &Grid) -> Self; }
// Spill a built grid to a store (mmap-backed, f32 lanes); Drop removes the file
// unless detached. Used internally by the builder above budget. spill_streaming is
// the ONE store-writing path — the slab-incremental build (v2 item 1) drives it
// with an on-demand ZCORN + constant-cube producer (never a whole in-core grid);
// spill_grid_to rides it too (its producer reads a built grid).
pub fn spill_grid(grid: &Grid, dir: &Path, cleanup: bool) -> Result<SpillBacking>;
pub fn spill_grid_to(grid: &Grid, path: &Path, cleanup: bool) -> Result<SpillBacking>;
pub fn spill_streaming(path: &Path, dims: Dims, coord: &[Pillar], cube_names: &[String], cleanup: bool,
fill_zcorn: impl FnMut(usize, &mut [f32]) -> Result<()>,
fill_cube: impl FnMut(&str, usize, &mut [f32]) -> Result<()>) -> Result<SpillBacking>;
pub struct SpillBacking { /* private: Store + resident COORD + dims + cube names */ }
impl SpillBacking {
pub fn dims(&self) -> Dims; pub fn store_path(&self) -> &Path; pub fn detach(&mut self);
pub fn cube_names(&self) -> &[String]; pub fn bulk_volume(&self) -> Result<f64>;
pub fn source(&self) -> SpillSource<'_>; // the spilled SlabSource (→ compute_clipped)
}
// Spilled structured MC (R3/R4) — run_mc with McSettings::with_spill_dir(dir):
// each draw realizes in-core into the ONE reusable model, is flushed to a REUSED
// per-shard store (overwritten per draw — never a new file per draw), and its
// summary streamed from the f32 lanes → discard. Bit-deterministic within the
// spilled mode; sharded == serial at every worker count. The historical
// run_structured_mc_spilled / _parallel_spilled entries are DEPRECATED thin
// wrappers over run_mc (spill_dir None maps to std::env::temp_dir()).
```
> **Parity is tolerance-based** (R4 honesty clause): f32 storage lanes change volumes
> at a small relative bound (measured on a 2500 m fixture: GRV/bulk bit-exact, HCPV
> ≤ ~2e-9, MC oil ≤ ~3.1e-6 relative — contact-boundary sensitive; asserted ≤ 1e-5).
> **v2 (item 1):** the spilled build is now **slab-incremental** — ZCORN + constant
> cubes stream k-slab-by-k-slab into the store (never a whole in-core grid), so the
> build's heap working set is **O(slab)** and a >RAM model builds. Measured peak RSS
> (3.2M cells): in-core 90 B/cell, build-then-spill 135, **streaming 47** (≈ the f32
> store alone). Eligible: constant/per-zone-constant population, collapse off;
> otherwise build-then-spill. Deferred: streaming SGS-population + writable-store MC
> realize; true O(slab) *resident* RSS awaits a page-evicting store writer
> (`question_petektools_store_page_evict`).
---
### View bundles — the viewer export seam (`view`; SPEC §7e)
Typed inspection bundles for a **separate** viewer codebase
(`decision_viewer_home_product`): petekStatic exports, the viewer renders — it
never computes. The serialized **structure is the contract** — version-tagged and
locked by a schema-snapshot test. `f64::NAN` = undefined (JSON `null`); SI units,
positive-down depth. Bundles **stream** straight to an `io::Write` (no intermediate
`serde_json::Value` tree — the streaming path that removes the legacy ~12.7×-payload
RSS spike): map/section via `write_json`; the volume bundle via its binary-block
envelope writers (below).
```rust
pub const SCHEMA_VERSION: u32 = 5; // v3: volume exterior-shell + binary blocks
// v4: section HorizonTrace polylines + MapBundle per-well `ties`
// v5: section colour-by-zone — IntersectionBundle.zones + SectionColumn.zone_ids
// (all additive #[serde(default)] fields; an older decoder ignoring unknown keys still reads v5)
// map/section: plain JSON, carry the family version
impl MapBundle { pub fn write_json<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>; }
impl IntersectionBundle { pub fn write_json<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>; }
// The model's registered WORLD georeference (StaticModelBuilder::with_georef):
// world (x,y) of column (0,0)'s centroid + world column spacing. Some -> the
// view frames are WORLD; None -> the local degenerate frame (synthetic square/box).
pub struct Georef { pub origin_x, origin_y, spacing_x, spacing_y: f64 }
impl Georef { pub fn new(origin_x, origin_y, spacing_x, spacing_y: f64) -> Option<Georef>; } // None if spacing not finite-positive
// Shared areal georeference: world (x,y) of node (i,j) = (origin + i*spacing, ...).
// origin/spacing come from the model's Georef when registered (WORLD frame — the
// raster overlays the world outline/wells and a world fence/bore section traces
// through it); otherwise from the grid's local column-centroid lattice.
pub struct GridFrame { pub origin_x, origin_y, spacing_x, spacing_y: f64,
pub ncol: usize, pub nrow: usize } // ncol=ni, nrow=nj
pub struct ValueRange { pub min: f64, pub max: f64 } // over finite entries (legend)
// A named georeferenced field on the shared frame, row-major values[j*ncol + i].
pub struct ScalarLayer { pub name: String, pub units: String,
pub values: Vec<f64>, pub range: ValueRange }
```
**`MapBundle` — `model.map_bundle(&MapSpec) -> Result<MapBundle>`** (areal / plan-view)
```rust
pub struct MapSpec { /* private */ }
impl MapSpec {
pub fn new() -> Self; // structural surfaces + outline + contacts only
pub fn property(self, name: impl Into<String>) -> Self; // + zone-average map(s) for this cube
pub fn k_slice(self, k: usize) -> Self; // also emit a single-k-slice map per property
}
pub struct WellTieResidual { pub horizon: String, pub residual_m: f64 } // v4: one per horizon tied
pub struct WellMarker { pub id: String, pub x, y: f64, pub tie_residual_m: Option<f64>, // tie_residual_m = mean of ties (None if no ties)
pub ties: Vec<WellTieResidual> } // v4: per-horizon residuals (from Provenance.well_ties; empty if none)
pub struct ContactMask { pub kind: String, pub depth_m: f64, pub crossing: Vec<bool> } // row-major j*ncol+i
pub struct MapBundle {
pub schema_version: u32,
pub inputs_ref: String, // provenance identity
pub frame: GridFrame, // shared by every areal layer below
pub outline: Vec<Vec<[f64; 2]>>, // boundary ring(s), world [x,y]
pub horizons: Vec<ScalarLayer>, // realized top + base depth surfaces (units "m")
pub zone_averages: Vec<ScalarLayer>, // name "{property}::{zone}" (the useful default)
pub k_slices: Vec<ScalarLayer>, // name "{property}::k{n}" (empty unless k_slice set)
pub wells: Vec<WellMarker>, // per-horizon tie residuals (from with_well_ties); empty if no ties
pub contacts: Vec<ContactMask>, // per-contact subcrop mask
}
```
Errors (`StaticError::InvalidInput`): column lattice `< 2x2` or not
axis-aligned/regular; a requested property absent; `k_slice` out of range.
**`IntersectionBundle` — `model.intersection_bundle(&SectionSpec, property: Option<&str>) -> Result<..>`** (vertical section)
The trace is marched through the areal lattice at a sub-cell step; each distinct
column crossed becomes an ordered `SectionColumn`. Raw metres — the viewer applies
vertical exaggeration. The structural top/base traces are the first/last *active*
`layer_tops` / `layer_bases`. Under a Follow conformity the active-layer count
varies per column; the arrays stay `nk`-sized and an inactive
(truncated/zero-thickness) layer is `NaN` in `layer_tops`, `layer_bases`, AND
`values` (the viewer NaN-guards).
**Interior-horizon traces (SCHEMA_VERSION 4).** `horizon_traces` carries one
polyline per *interior* framework horizon (every zone-bounding horizon strictly
between the structural top and base — `N − 2` of them for an `N`-horizon stack,
top→down); the structural top/base are NOT repeated. Each `depths` array runs
**parallel to `columns`**: `depths[c]` is that horizon's depth at `columns[c]`,
taken as the zone-top interface (the top-depth of the first cell of the zone the
horizon bounds above). A single-zone (2-surface) model emits an empty
`horizon_traces`, so the block is backward-compatible.
```rust
pub enum SectionSpec {
Polyline(Vec<[f64; 2]>), // world [x,y] trace
AlongBore { trajectory: Vec<[f64; 3]> }, // world [x,y,z] — xy traces, z overlays
}
pub struct SectionColumn {
pub distance_m: f64, pub i: usize, pub j: usize, pub x: f64, pub y: f64,
pub layer_tops: Vec<f64>, // per-layer cell top depth (len nk), top->base — column CENTROID (hover/back-compat)
pub layer_bases: Vec<f64>, // per-layer cell base depth (len nk) — column centroid
// v4-additive: cell top/base at the column's LEFT/RIGHT fence edges (bilinear from
// the ZCORN corners at the section's entry/exit x,y), NaN-gapped like layer_tops.
// With these the viewer draws a dip-following TRAPEZOID; under sugar_cube they equal
// the centroid trace. FROZEN field names (a concurrent viewer consumes them).
// Fence direction: for a Polyline it is the trace segment; for an AlongBore it is
// the trace's AREAL TANGENT through the column (neighbouring column centres, not the
// raw MD-station micro-segment — which degenerates to the centroid on a vertical /
// densely-sampled bore). A truly vertical bore (a single areal point, one column)
// has no tangent, so its edges stay the centroid (l == r) — the one honest flat case.
pub layer_tops_l: Vec<f64>, pub layer_tops_r: Vec<f64>,
pub layer_bases_l: Vec<f64>, pub layer_bases_r: Vec<f64>,
pub values: Vec<f64>, // per-layer property value (empty if property=None)
// v5-additive (#[serde(default)]): per-layer zone id — index into IntersectionBundle.zones,
// NaN-gapped in lockstep with the geometry/value arrays (an inactive layer carries the
// sentinel SectionColumn::NO_ZONE = u16::MAX). FROZEN field name (the viewer decodes it).
pub zone_ids: Vec<u16>,
pub path_z: Option<f64>, // bore path depth at this station (AlongBore only)
}
impl SectionColumn { pub const NO_ZONE: u16 = u16::MAX; } // zone_ids gap sentinel (inactive layer)
pub struct SectionContact { pub kind: String, pub depth_m: f64 }
pub struct HorizonTrace { pub name: String, pub depths: Vec<f64> } // v4; depths parallel to columns
pub struct SectionZone { pub name: String, pub color: Option<String> } // v5; a zone in the id table (color from StackZone.color)
pub struct IntersectionBundle {
pub schema_version: u32,
pub inputs_ref: String,
pub sugar_cube: bool, // v4-additive: true = flat-box cells (edge arrays flattened to centroid); false (default) = dip-following trapezoids
pub property: Option<String>, // which property `values` carry
pub top_name: String, pub base_name: String, // structural-surface labels
pub columns: Vec<SectionColumn>, // in trace order (by distance_m)
pub horizon_traces: Vec<HorizonTrace>, // v4: interior-horizon polylines, top->down (empty for a single zone)
pub zones: Vec<SectionZone>, // v5-additive: the zone table zone_ids indexes into ([{name,color}], top->base). FROZEN
pub contacts: Vec<SectionContact>, // GOC / OWC / GWC depths
}
```
**Section colour-by-zone (SCHEMA_VERSION 5, `task_suite_section_zone_color`).** The
bundle's `zones` list (`[{name, color}]`, the model's stratigraphic zones top→base)
plus each column's per-layer `zone_ids` are the payload half of the viewer's
colour-by-zone rendering. `zone_ids[k]` indexes `zones`; it is `NaN`-gapped in
lockstep with the geometry/value arrays — an inactive/truncated layer (where those
are `NaN`) carries `SectionColumn::NO_ZONE` (`u16::MAX`), which the viewer skips
exactly as it skips a `NaN` depth. `color` comes from `StackZone::color` (`None` on
the single-implicit-zone paths). Both fields are `#[serde(default)]` (additive). The
field names `zone_ids` and `zones: [{name, color}]` are **frozen** — a concurrent
viewer decodes exactly them.
Errors (`StaticError::InvalidInput`): lattice `< 2x2` or not axis-aligned/regular;
trace with `< 2` vertices; a named property absent.
**`VolumeBundle` — `model.volume_bundle(property: &str) -> Result<VolumeBundle>`** (3-D exterior shell, SCHEMA_VERSION 4)
The corner-point cell **exterior shell** coloured by `property`: only faces
bordering an inactive/absent neighbour or the grid boundary are emitted (a cell is
active iff its property value is finite and its bulk volume is positive). For a
low-relief grid this is O(surface) not O(volume) — a 1M-cell (200×200×25) box drops
from ~12M triangles to ~200k (1.67%), and the serialized payload from ~557 B/cell
decimal-text JSON to **~6.65 B/cell** (self-contained) / **~5 B/cell** (sidecar).
Shared vertices are deduplicated by f32 position; the per-cell arrays are compacted
to the shell cells only, with a `tri_cell` index per triangle recovering cell
identity for the viewer's threshold filter / picking. The mesh builder **lives
here** — moved DOWN from petekSim's `srs-core/mesh.rs` (the DAG flows downward).
```rust
pub struct VolumeBundle {
pub schema_version: u32, // 4
pub inputs_ref: String,
pub property: String,
pub cell_count: usize, // total grid cells (cell_values.len() = shell count)
pub positions: Vec<f32>, // deduped shell verts, 3 (x,y,z) each — grid-LOCAL coords
pub indices: Vec<u32>, // 3 per triangle, into positions
pub tri_cell: Vec<u32>, // compact cell index per triangle (into cell_values/zone_ids)
pub cell_values: Vec<f32>, // per shell cell (compact) — the filter/legend array
pub zone_ids: Vec<u16>, // per shell cell (compact) — index into zone_names
pub zone_names: Vec<String>,
pub value_range: ValueRange, // over the shell cell values
}
impl VolumeBundle {
// Self-contained: metadata envelope + base64-wrapped binary blocks, ONE file (save_view).
pub fn write_self_contained<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>;
// Served: JSON envelope (offset,length manifest) + raw model.bin blocks, no base64 tax.
pub fn write_sidecar<W1: std::io::Write, W2: std::io::Write>(&self, json: &mut W1, bin: &mut W2) -> std::io::Result<()>;
}
// Threshold regeneration (decision (b)): client slider is shell-only; the server
// re-cuts the shell at a cutoff (exposing revealed interior) via:
model.volume_bundle_thresholded(property: &str, cutoff: f64, keep_above: bool) -> Result<VolumeBundle>
```
Errors (`StaticError::InvalidInput`): `property` absent (both methods).
**Binary-block payload spec (SCHEMA_VERSION 4 — the viewer decode contract).** The
big arrays are emitted as raw **little-endian**, tightly packed (no padding within
or between blocks) bytes; the JSON envelope stays human-readable
(names/units/ranges/zones/provenance). dtypes: `f32` (4 B), `u32` (4 B), `u16`
(2 B); a NaN f32 is canonical `0x7FC00000`. Block order = row-major (C-order)
flatten of `shape`.
```jsonc
{ "schema_version": 4, "kind": "volume", "inputs_ref": ..., "property": ...,
"cell_count": N, "shell_cell_count": C, "vertex_count": V, "triangle_count": T,
"zone_names": [...], "value_range": {"min":..,"max":..},
"positions": { "dtype":"f32", "shape":[V,3], <payload> },
"indices": { "dtype":"u32", "shape":[T,3], <payload> },
"tri_cell": { "dtype":"u32", "shape":[T], <payload> },
"cell_values": { "dtype":"f32", "shape":[C], <payload> },
"zone_ids": { "dtype":"u16", "shape":[C], <payload> } } }
// <payload> = "data":"<base64 of the LE bytes>" when encoding=="base64"
// = "offset":<byte>, "length":<byte> when encoding=="sidecar"
// (blocks concatenated in the above order in the companion model.bin)
```
---
## Python surface
The `petekstatic` wheel exposes the Rust-backed `StaticModel` / `build_flat_model`
surface and the first notebook-facing workflow facade:
```python
petekstatic.__all__ == [
"CoKriging", "DistributionSpec", "Grid", "HorizonSpec", "Layering",
"PropertyHandle", "PropertyPipeline", "PropertyPipelineSpec",
"PropertyStore", "SgsRecipe", "Spherical", "StaticModel",
"UpscaleRecipeBuilder", "Var", "VolumeCase", "VolumeResult",
"WellTie", "WellLog", "WellLogSpec",
"__version__", "build_flat_model",
"distributions", "upscale",
]
```
`build_flat_model` returns a single-zone `StaticModel` suitable for smoke tests,
volume reads, and bundle reads. `Grid.from_project(...)` owns the canonical
static declaration shape in Python: geometry, horizons, zones, layers, scalar
and formula property assignment, property recipe lowering/execution handles, and
deterministic simple volumes. Full production corner-point model building, contact scenarios,
bundles, and MC remain backed by the Rust `StaticModelBuilder` path and may
still be surfaced through petekSim's downstream product facade while the facade
lowering is completed.
---
## Change control
The cross-library seams here (`StaticModel` accessors, `RealizationDraw`, the
regeneration API, `solve_surface_seeded`/`KernelSurface`) are ratified contracts:
changing a signature requires (1) an edit to this file, (2) coordinator
(petekSuite) + consumer (petekSim) sign-off. Library-internal signatures are
petekStatic's own call. The whole file locks at the 0.1 release
(`task_petekstatic_release_0_1`).