Skip to content

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.

import petroscript as ps
oil_rate = ps.actions.field().oil_production_rate
rate_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
],
)

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, between 1 and 1.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 between 0 and 1 (udq.policy.equality_tolerance.invalid).

Attaching a policy to a schedule without quantities raises udq.policy.requires_quantities.

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 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.

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.

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.

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.

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