User-defined quantities, lookup tables, and action rules
PetroScript models field-management logic as typed reservoir concepts. A
user-defined quantity (ps.actions.UserQuantity) names a
typed expression over summary quantities; a user-defined lookup table
(ps.actions.UserTable) interpolates one dimension in one of three closed
interpolation words; an action rule (ps.actions.When(condition, then=[...]))
applies supported schedule changes when its condition holds. Each is a change a
ps.Event dates, and the schedule keeps the order they are stated in. The compiler
owns the deck spelling, including the computed workspace dimensions when a
definition outgrows the simulator defaults.
From-scratch authoring
Section titled “From-scratch authoring”import petroscript as ps
oil_rate = ps.actions.field().oil_production_raterate_limit = ps.actions.UserTable( name="TU_LIM", interpolation="nearest", x=(100.0, 500.0), y=(100.0, 180.0),)margin = ps.actions.UserQuantity(name="FU_MARG", expression=rate_limit.lookup(oil_rate) - oil_rate)keep_open = ps.actions.When( margin >= 0.0, then=[ps.Open(well="PROD1")], name="KEEP_OPEN", runs=10, minimum_wait=ps.Quantity(30, "day"),)evaluation = ps.Numerics( user_quantity_magnitude_limit=1.0e6, user_quantity_undefined_value=0.0, user_quantity_equality_tolerance=1.0e-4,)
model = ps.Model( name="USER_DEFINED_CASE", units="field", start="2020-01-01", grid=ps.grid.Cartesian(shape=(3, 3, 1), dx=1000.0, dy=1000.0, dz=50.0, top=2500.0), rock=ps.Rock(porosity=0.2, permeability=100.0, vertical_ratio=1.0), fluid=ps.fluid.OilWater( oil=ps.pvt.DeadOil(pressure=[14.7, 3500.0], oil_fvf=[1.06, 1.0], oil_viscosity=[1.0, 1.2]), water=ps.pvt.Water( reference_pressure=3000.0, fvf=1.0, compressibility=3.0e-6, viscosity=0.5, viscosibility=0.0, ), oil_surface_density=53.0, water_surface_density=64.0, ), relperm=[ps.relperm.OilWater(sw=[0.20, 0.80], krw=[0.0, 1.0], krow=[1.0, 0.0], pcow=[0.0, 0.0])], initial=ps.initial.Equilibrium( datum_depth=2510.0, datum_pressure=3000.0, oil_water_contact=2600.0, gas_oil_contact=0.0 ), numerics=evaluation, wells=[ps.Well(name="PROD1", i=3, j=3)], schedule=ps.Schedule( report=[ps.Every(30.0, 30.0)], events=[ ps.Event( "2020-01-01", ps.Perforate(well="PROD1", k=(1, 1), diameter=0.3), ps.Produce(well="PROD1", oil_rate=150.0, bhp_limit=2500.0), rate_limit, margin, keep_open, ) ], ),)The lookup table lowers into the schedule UDT block with its nearest-value
interpolation token, the quantity lowers into a UDQ DEFINE record, the
rule lowers into an ACTIONX block, and the evaluation policy lowers into a
RUNSPEC parameter record with unset slots defaulted.
The most common trigger is a water cut, a fraction. A threshold is a bare number
in the model’s units, as the summary reports the quantity: a gas-oil ratio is
Mscf/stb in FIELD (shut above 2,000 scf/stb is > 2.0) and sm3/sm3 in METRIC
(Units and bare numbers). A well’s quantities are read
through the well itself, ps.actions.well(model.get("wells/PROD1")). A rule dated
at the model’s start is in force from the first day:
shut_wet = ps.actions.When( ps.actions.field().water_cut > 0.5, # one well: ps.actions.well(model.get("wells/PROD1")) then=[ps.Shut(well="PROD1")], name="SHUT_WET",)watered = model.at(model.start, shut_wet)assert "FWCT > 0.5" in ps.compile(watered).files["USER_DEFINED_CASE.DATA"]Assign, update, and the scope a name declares
Section titled “Assign, update, and the scope a name declares”A quantity is built from its records in order through
ps.actions.UserQuantity(name=..., statements=[...]). A row holding
expression binds the name: with stated_as_value=True it is one ASSIGN
record, binding a number, and otherwise one DEFINE record, binding a formula
over every member of the name’s scope. A row naming its members (well= on a
WU... name, group= on a GU... name) is an ASSIGN record whether it says
so or not, and binds a number to those members alone:
{"expression": 10.0, "well": "PROD1"} on WU_LIMIT writes
ASSIGN WU_LIMIT 'PROD1' 10. A formula on an ASSIGN row is refused
(udq.expression.invalid), and so is well= or group= on a name of another
scope (udq.name.scope_mismatch); an ASSIGN row naming no member binds the
deck’s '*', every member of the name’s scope. A row holding evaluated is one
UPDATE name ON|OFF record, whether the name is re-evaluated from here on (the
binding survives the switch). ps.actions.UserQuantity(name=..., expression=...)
stays the one formula case.
The name’s first letter says its scope and its second is U: FU... is
field, WU... well, GU... group, CU... completion, RU... region,
SU... segment, AU... aquifer, BU... block. Flow
evaluates field, group, well and segment quantities only, so a CU..., RU...,
AU... or BU... name is refused (udq.name.invalid, its fix line the field
quantity); and a formula reads every member of its own scope or one value, so
a field quantity over every group or every well, or a well quantity over every
group, is refused (udq.expression.invalid, its fix line naming the group
FIELD, or stating a quantity over every well as a well’s).
quantity = ps.actions.UserQuantity( name="FU_MAXO", statements=[ {"expression": 2500.0, "stated_as_value": True}, # ASSIGN FU_MAXO 2500 {"evaluated": False}, # UPDATE FU_MAXO OFF ],)Evaluation policy
Section titled “Evaluation policy”ps.Numerics’ three user-quantity settings are the field-wide policy for
evaluating user-defined quantities. Each is optional, and the schedule must
define at least one quantity:
user_quantity_magnitude_limit— absolute value range for evaluated quantities, between1and1.0e20(udq.policy.magnitude_limit.invalid);user_quantity_undefined_value— value assigned to undefined quantities, finite and within the magnitude limit (udq.policy.undefined_value.invalid);user_quantity_equality_tolerance— floating-point equality tolerance, strictly between0and1(udq.policy.equality_tolerance.invalid).
Attaching a policy to a schedule without quantities raises
udq.policy.requires_quantities.
Repeating action rules
Section titled “Repeating action rules”ps.actions.When(condition, then=[...], name=..., runs=..., minimum_wait=...)
bounds how a rule repeats: runs caps the number of successful executions, and
minimum_wait (days, or ps.Quantity(..., "day")) is the minimum time between
the condition being satisfied and the rule executing again. minimum_wait must
be a non-negative finite duration (action.min_wait.invalid).
runs=1 states one execution, and the rendered header writes it. Left out,
runs says nothing: When.runs is None is a header record that never
wrote the cell, which is also what an imported deck that left it open comes
back as. The two run identically — one execution either way — and they are not
the same record, because the rule’s header is part of the image the restart
keeps in IACT/ZLACT.
A rule that rebuilds the group tree
Section titled “A rule that rebuilds the group tree”A body may state a ps.Group and a ps.wells.GuideRate, each taking effect
when the rule fires (OPM Flow reads GRUPTREE and WGRUPCON inside ACTIONX). A
group the body creates is not in Model.groups, since the start’s tree does not
hold it, and a well the body regroups into it is a restated ps.Well in the
same body. actionx/ACTIONX_GRUPTREE builds a production tree and an injection
tree this way.
The scope a rule body stated
Section titled “The scope a rule body stated”A rule body may wrap grid-edit cards in BOX/ENDBOX. Those cards state no
reservoir quantity: they move the input box that the card between them reads,
and that card carries the box into its own event — so the body’s EFFECT does
not need them, and an authored body never states them.
The rule’s stored image does need them. The image is the records between the
rule’s header and its close, counted one for one, so a scope card occupies a
record even though it appends no event. When.stated_body_scopes holds
what an imported body stated: one (card, position, slots) entry per scope
card, where position is the number of body statements that stood before it
and slots are the six bounds the card wrote — None for a slot the deck
defaulted, which comes back as 1* rather than as the bound it resolves to.
The closing card states no record and carries None.
The image counts KEYWORDS as well as records, and
When.stated_body_cards holds how the deck grouped its body statements
into cards: one (keyword, position, count) entry per card holding more than
one record, where position is the number of body statements that stood
before it. Written back as N keywords of one record each, a body keyword
stating N records would add N-1 lines to the image — enough to
move INTEHEAD[157] and IACT’s per-action line count, both of which carry
the longest body’s line count plus one.
Note what this carrier is not. It is not “fold adjacent statements that lower to the same keyword”: three decks in the corpus state two adjacent cards of the same keyword inside one body, and folding those would delete two records from the image exactly as splitting invents two. Only the deck’s own grouping distinguishes them, and the reader takes it by keyword-object identity rather than by keyword name for precisely that reason. A card holding one record is not carried, because one record already renders as one keyword; an authored body carries nothing and renders as it always did.
An authored rule carries () and renders exactly as it always did, as does
an imported rule whose body stated no box. A carried scope is faithful
until touched: at every render, each card standing inside a stated box is
checked against that box, and a body that has since been edited past what the
scope explains stands the carrier down rather than replaying a statement that
is no longer true of the model.
Interpolation modes
Section titled “Interpolation modes”A table’s interpolation is one of three closed words: "linear_clamped"
(linear inside the table, clamped at the ends), "nearest" (nearest tabulated
value), and "linear_extrapolated" (linear inside the table, extrapolated
beyond it).
Raw deck tokens are compiler internals and are rejected.
Python hooks
Section titled “Python hooks”ps.Event(date, ps.actions.PyAction(name="WTEST", path="wtest.py", run_mode="every_timestep")) states a Python hook the run calls from that date
(PYACTION): its name, the file Flow runs (required: Flow gives it no default)
and how often it runs, "once", "every_timestep" or "until_true". OPM Flow
rejects integer run counts, so no other values are accepted; PetroScript never
imports or executes the file.
Workspace dimensions
Section titled “Workspace dimensions”A model states no sizes unless it wants to. When a quantity definition needs
more operator or operand slots than the simulator’s default workspace, the
compiler computes the RUNSPEC UDQDIMS record from the scheduled definitions;
small models write none. A model that reserves more states it on
ps.Capacity: functions_per_user_quantity, terms_per_user_quantity and the
per-scope counts (field_user_quantities, well_user_quantities and the like)
write UDQDIMS; user_arguments and distinct_user_arguments write
UDADIMS; user_tables, rows_per_user_table and
interpolation_points_per_user_table write UDTDIMS. The compiler never
computes the last two cards: they are written only when the model states them.
PetroScript is licensed under the Apache License 2.0. © 2026 Blauweiss EDV LLC. PetroScript by Blauweiss AI.For AI agents: llms.txt