Simulation & uncertainty¶
Goal. Turn a deterministic static model into a probabilistic one — zoned Monte-Carlo, structural uncertainty, P-curves and tornado — and derive scenarios off the same geometry.
This tutorial continues from the flagship static-model
build; model is the populated model from there.
Runnable notebook
Mirrored by the executed notebook petekSim — scenarios & uncertainty.
Zoned Monte-Carlo — one Mc spec¶
A single Mc spec describes the uncertainty, and zoned_uncertainty runs it
per zone, drawing per-zone contact spreads and per-property level shifts. On a
zoned model it auto-routes to the per-zone run.
mc = model.zoned_uncertainty(
ps.Mc(porosity=0.01, # sd on the porosity level shift
contacts=4.0, # sd (m) on the lower FWL / OWC pick
goc=3.0, # sd (m) on the gas-oil contact
n=2000, seed=42),
)
mc.total["stoiip"] # {p90_msm3, p50_msm3, p10_msm3, mean, samples}
mc.zones # [{zone, stoiip:{...}, giip:{...}, two_contact}, ...]
Every Mc field accepts a scalar sd (sugar for ps.shift(sd)), an explicit
ps.shift(...) / ps.dist(...), or None. per_zone={"Z2": ps.Mc(...)}
overrides a single zone.
The reservoir P-convention
Percentiles follow the petroleum exceedance convention: P90 = low, P10 =
high. p90_msm3 < p50_msm3 < p10_msm3.
Structural uncertainty (sd / vgm)¶
Declaring sd and vgm on the horizon rows adds a correlated depth /
thickness perturbation to each MC draw:
- the top row's
sd/vgmis a correlated top-depth field; - every deeper row's
sd/vgmis a correlated isochore (thickness) perturbation of the zone above it, clamped ≥ 0 and zero-masked where the base isochore is exactly 0.
By construction, ordering and exact merges survive every draw, so the structural field widens the in-place spread without ever producing an invalid geometry.
hz_struct = ps.Horizons(
ps.hz(man["horizons"][0], sd=12.0, vgm=("spherical", 2500.0)), # top-depth field
ps.hz(man["horizons"][1], sd=12.0, vgm=("spherical", 2500.0)), # zone-0 isochore
*[ps.hz(h) for h in man["horizons"][2:]],
zones=man["zones"], ties=ps.TieSettings(method="convergent"),
gridding=ps.Gridding(collapse=True),
)
grid_s = geom.build(hz_struct, layering=lay, collapse_negative=True, min_thickness_m=0.0)
model_s = grid_s.model(props, con, fluid="oil", fvf=1.30, gas_fvf=0.005)
mc_s = model_s.zoned_uncertainty(ps.Mc(contacts=4.0, n=128, seed=42))
# mc_s total spread is WIDER than the no-field control at the same seed.
Loud on a flat model
A structural field on a non-zoned (flat) model has no hook yet — the build
raises ps.NotYetSupported rather than silently ignoring it. No silent
degradation.
Distributions & charts¶
Chart bundles are computed here (deterministically, in Rust) and rendered by the viewer's Charts tab — the viewer fits and bins nothing.
dist_field = ps.Distribution().bundle(mc) # the field STOIIP distribution
dist_z4 = ps.Distribution(zone="Z4").bundle(mc) # a single zone
model.save_view("model.html", property="PORO", charts=[dist_field, dist_z4])
Tornado — the flat path¶
Tornado is flat-path only
Ranked sensitivity (tornado) lives on the flat (non-zoned) MC path.
Run model.uncertainty(...) for a whole-model MC, then build the tornado
bundle from it. The zoned run exposes per-zone distributions, not a tornado.
flat = model.uncertainty(ps.Mc(porosity=0.02, contacts=5.0, n=2000, seed=42))
tor = ps.Tornado(units="MSm³").bundle(flat) # ranked input swings
ps.aggregate([seg_a, seg_b], correlation="independent") rolls up several flat
segments into a field distribution; ps.distribution_bundle([a, b],
aggregate=..., names=[...]) overlays per-structure + field series in one chart.
Scenario derivation¶
A scenario is a derived spec — .replace returns a new value; the original is
untouched — so the same geometry yields N deterministic, differing models.
# Contacts scenario: a deeper OWC ⇒ more oil in Z2
con_b = con.replace("Z2", owc=man["contacts"]["owc_z2"] + 40.0)
model_b = grid.model(props, con_b, fluid="oil", fvf=1.30, gas_fvf=0.005)
# Horizon scenario: drop the deepest zone (and its base horizon)
hz_drop = hz.replace(rows=hz.rows[:-1], zones=hz.zones[:-1])
model_c = geom.build(hz_drop, layering=lay).model(props, con)
# Layering scenario: finer cells in every zone
lay_fine = lay.replace("Z*", dz=0.5)
Rebuilding from the same spec is bit-deterministic — the acceptance suite pins exactly this (two derived specs → two deterministic, differing models).
Durable scenarios¶
A whole scenario is a savable file. AssetSpec bundles structure + layering +
contacts + props into one value that round-trips through a
plain dict:
asset = ps.AssetSpec(name="demo",
horizons=hz, layering=lay, contacts=con, props=props)
assert ps.spec_from_dict(asset.to_dict()) == asset # durable, comparable
Next: inspect any of these models in the viewer.