Skip to content

Build a model from a bundle of tables

A bundle is one directory that the studies of a reservoir write and its model is assembled from, with fixed names and fixed shapes, so the deck that comes out of it is a pure function of the bundle and a small recipe. This page is the door: what the directory holds, how a sheet is written so the bytes never depend on who wrote it, how a bundle opens, and what refuses at the door. PetroScript owns the sheet schemas.

<workspace>/sidecars/recipe.json the one file you edit: how to assemble, v0.1
<workspace>/sidecars/tables/<sheet>.parquet one sheet per kind, units per column
<workspace>/sidecars/grid/<NAME>.GRDECL deck-native arrays, write(read(bytes)) == bytes
<workspace>/sidecars/schedule/SCHEDULE.INC rendered by machinery, never hand-typed

The sheets of v0.1: wells, history_panels, tops, zonation, contacts, contact_depths, completions, faults, schedule_events, forecast_streams (the results sheet, simulated volumes), prior_production (what flowed before the history panel’s first month), and one sheet per PVT and SCAL keyword family (pvt_pvto, pvt_pvtg, pvt_pvdg, pvt_pvtw, pvt_density, pvt_rock, scal_swof, scal_sgof, scal_swfn, scal_sgfn) with the manual’s item names as columns. A shared mu column could not carry per-kind units under FIELD; one sheet per family can. contact_depths is what the petrophysics stage measures, a depth bracket per contact (owc, goc, gwc) with its evidence and citation; contacts is what the reservoir engineer assembles from it for EQUIL, per region with its datum. contacts@0.2 states the water contact under its own name: gwc_tvdss for a gas-water region, owc_tvdss (with goc_tvdss when there is a gas cap) for an oil region. A row never states both water contacts (bundle.contacts.two_water_contacts, at write and at the door), and the recipe’s phases decide which one the door accepts: a gas-water recipe refuses a row with no gwc_tvdss (bundle.contacts.gwc_missing) or with an oil contact (bundle.contacts.oil_contact_in_gas_water); a recipe with oil refuses a gwc_tvdss (bundle.contacts.gwc_in_oil_system) and a row with no owc_tvdss (bundle.contacts.owc_missing). contacts@0.1 stays beside it, unchanged; a recipe naming bare contacts on an unstamped file is ambiguous and says so. contact_depths@0.2 keys a bracket on the contact and its equilibration region (eqlnum, as contacts numbers it), so a second region states its own owc where 0.1 refused it as a repeated key, and a region is numbered from 1 (bundle.contact_depths.eqlnum); tops@0.2 adds the propagated top’s spread, low and high, and a low above its high refuses (bundle.band.order). Their 0.1 versions stay registered, unchanged. A tops interpretation writes its tops with the band beside them (the shallowest and deepest estimate), which tops@0.2 takes as one sheet joined on (well, horizon), and a contact pick writes one bracket per region it was run over, which contact_depths@0.2 keys on that region. The door reads them as it reads any sheet: a primitive’s bracket written with no eqlnum refuses bundle.column.missing under 0.2, and a band written as elevations, upward, reads inverted and refuses bundle.band.order, since every tvdss is a depth positive downward.

Beside them sit the sheets no deck is built from, which the assembler never reads: givens (the numbers and facts a later step reads by name, with how each is known), deliverable_spec (what the study must deliver, by whom) and surfaces (a mapped horizon, one row per node, with its frame on every row); they are described under The givens, the deliverable spec and the surfaces.

Every sheet goes through one pinned writer. It sorts the rows by the sheet’s declared key, writes one fixed row-group size, and puts the key, the units of every numeric column and the schema id into the file’s metadata. The same data gives the same bytes whatever order the rows came in, which is what lets the bucket be a git repository. Every registered sheet is built from its schema before polars sees the rows, so a value’s bytes are a function of the value alone, never of the rows beside it, and a json object built from a polars struct may not carry a null-valued key, since the struct’s padding and a stated null look alike (it refuses bundle.column.dtype, naming the first null key’s path, such as params.thp_limit; give such objects as raw records or as canonical JSON text, which keep a stated null).

import petroscript as ps
wells = {
"well": ["P2", "P1"],
"x": [1200.0, 1350.0],
"y": [800.0, 815.0],
"ref_depth": [2500.0, 2510.0],
"group": ["G1", "G1"],
"phase": ["OIL", "OIL"],
"type": ["producer", "producer"],
}
path = ps.io.write_sheet(
wells,
path="ws/sidecars/tables/wells.parquet",
key="well",
units={"x": "m", "y": "m", "ref_depth": "m"},
schema="wells@0.1",
)
assert path.read_bytes() == ps.io.write_sheet(
{name: list(reversed(values)) for name, values in wells.items()},
path="ws/sidecars/tables/wells.parquet",
key="well",
units={"x": "m", "y": "m", "ref_depth": "m"},
schema="wells@0.1",
).read_bytes()

A key that does not tell every row apart is refused by the writer, because a sheet two rows of which cannot be told apart is not a sheet; under a schema the refusal is the door’s own, typed (bundle.key.duplicate: the schema id, the key columns, and the first repeated keys with the rows that state them); so is a key column the rows lack (bundle.column.missing, naming it), where a write without a schema answers in the writer’s own words. The writer takes three forms: a polars frame, a mapping of columns (each a list, a tuple, a numpy array or a polars Series) or a list of records (dicts). Anything else (a pandas or arrow frame, named tuples, dataclasses or pydantic models, a generator, a lone Series, a scalar or a set where a column belongs, columns repeating a name) is refused by name with one fix, wrap it: pl.DataFrame(x) (bundle.frame.form under a schema, the writer’s own ValueError without one); polars builds a frame from every one of them and reads a pandas blank (NaN, <NA>, NaT) as null. Records and plain columns stay Python values until the schema reads them, a numpy number or bool is its Python value, and numpy scalars inside json objects are spelled as their Python numbers (a numpy array there as its list). path is keyword-only like every PetroScript door. Rows that do not build one frame (a column holding numbers in some rows and texts in others) answer the writer’s own ValueError, naming the file, never a polars error. Column names are English, never deck mnemonics.

With schema="wells@0.1" the writer runs the door’s checks before a byte is written: every schema column present and no other, dtypes, no NaN and no infinity in a numeric column (null is the spelling for absent), nullability, a unit on every numeric column that has one, all in one unit system, the vocabularies, the key. A refusal is the same BundleRefusal the door raises, with the same reason code, and no file is left behind, so nothing unvalidated reaches a journal. Without schema= the writer stamps nothing and validates nothing, as before.

A name the deck carries between quotes — a well’s, a group’s, a fault’s — is one line with no quote mark (bundle.name.one_line: no control character, no line or paragraph separator, no ' or "; a line break would split the record and a quote mark end the string early, leaving the rest as deck text of the caller’s own), and a completion’s date is a calendar date, YYYY-MM-DD (bundle.completion.date). The evidence sheets keep what the documents say.

The recipe’s title and selected_deck are one line too (bundle.recipe.one_line, at the door): the title becomes the deck’s TITLE line and the path’s stem the model’s name. The law sits on the model’s own name and title, so every route to a model meets it — the factory, the builder, an imported deck, generated code; a title may carry a tab, which breaks no line and states no keyword, and a name may not.

Every depth in a bundle is a depth, positive downward, like the grid’s own: a tvdss value (gwc_tvdss, datum_tvdss, a top’s tvdss) is metres (or feet) below mean sea level as a positive number — a contact 3320 m below sea level is 3320, never -3320 — and TVDSS is true vertical depth MINUS the depth reference’s elevation above mean sea level (the derrick floor or rotary table height), never plus. top_md and base_md are measured depths along the hole. The wells sheet’s ref_depth is the depth at which the well’s bottom-hole pressure is stated (every bhp target and bhp_limit refers to it); it need not lie inside the grid, and it is never the derrick’s elevation nor a pressure floor. Each schema’s doc says its own part of this, since the doc is what a sheet’s writer reads.

A well is named in eight characters, in every sheet a deck is built from

Section titled “A well is named in eight characters, in every sheet a deck is built from”

The well column is the deck’s name in every sheet a deck is built from (wells, completions, the panels, the events, the forecast streams). The evidence sheets — perforations, tops, zonation, which state what the documents say and which the assembler never reads — keep the documents’ own names (WELL-A-101, a sidetrack WELL-A-101-S2): a deck name invented inside evidence merges boreholes the documents tell apart. Eclipse names a well in eight characters and a simulator’s results truncate a longer one, so WELL-A-101 … WELL-A-106 all come back as WELL-A-1. A longer name refuses at write and at the door (bundle.wells.name_length: the name, its length, the names that would collide): state an 8-character deck name, and put the bundle’s own spelling in source_name (wells@0.2).

A unit word is read the way it is commonly spelled, and one refusal names every column

Section titled “A unit word is read the way it is commonly spelled, and one refusal names every column”

A stated unit is folded before it is judged: case, spaces, a METRIC: or FIELD: prefix (the spelling a schema listing teaches), and on a monthly sheet a per-month suffix (1000 Sm3/month, the bundle’s own spelling, is 1000 sm3; prior_production states a cumulative, which has no period, so a per-month word there is a mismatch); the file is stamped with the schema’s own word. A prefix that names the other system’s word (FIELD:sm3) is a mismatch. A unit refusal states every failing column at once (columns beside column), each with the token to send, the systems as labels and what was sent — column 'oil': send units.oil = "sm3" (METRIC) or "stb" (FIELD); "/month" allowed; you sent 'METRIC' — bundle.unit.mismatch when any column stated a word, bundle.unit.missing when none did.

A nullable column is optional on write; a json column takes an object

Section titled “A nullable column is optional on write; a json column takes an object”

The writer fills what a caller may not know: an absent nullable column (owc_tvdss on a gas region, an event’s end, a note, a confidence) is written as null, with its unit (the word of the system the stated units name, METRIC by default) put in the metadata beside the others, so a sheet never refuses bundle.column.missing for a column whose value is “not reported”. A json column (params) takes an object or a list as well as a string; the writer serialises it as canonical JSON, so a caller states {"bhp_limit": 120, "bhp_limit_units": "barsa"} and never a string of it. The door’s checks follow on the filled frame.

ps.bundle.open reads the recipe, checks it, and hands back the bundle. A sheet is validated against its registered schema the first time it is read, then held. Bundle.sheet returns a polars frame; .to_pandas() is one call away for anyone who works in pandas.

import json
from pathlib import Path
side = Path("ws/sidecars")
(side / "grid").mkdir(parents=True, exist_ok=True)
(side / "grid" / "GRID.GRDECL").write_text("-- geometry\n")
ps.io.write_sheet(
[
{"well": "P1", "start": "2020-03", "class": "shut_in", "end": None,
"evidence": "reported", "confidence": 0.9, "source_citation": "annual report p.3",
"params": "{}", "note": None, "conflict": None},
],
path=side / "tables" / "schedule_events.parquet",
key=("well", "start", "class"),
)
(side / "recipe.json").write_text(json.dumps({
"recipe_version": "0.1",
"units": "METRIC",
"title": "example",
"start": "2020-01-01",
"history": {"from": "2020-01", "to": "2021-12"},
"forecast": {"horizon_months": 12},
"phases": ["oil", "water"],
"selected_deck": "decks/example.DATA",
"grid": {"geometry": "sidecars/grid/GRID.GRDECL", "properties": [], "regions": []},
"sheets": ["wells@0.1", "schedule_events"],
"schedule": {"history_control": "ORAT", "step": "monthly", "forecast_controls": "from_events"},
"options": {},
}))
bundle = ps.bundle.open("ws")
assert bundle.units == "METRIC"
assert bundle.sheets == ("wells@0.1", "schedule_events")
assert bundle.sheet("wells")["well"].to_list() == ["P1", "P2"]
assert bundle.schema("schedule_events").id == "schedule_events@0.1"

The recipe is v0.1: recipe_version, units (METRIC or FIELD, one per recipe), title, start (a date, YYYY-MM-DD), history (from and to, months as YYYY-MM), forecast (horizon_months), phases, selected_deck, grid (geometry, properties, regions, and optionally for_grid, below), sheets, schedule (history_control, step, forecast_controls) and options. ps.bundle.recipe_schema() is that table: every key with its type, whether it is required, one line of meaning and an example, plus one minimal recipe that assembles the gas-water fixture, exported as docs/reference/bundle/RECIPE.json beside SCHEMAS.json under the same pair law. The door checks the whole recipe against it before any assembler code runs, so no recipe fault reaches the assembler as a Python exception: a required key that is missing refuses bundle.recipe.missing_field naming the dotted path, the type and an example; a wrong type bundle.recipe.field_type; a date or month that is not YYYY-MM-DD or YYYY-MM, or a history window that ends before it starts or opens before the first month the deck renders whole (the start’s, or the next after a mid-month start; the refusal also names the start that would carry it), bundle.recipe.date; an unknown key at any depth bundle.recipe.unknown_field naming the path and what that level knows (an unknown forecast.* key points at where forecast controls live: they are schedule_events rows, never recipe keys); schedule.step and schedule.forecast_controls outside their one word each bundle.recipe.vocabulary. Then: a sheet named but absent refuses naming the sheet; a grid file named but absent refuses naming the file; a PVT or SCAL sheet whose phase the recipe does not state refuses naming both; schedule.history_control is one of the manual’s WCONHIST words (ORAT, WRAT, GRAT, LRAT, RESV) or by_phase.

table = ps.bundle.recipe_schema()
assert {row["path"] for row in table["keys"] if row["required"]} >= {"start", "history.from", "forecast.horizon_months"}
assert table["example"]["units"] == "METRIC" and table["example"]["forecast"] == {"horizon_months": 12}

A wrong column, a missing unit, a unit that disagrees with the recipe’s system, a word outside a fixed vocabulary, a null in a required column, a NaN or an infinity in any numeric column (a blank an xlsx reader turned into NaN is stated as null, never written as NaN; bundle.column.nan, bundle.column.nonfinite), a key that repeats, a history month outside the window, an unresolved conflict row in schedule_events, or a bare number inside an event’s params: each refuses before any deck is written, and each refusal is a typed record.

ps.io.write_sheet(
wells,
path=side / "tables" / "wells.parquet",
key="well",
units={"x": "ft", "y": "m", "ref_depth": "m"},
)
try:
ps.bundle.open("ws").sheet("wells")
except ps.bundle.BundleRefusal as refusal:
assert refusal.reason == "bundle.unit.mismatch"
assert (refusal.sheet, refusal.column) == ("wells", "x")
assert (refusal.given, refusal.expected) == ("ft", "m")
assert refusal.kind == "unit"
record = refusal.as_record()

reason (the code), kind (its class: unit, column, key, vocabulary, shape, reference, range, grid, recipe or schedule), schema, sheet, column, columns, row, given (what was sent) and expected are fields, never a sentence to parse, and the sentence names what was given and what was expected too (file is a field too: the sentence names the sheet’s file alone, never the directory a host keeps it in), so a refusal about a sheet is by construction a question to whoever produced it. Every numeric value inside schedule_events.params carries its units ({"control_mode": "BHP", "bhp_limit": 120, "bhp_limit_units": "barsa"}); a bare number refuses naming the row.

ps.bundle.SCHEMAS holds one SheetSchema per kind: its id (wells@0.1), its columns with dtype, units per unit system, nullable and key flags, and the fixed vocabularies, reachable through ps.bundle.export_schemas()["vocabularies"] by name (schedule_events.class, recipe.schedule.history_control, the well phases and types, the fault faces). A kind may carry more than one version: history_panels@0.1 is the long shape (one row per well, month and stream, the unit stated per row) and history_panels@0.2 the wide one (one column per stream, each with its own unit per system); a recipe names the id, or the file carries it, and a kind with several versions refuses a bare name rather than guess; zonation@0.2 beside zonation@0.1 and wells@0.2 beside wells@0.1 are the other such pairs (wells@0.2 adds one nullable column, source_name: the bundle’s own name for the well as its production panel spells it, WELL-A-101, null when the deck name is the bundle’s own. It records the source’s name and nothing reads it: history sheets name each well by its deck name, so map source names to deck names before writing them; a history row under the source name refuses: bundle.wells.name_length for a name longer than eight characters such as WELL-A-101, bundle.schedule.well_unknown for a short one), and so are tops@0.2 beside tops@0.1, contact_depths@0.2 beside contact_depths@0.1 and deliverable_spec@0.2 beside deliverable_spec@0.1. The vocabularies include units, every unit word in one place (One place for every unit word). The same registry is exported as docs/reference/bundle/SCHEMAS.json by python tools/export_bundle_schemas.py, committed and kept equal to the registry by a test, so another tool reads the same schemas without importing PetroScript. Bundle.schema(kind).id is the id a sheet records.

assert "wells@0.1" in ps.bundle.export_schemas()["schemas"]
assert len(ps.bundle.export_schemas()["vocabularies"]["schedule_events.class"]) == 16

ps.bundle.assemble(bundle) enters the schedule into the model as statements, and the compiled deck’s SCHEDULE section is the timeline, from the first history card to the end.

The history window is history_panels, in either shape, as ps.History and ps.InjectionHistory statements: one WCONHIST or WCONINJH per well and month, monthly volumes divided by the calendar month into daily rates, explicit SHUT rows for zero months and gap months. The recipe’s schedule.history_control is WCONHIST’s control word for every producer (ORAT, WRAT, GRAT, LRAT, RESV), or by_phase, each well’s own phase word from the wells sheet.

The panel runs from the model’s start month to history.to, and every month of it renders: history.from is the first month the model is matched on, not the first it simulates, so the state at history.from carries every month simulated before it, by the simulator’s own material balance. The panel is the monthly record’s own cells. What flowed before the record’s first month is a sheet of its own, prior_production@0.1: one row per well, dated at the panel’s first month, each stream’s cumulative before it in the wide panel’s columns and units. The deck carries each row at one daily rate over the months from the model’s start to the panel’s first month in which the completions sheet has the well open (the panel’s own whole-month rule), so a model started at first production reaches that month in the depleted state; a row with a volume on a well no completion opens in those months refuses bundle.schedule.well_closed. A row names a well of the wells sheet, never FIELD (bundle.schedule.well_unknown): a field total is split per well by whoever states the split, as that split’s own statement. A row dated at another month, a model that starts at the panel’s first month with no months to carry it over, or a panel with no rows, refuses bundle.panel.window. A panel row before the start or after history.to refuses bundle.panel.window too, and a row before the start names the three routes: start at the panel’s first month (which carries the panel’s own production only), a prior_production sheet with the model started at first production, or the start kept, with the panel from the start’s month on and an initial state that is itself the depleted one, a datum_pressure measured at the start. After a start mid-month, the start’s month here is the month after, the first the deck renders whole. A panel that opens after the start stands every well SHUT until it. A history.from before the first month the deck renders whole refuses bundle.recipe.date, naming that month and the start at or before history.from that would carry it.

The forecast window is schedule_events as controls only, one DATES card per month over forecast.horizon_months after history.to, and a closing date so the last month is simulated:

classcard
shut_in, plug_abandon, test, injection_stopWELOPEN ... SHUT (never STOP)
restartWELOPEN ... OPEN
target_change, choke_changeon a producer WCONPROD under BHP (bhp_limit alone), or under one stated rate target — gas_rate (GRAT), oil_rate (ORAT), water_rate (WRAT), liquid_rate (LRAT), per day — with bhp_limit beside it as the floor, or under a THP limit through the well’s lift table (thp_limit with lift_table: the table’s VFPPROD, and the limit alone or beside a rate target); beside it WECON (min_gas_rate), WTEST (retest_days) and WEFAC (uptime); on an injector WCONINJE under BHP; params must state bhp_limit and bhp_limit_units, or thp_limit and thp_limit_units with lift_table
injection_start, injection_changeWCONINJE with the disclosed rate (rate_units, phase) and, or, a bhp_limit
curtailmentGCONPROD on the named group with oil_limit, gas_limit, water_limit or liquid_limit, each with its units twin (oil_limit_units and so on)

params is the flat spelling of a schedule event’s settings: control_mode (BHP or THP; beside a producer’s rate target, the mode it selects or nothing), bhp_limit and bhp_limit_units, thp_limit and thp_limit_units with lift_table, a producer’s one rate target with its units twin (gas_rate and gas_rate_units), a producer’s operations (min_gas_rate, retest_days, uptime, each with its units twin), and for the injection classes rate, rate_units and phase. A numeric key without its units twin refuses at the door (bundle.schedule.params.units_missing); a units word from the other unit system refuses at the render (bundle.schedule.params.units_mismatch), and so does a rate word for the other stream (FIELD states a liquid rate in stb/day and gas in Mscf/day, so a gas_limit in stb/day refuses by name instead of reaching a conversion the units module has no factor for); a word neither system knows refuses too (bundle.schedule.units_unrendered). A pressure is stated absolute, bara or barsa in METRIC and psia in FIELD, because the schedule writes an absolute pressure: a bar, bars or psi states no reference and a barg or psig is a gauge pressure, and each refuses by that name (bundle.schedule.units_unrendered, with its own sentence) rather than be read as absolute or given an atmospheric offset nobody stated. Each class reads a fixed set of numeric keys, bhp_limit, thp_limit, a rate target and the three operations on a target or choke change (with lift_table, the one number that takes no units twin), rate and bhp_limit on the injection classes, one of oil_limit, gas_limit, water_limit or liquid_limit on a curtailment, none on a shut, test, plug, injection stop or restart; any other number in params refuses at the door (bundle.schedule.params.key_unrendered) rather than being dropped, and a rate on an injector’s target change refuses by the same name, since a target change renders the BHP limit only. An event’s end matters only for a state: a shut_in, test, plug_abandon or injection_stop is undone at its end (a WELOPEN OPEN that month), and an empty end persists through the forecast; an end past the horizon holds the state through the forecast with no undo card, and the event’s comment line says so (the idiom a control spells with the last month, stated more honestly); a state that started and ended inside the history window is history’s business, the panel is the record there, so it leaves only its comment line, worded “within the history window; carried by the panel”, in the month it started (the deck’s first month when it started before the model), and no card, its start never clamped; an end before its own start refuses at write (bundle.event.window); an end on restart, or on any control class, is refused (bundle.schedule.end_unused), with one exception: the forecast horizon’s last month, a sheet’s idiom for “through the forecast”. A state whose start lies inside the history window holds from the forecast’s first month, since the panel is the record before it; a control dated before the forecast’s first month refuses (bundle.schedule.event_in_history), naming that month: restate it there if it holds into the forecast, or leave it out. A well of the wells sheet with no panel row in the window is stated SHUT through the history, since a history well without a card would be left open. Evidence and confidence are not judged: every stated limit renders, and evidence, confidence and source_citation are written as a comment line above the card, so the deck says where the number came from.

workover, recompletion, stimulation, new_well, transfer and allocation_change refuse by name in this version (bundle.schedule.class_unrendered). Every other refusal is typed the same way: a control before the forecast (bundle.schedule.event_in_history), an event beyond the horizon (bundle.schedule.event_beyond_horizon), a limit not stated at all (bundle.schedule.limit_missing), a well or group the wells sheet does not know (bundle.schedule.well_unknown, bundle.schedule.group_unknown), a panel volume in a word the recipe’s system does not state that stream in (bundle.panel.units_mismatch, naming the system and the word the row carries) or in a word neither system knows (bundle.panel.units_unrendered), and an unresolved conflict row refuses the bundle at the door. A panel volume is read in the word WCONHIST and WCONINJH read it in per day: sm3 or 1000 sm3 in METRIC, stb for oil and water and Mscf for gas in FIELD. A producer’s rate target is a stated row like any other control: the event’s evidence, confidence and citation ride above its card, the rate is per day (sm3/day or 1000 sm3/day; stb/day for a liquid, Mscf/day for gas; a per-month word refuses naming the tokens, since a control holds over months of different lengths), two targets refuse (bundle.schedule.target_ambiguous), a stated control_mode must be the one the target selects (bundle.schedule.control_mode), and a rate without its bhp_limit refuses (bundle.schedule.limit_missing). A producer may state one rate target in the forecast, with bhp_limit as its floor: a BHP-only forecast cannot state the level a field is produced at (in one test a 5.8-fold jump at the history’s end). Beside the direct target stands the group route — a field or group rate is stated as a curtailment event with gas_limit (or oil_limit, water_limit, liquid_limit) and renders as GCONPROD — and a rate on a producer that is misspelled names both routes in its refusal. Whether a forecast merely echoes its history, and whether a target cites its evidence, are judgements the door leaves to its reader.

WCONHIST
'P1' 'OPEN' 'GRAT' 0 0.3225806451612903 9677.41935483871 1* 1* 1* 1* /
/
DATES
1 'FEB' 2020 /
/
...
DATES
1 'APR' 2020 /
/
WCONPROD
'P1' 'OPEN' 'GRAT' 1* 1* 1e+20 1* 1* 120 /
/

In a gas-water model a producer’s pressure control is written as above: a gas-rate target with no limit (1e+20, the simulator’s own number for none) and the stated pressure as its floor. With oil in the phases the same row is 'P1' 'OPEN' 'BHP' 5* 120 /. The two run to the same production to the last digit, and the first is the one the pinned Flow runner survives: under a pressure target alone a gas-water producer that starts to flow from zero well rates — re-opened after a shut-in, opened by its first forecast control after a history month that reported nothing, or handed a fresh control after it stopped flowing on its own — ends the run with a segmentation fault (“Infinite primary variable after update from wellState”; nine runs of one test series). No reading of the schedule sees the third case coming, so every such control is written this way. The rate slot echoes nothing: it is no limit, PetroScript’s own overdraw rule reads it as no volume stated, and a shut-in and a restart stay WELOPEN.

The timeline is statements: history statements, dates and controls, naming the wells of the wells sheet, which the deck writes card for card.

A THP floor through the well’s lift table

Section titled “A THP floor through the well’s lift table”

A gas well flows while its bottom-hole pressure can lift the column against the tubing-head pressure, and Flow turns a THP limit into a bottom-hole pressure only through a lift table. So a producer’s thp_limit renders only beside lift_table, the table_id of a lift_tables@0.1 table built for that well: the deck carries the table’s VFPPROD (at the head of the include, so SCHEDULE.INC carries it too) and the producer’s WCONPROD states the THP limit and the table number. The limit is the control alone (THP; with a bhp_limit beside it either may be named in control_mode, and the other binds beside it) or a floor beside a rate target, and bhp_limit is optional beside it:

VFPPROD
3 2400 'GAS' 'WGR' 'OGR' /
...
WCONPROD
'P1' 'OPEN' 'THP' 1* 1* 1* 1* 1* 1* 30 3 1* /
/

A lift_table may also be named beside a rate target or a bhp_limit with no thp_limit: the table is bound under that control with no THP limit, and the run reports the well’s tubing-head pressure through it.

A later control row of the well names its lift_table again. A control row replaces the well’s whole WCONPROD row, lift table included, so a row naming none would drop the table, and the run would report no tubing-head pressure after it (measured on Flow 2025.10: WTHP 20 bar under the table, 0 after a BHP-only row); such a row refuses by name (bundle.schedule.lift_table_missing, naming the table to restate), as the model refuses any control that drops a lift binding. Beside a bhp_limit or a rate target the restated table stays bound under that control.

A lift table needs three phases: the pinned Flow (2025.10) computes a tubing’s bottom-hole pressure with two phases only for oil and water, and a gas-water deck with a lift-bound producer stops at the first well potential (“Two-phase VFP calculation only supported for oil and water”), measured, while the three_phase and gas_condensate families run the same control to the end. So a thp_limit or a lift_table in a gas-water model refuses by name (bundle.schedule.lift_two_phase: state the reservoir with oil, gas and water, or a bhp_limit), before any other lift refusal, so the first fix named is the one that holds. A THP limit with no lift_table refuses by name (bundle.schedule.lift_table_missing, with the fix: name the well’s table, or state a bhp_limit instead), and so does a table number the recipe’s lift_tables sheet does not state or states for another well (bundle.schedule.lift_table_unknown). An injector’s lift table (VFPINJ) is not stated through this door: a THP limit on an injector refuses (bundle.schedule.params.key_unrendered), the injection classes read no thp_limit, and a control_mode other than the one their rate or bhp_limit selects (RATE or BHP; THP among the others) refuses (bundle.schedule.control_mode) rather than drop.

lift_tables@0.1 states a gas well’s table long, one row per point of its grid: table_id, the axes gas_rate (per day), thp, wgr (water-gas ratio) and ogr (condensate-gas ratio, 0 for a dry gas), and bhp, the bottom-hole pressure the tubing needs there, stated at datum_depth (a TVDSS, positive downward, like the wells sheet’s ref_depth). Each axis is the distinct values its column states within the table, and every combination states one row (bundle.lift_tables.body names the count and the axes where one is missing); a table is one well’s at one datum and is numbered from 1 (bundle.lift_tables.table); a rate or ratio below 0, or a thp or bhp at or below 0, refuses bundle.lift_tables.range; its well is a producer the wells sheet states with phase GAS (bundle.lift_tables.well: the axes are a gas well’s, and an oil well’s GOR would fall off the OGR axis without a word from the simulator; an oil well’s table, on liquid rate, water cut and GOR, would be a new version). The numbers are the deck’s own units, so the table carries no unit system of its own.

A thp_limit off the bound table’s THP axis refuses (bundle.schedule.params.range), since Flow would extrapolate the table without a word; a drawn one refuses in the member that draws it, so keep the draw’s low and high on the axis.

assert "lift_tables@0.1" in ps.bundle.SCHEMAS
assert "lift_table" in ps.bundle.SCHEMAS["schedule_events@0.1"].doc

Three operations ride on a producer’s control row, each an existing verb’s card beside the WCONPROD in the same month: min_gas_rate (a gas rate per day, in the rate words above) is the rate below which the well is shut (ps.wells.Economics, WECON); retest_days (days) retests a well shut physically or economically at that interval and reopens it when it can flow (ps.wells.WellTest, WTEST, reasons P and E); uptime (none, a fraction above 0 and at most 1) is the fraction of the time it is on stream (ps.wells.Efficiency, WEFAC). Each stays in force in the months after, until a later row of the well restates it: the simulator keeps a well’s last WECON, WTEST and WEFAC. A THP floor with no reopen rule forecasts zero after the first load-up; the operator restarts loaded wells, and retest_days is how the deck says so. A word outside those refuses (bundle.schedule.units_unrendered), an uptime above 1, or a min_gas_rate, retest_days or thp_limit at or below 0 refuses bundle.schedule.params.range, and the operations on an injector refuse by name. A numeric key its class reads, stated as a JSON string ("30" for 30), refuses bundle.event.params at the row check: state it as a JSON number.

tubing@0.1 is the tubing a lift producer builds a well’s lift table from: per well, one row per interval of one size (from_md, to_md, measured depths), its inner_diameter and roughness (null = not reported) in the sheet’s length unit, and the source_citation that states it. The deck never contains tubing, only the lift table built from it: the assembler reads the sheet through the door when the recipe names it, checks every well against the wells sheet (bundle.tubing.well), and renders nothing from it. A from_md below 0, an interval that does not run downward, a bore at or below 0, a roughness outside [0, bore) and an interval that does not start where the one above it ends (an overlap or a gap) refuse bundle.tubing.interval.

assert {"tubing@0.1", "well_pressures@0.1"} <= set(ps.bundle.SCHEMAS)

ps.bundle.assemble(bundle) returns a Model, never a deck: the model is what an engineer inspects, edits and compiles, and ps.compile(model) writes the sealed deck, one self-contained file. Every number the model carries is one a sheet or a grid file states; the assembler defaults nothing from a seed, and where a statement it needs is not there it refuses by name.

  • The grid files are read into the model and render inline. The geometry is a corner-point file (SPECGRID, COORD, ZCORN, ACTNUM); each name under the recipe’s grid.properties (PORO, PERMX, PERMY and PERMZ, with NTG and ACTNUM when stated) and grid.regions (EQLNUM, SATNUM, PVTNUM) is one file holding that keyword and nothing else, stamped on the model with its file and checksum. A geometry file may state MAPAXES: the grid carries it and the deck states it, so a grid written in local coordinates keeps its place on the map; the grid reader and the deck importer read the record one way (the manual’s order: a point on the map’s y axis, the origin, a point on its x axis; a record that is not six numbers, defaults a slot or states one point twice refuses in one sentence saying which). The frame places local coordinates on the map, origin + x * unit(x axis) + y * unit(y axis), which is how a surface’s frame (map coordinates) is matched onto a local grid’s pillars; a grid with no MAPAXES states its coordinates on the map already. A foreign file is read as written, never re-rendered first.
  • A property is made for one grid, and the recipe can say which: grid.for_grid binds each array it names (one of grid.properties or grid.regions) to the grid statement it was sized for, {"id": 12, "revision": 1, "payload_sha256": "<the grid include's sha256>"}. The binding rides on the recipe, the statement that pairs the property with its grid, and never on a row or a file name. The assembler refuses bundle.grid.property.mismatch, naming the array, the grid it was made for and both hashes and asking for the property to be made again for the grid it pairs with, when a bound array’s payload_sha256 is not the sha256 of the geometry file it pairs with: a property made for G1 never pairs with a G2 of the same frame and dimensions whose ZCORN differs, which a count check cannot tell. A declared remapping across grids is not in this version; an unbound array pairs as before. Which arrays must be bound is the caller’s to decide: a bundle cannot tell a property file placed from a statement’s payload from one written beside the grid. At the door a binding naming an array the grid does not carry, or a key that is not id, revision or payload_sha256, refuses bundle.recipe.unknown_field, a missing key bundle.recipe.missing_field, and an id or revision below 1 or a hash that is not 64 lowercase hexadecimal digits bundle.recipe.field_type.

ZCORN holds 8 values per cell (2·nx × 2·ny × 2·nz): for each layer the TOP plane then the BOTTOM plane; a plane is 2·ny rows of 2·nx values — each cell row twice (its near j-edge, then its far j-edge), each cell two values per row (its i-edges). Worked, a 2 × 2 × 1 grid dipping 10 m per cell along i, 20 m thick — 32 values as 2 planes × 4 rows × 4 values:

ZCORN
2400 2410 2410 2420 -- top plane, cell row j=1, its near j-edge: cell (1,1) west, east; cell (2,1) west, east
2400 2410 2410 2420 -- cell row j=1, its far j-edge
2400 2410 2410 2420 -- cell row j=2, near
2400 2410 2410 2420 -- cell row j=2, far
2420 2430 2430 2440 -- bottom plane, the same four rows, 20 m deeper
2420 2430 2430 2440
2420 2430 2430 2440
2420 2430 2430 2440 /

A file with four values per cell (a top and a bottom for each corner) has half the count and is refused by the count with this sentence; a file with each cell’s eight corners written together has the right count, reads without error, and gives flat cells — refused as degenerate, with the same sentence.

  • The recipe’s phases and the PVT tables named spell the fluid family, which ps.bundle.family_of(bundle) returns by name. Gas and water (gas_water) become PVDG, PVTW and DENSITY with SWFN and SGFN under the gas-water formulation; oil, gas and water with a dry gas table (three_phase) become PVTO, PVDG, PVTW and DENSITY with SWOF and SGOF; oil, gas and water with the wet-gas table pvt_pvtg are a gas condensate (gas_condensate): the oil is the condensate the gas carries, the deck writes PVTG and PVTO with VAPOIL and DISGAS, and it produces gas, water and condensate (the oil phase’s WOPT and FOPT). One PVT region, numbered 1, in this version; several SATNUM regions read as table families. Each table’s source names its sheet, file and checksum. pvt_pvto and pvt_pvtg are two-level tables and their row column is a key, never an order: the door groups pvt_pvto by rs (each node’s rows by rising pressure, the bubble point first) and pvt_pvtg by pressure (each node’s rows from the saturated vaporized-oil ratio down to the dry gas), so a sheet written ratio-first or with its nodes interleaved assembles to the same deck. What the simulator would refuse is refused by name: fewer than two nodes (bundle.pvt.saturated_nodes), a last node with its saturated row alone (bundle.pvt.undersaturated_missing), two rows of a node at one coordinate (bundle.pvt.duplicate_row). A family counts as rendered because a deck of it runs: tests/test_bundle_families_run_in_flow.py assembles a small bundle of each family through the door and runs it in Flow.
  • With oil in the phases any datum the contacts sheet states stands. A bundle states no dissolved-gas or vaporized-oil table by depth, and without one the simulator initialises only from a datum on the gas-oil contact, which would make a model carry its pressure down a gradient by hand. The door states the table itself: RSVD for the oil families (and RVVD beside it for a gas condensate), constant over the grid’s depths at the ratio saturated at datum_pressure, read off the saturated rows of pvt_pvto (pvt_pvtg), with EQUIL items 7 (and 8) set to 1. The simulator caps each cell at the ratio saturated at its own pressure, so the fluid is saturated at the datum and above it and undersaturated below. A datum pressure past the sheet’s last saturated node takes that node’s ratio: the model layer refuses a dissolved gas above the sheet’s own envelope, so a PVT sheet should cover the reservoir pressure. The deck’s bubble point is therefore the datum pressure whatever the oil is; check_fluids says so per region and warns where no gas cap is in the grid, and a bubble point below the datum is not stated through the recipe. Measured in Flow on the families’ fixture against the same state stated on the contact with no table: oil and gas in place within 0.3 % for a datum 5 m above or 25 m below the gas-oil contact, within 0.1 % for a condensate’s datum 30 m above its gas-water contact.
  • contacts becomes EQUIL, one record per region: a gas-water region’s gwc_tvdss, an oil region’s owc_tvdss and goc_tvdss, and a gas condensate’s gwc_tvdss — it has a gas-water contact and no oil column, so EQUIL’s water contact and gas-oil contact both sit at the GWC. Each record is stamped sheet:contacts@0.2 row=<region>, which the render names beside EQUIL under debug_comments=True.
  • wells and completions become the wells. A well’s head cell (WELSPECS I and J) is the (i, j) of its earliest completion by date and layer, its reference depth is the sheet’s ref_depth. Each cell is declared at the start as the sheet has it over the start’s month (SHUT when the sheet first completes it later), and each later change of its state (open, shut, a new diameter or skin) is a COMPDAT under the DATES of the month it holds from, read as the panel check reads a month: an opening or a new skin from the first of its own month, a shut from the first of the month after, since its month produced (a DATES is added in a lead-in month the panel does not reach). What the sheet dates is what the simulator runs. A sheet whose every row predates the start renders no dated COMPDAT. i, j and k are one-based cell indices inside the grid; a completion outside it refuses bundle.completion.coordinates naming the well, the row and the bounds, and a completion in a dead cell — ACTNUM 0, or PORO, PERMX, PERMY (or NTG) not above 0 — refuses bundle.completion.dead_cell naming the well, the cell and the property, since the simulator does not survive such a connection (a k range must skip a layer that carries no properties). x and y stay a map fact the deck never states.
  • The model layer’s own checks are the model’s input speaking, never an unavailability: a ModelError while the model is built refuses bundle.model.invalid with the rule id ([relperm.endpoint_sum] …), and a ValueError the model layer raises on the input refuses bundle.model.input with its message; a door refusal raised inside passes through unchanged.
  • faults becomes FAULTS and one MULTFLT per fault.
  • The schedule enters as statements, the history from the panels and the forecast’s controls from the events, card for card.
  • The observables are the field’s phase totals and pressure and each well’s phase totals.

Assembling the same bundle twice gives the same model and the same text.

Faults: FAULTS and MULTFLT as rows, one row per face

Section titled “Faults: FAULTS and MULTFLT as rows, one row per face”

FAULTS names the faces a fault sits on and carries no multiplier; MULTFLT carries the multiplier. A faults@0.1 sheet states the two as rows, and the assembler writes them through PetroScript’s own FAULTS and MULTFLT spelling. The rows are ONE ROW PER FACE: a box a row states is read as the unit faces it covers, each face once. A fault’s multiplier is carried as mult on every face of its fault, and a fault with no multiplier has mult null.

The deck states per fault, i, j and face the contiguous runs of k for an X or a Y face and one record per Z face: Flow reads a face across its own axis, an X face with one i, a Y face with one j and a Z face with one k. The sheet carries no grid column: the statement holding the rows names its grid. The door refuses a box that is not positive and ordered, or one whose face’s own axis runs over more than one index, an X face over several i, a Y face over several j or a Z face over several k (bundle.faults.box, naming the axis), and a fault stating two multipliers refuses bundle.assemble.fault.multiplier_disagrees at assembly.

A band is made of members that differ, and a bundle states how they differ as a sheet. uncertainty@0.1 is the prior: one row per uncertain quantity, keyed (parameter, target), and each row IS an ES-MDA ParamSpec(name, low, high, log) — low and high its honest bounds, never null; distribution uniform (log False) or log_uniform (log True, the natural prior for a multiplicative quantity: the two multipliers and kv_kh; a contact shift is uniform); base a reference case the evidence names, never drawn from; units null for a dimensionless row and m or ft for a contact shift, in the recipe’s own word at the door; evidence the sentence the range rests on. The first vocabulary is four rendered words, the four operations below, and one reserved: perm_multiplier (PERMX, PERMY and PERMZ over the target’s cells), poro_multiplier (PORO), kv_kh (PERMZ = ratio x PERMX over the target, after every multiplier), contact_shift (added to the named contact of the named region’s contacts row), and aquifer_strength, which refuses by name until aquifers@0.1 exists. A target is all, layer:<k>, layers:<a>-<b> or <REGION>:<n> for a region array the recipe’s grid.regions names (fipnum:2); a contact shift’s is eqlnum:<n>:owc, goc or gwc. The operations are drawn too, each editing the producer control rows (target_change, choke_change) its target selects that state its params key: thp_limit sets the THP floor (a pressure in the recipe’s absolute word, bara or barsa, psia in FIELD), thp_limit_delay moves the start of a row stating thp_limit by the draw rounded to whole months (a compression step’s date), uptime (null units, at most 1) and retest_days (days) set those params; their target is all (every such row), <YYYY-MM> (those starting that month) or <well>:<YYYY-MM>. A reserved word, a range that is empty or not above zero where the quantity is positive (an uptime above 1 too), a target off the grammar or naming a region the recipe does not carry, and a wrong unit each refuse by name (bundle.uncertainty.reserved, .bounds, .target, .units). Every operations target selects against the sheet as stated, so a compression step’s level, date and uptime may each be drawn on its one row (P1:2020-08 names it for all three); a target that selects no row, two draws of one parameter on the same row, and a delay that moves a row out of the forecast window refuse bundle.uncertainty.target at the member that draws it. A delay draws the row’s date and nothing else, so the row keeps its place among its well’s other rows: a delay that moves it across, onto or off the month of another row of its well (a shut, the shut’s end, a restart, a test, another control) refuses the same way, as does one that moves a well’s first forecast control later (the months before it would run on the last history rates with no control).

The recipe draws the members: "ensemble": {"uncertainty": "uncertainty@0.1", "members": 20, "seed": 0, "sampling": "lhs"} — the sheet’s id (named in sheets too), at least two members, a seed (0 is a seed) and the one method this version draws, a Latin hypercube: the members are dealt each row’s N strata by a permutation seeded by (seed, row), each sits at a point of its stratum seeded by (seed, row, member), a log-uniform row stratifies in log10 — so twenty members cover the box where twenty independent draws may not, the same seed gives the same numbers, and the population is the ParamSpec’s own box, the one esmda.sample_prior draws over the same specs. ps.bundle.member_draws(bundle) returns every member’s numbers as one frame (member, parameter, target, value, units), and ps.bundle.assemble(bundle, member=i) renders member i: the base case with that member’s draws applied — the property arrays edited over the target’s cells and inlined, the contacts row shifted and its EQUIL stamp naming the shift. With no member the base case is byte for byte what it was before the ensemble key existed. A member outside 1..N, a layer past the grid, a target that selects no cell, and a contact shifted outside its region’s active depth span — the span the base contact’s door reads, one span for one number — or onto a contact the row does not state refuse by name (bundle.recipe.ensemble, bundle.uncertainty.target, bundle.uncertainty.contact; a draw that takes the contact below zero says so as a fact, and with its mirror inside the span says the base contact’s own sentence); a recipe with no ensemble key answers member_draws with bundle.recipe.ensemble_missing. Each renderer has a Flow row that the run moves the way the physics says.

perforations@0.1 is evidence, not a completion: per well and date a measured-depth interval (top_md, base_md), its status (open, closed, squeezed) and its source, with NO cell indices — what the documents say a well was perforated over, from which a completions sheet is later derived with the grid and the trajectory. The assembler never reads it.

ps.bundle.check_grid(path, geometry=..., properties=..., regions=..., contacts=...) asks a grid alone what the assembler asks before it reads a sheet: the geometry reads as corner-point, every active cell has thickness and a positive depth (an elevation written where the deck wants a depth refuses bundle.grid.depth, through this door and through assemble alike; a grid with no active cell refuses bundle.grid.inactive the same way), every named array holds one value per cell; the same refusals, and when they pass a report of raw measurements — dims, active, active per layer, thickness min and max over the active cells, the active depth span, the footprint (x and y extent and the active area in km2), the median cell size, and how well laterally adjacent cells agree at the corners they share (how many were compared, the largest disagreement, the fraction beyond 1 % of the mean thickness). A fault offsets a minority of pillars and a ZCORN written in the wrong order offsets nearly all — every cell can keep its thickness while its corners sit a hundred metres off — so more than half disagreeing is a warnings line that says “usually” and “check”, never a refusal: faults are real. A quiet report accepts nothing about the geometry. With contacts= naming a written contacts sheet, a datum or contact the bundle reads as an elevation is a warnings line (bundle.contacts.outside_grid, below), and the report places each stated contact and datum against the active depth span — of its own equilibration region when regions names EQLNUM: above, inside or below; a stated region no active cell carries is said in warnings, never dropped, and a contacts path that is not there refuses bundle.sheet.missing. A reader that took a sheet refusal for “grid accepted” has this to ask instead. The report also states layout, how a property array is laid out — one value per cell of the whole grid, I fastest, then J, then K — and, per array named in properties=, a properties entry with that array’s mean over the active cells along K (by_layer), along J (by_row) and along I (by_column), in eight equal-count bins where an axis runs past 64. An index inside the grid proves nothing about placement: an array written transposed or in the wrong order reads without error and every count stays right, and it shows here as the gradient the engineer put in one axis appearing in another. Nothing refuses on it — a model may well grade along I — but the witness is there to read.

ps.bundle.check_fluids(path) asks the PVT sheets what the fluids weigh where the model starts. It states the family and the rule that decided it, the unit words, and per equilibration region the datum pressure, the saturated solution ratio each table carries there, and the derived phase densities: the water from its own compressibility branch (rho_w,s / Bw, Bw = fvf / (1 + x + x^2/2), x = c (p - p_ref)), the hydrocarbons from each table’s saturated curve read linearly between its nodes at the datum — (rho_g,s + Rv rho_o,s) / Bg from pvt_pvdg or pvt_pvtg, (rho_o,s + Rs rho_g,s) / Bo from pvt_pvto — with the formula and the units beside every number. Those are METRIC’s; FIELD states gas volumes in Mscf and liquid ones in stb and rb against lb/ft3, so its arithmetic carries 1000 ft3/Mscf and 5.614583333 ft3/rb (ft3/stb), and its formula names both: (1000 rho_g,s + 5.614583333 Rv rho_o,s) / (5.614583333 Bg) and (5.614583333 rho_o,s + 1000 Rs rho_g,s) / (5.614583333 Bo). Nothing is extrapolated: a datum pressure outside a table’s nodes gives no number, the reason, and a warnings line. A report accepts nothing and refuses nothing about the numbers it states; the sheets it reads it requires, with the door’s own bundle.assemble.sheet_unnamed and bundle.assemble.sheet_missing, and for an oil family it reads the grid’s active depth span per equilibration region as the assembler does, so a grid the assembler refuses refuses here too. Every density is a surface density over a formation-volume factor, so a factor at or below 0 on any row of a table the report reads, saturated or not, refuses here and through assemble alike, before any division and before any table is built: bundle.model.invalid with the model layer’s rule id (fluids.water.fvf, fluids.gas.positive_values, fluids.gas.pvtg.positive_values, fluids.oil.positive_values), naming the sheet and the lowest factor.

A hydrocarbon heavier than its water at the datum is a warnings line here and refuses through assemble (bundle.pvt.density_order), because those two numbers decide the whole initial state: one test deck wrote a Bg 5.6x too small — a per-Mscf value under an rm3/sm3 unit word — and a Rv a thousand times too large, the tables put the gas at 1,189 and 15,000 kg/m3 against water at 1,010, every completed run initialised with water in every cell, and no door said a word. What refuses is a scoped restriction of what this recipe assembles, never a claim about physics: the refusal names the region, the datum pressure, both densities with their units, the formula each came from, the gas_fvf or oil_fvf column and its unit word, and it says that a fluid meant to sit below its water — a heavy oil under brine, a dense CO2 phase — is not stated through this recipe today. A datum pressure off the table’s nodes yields no number and so never refuses.

Each region with oil also states saturation_pressure: the datum pressure, with what it is — the door builds RSVD (and RVVD) saturated at the datum, so the deck’s bubble (and dew) point is the datum pressure whatever the fluid is, and no sheet column states another (a datum off a table’s nodes states none; a gas-water region has none to state). An oil region whose goc_tvdss lies above its active depth span holds no gas cap in the grid, where an undersaturated oil is the likely case, and the report warns: it names the region, the datum pressure and the Rs the recipe initialises the oil at, and says that an oil whose bubble point lies below the datum pressure is not stated through this recipe: the recipe initialises the oil saturated at the datum pressure, at the Rs pvt_pvto’s saturated curve gives there, so its bubble point is the datum pressure. The Rs follows the table: the same datum with the table’s Rs scaled by 0.8 gives an Rs 0.8 times as large.

The inverse importer (a from_deck door, the corpus teaching the assembler) is not built yet.

Two shapes of the history panel, and pandas

Section titled “Two shapes of the history panel, and pandas”

history_panels@0.1 is long (well, month, stream, volume, units): one volume column holds oil in sm3 and gas in 1000 sm3, so that sheet alone carries a units column per row. history_panels@0.2 is wide, one row per well and month with one column per stream (oil, gas, water, water_injection, gas_injection), each with its own unit in the file’s metadata, which is the rule every other sheet follows and the shape the history verb reads. Both ids stay registered; a bundle names the one it wrote. Every door accepts a pandas frame or df.to_dict("records"); Bundle.sheet(kind) returns a polars frame, and frame.to_pandas() is one call for readers who prefer pandas; that call needs pandas installed in the reader’s own environment, since pandas is not a PetroScript dependency and stays out.

zonation@0.2 lets a zone say how its permeability was established. perm_md may be null there, and a required perm_basis names one of core, log_transform or unavailable; the two agree or the row refuses (bundle.zonation.perm_basis, at write and at the door): unavailable goes with a null, the other two with a number in the sheet’s permeability unit. A petrophysics study with no core writes unavailable and keeps its porosity, saturation and net-to-gross on the row; a reader of such a row must not zero-fill the permeability and must not infer one in place, since a transform it applies is a new statement under its own name (log_transform). zonation@0.1, permeability on every row, stays registered; a recipe names the id it means.

forecast_streams@0.1 is what a simulation gives back, in the shape the wide panel uses: one row per well and month, oil, gas, water, water_injection and gas_injection as volumes for the month with a unit per column, and FIELD allowed as a well name for the field totals. It is a kind of its own, not a third version of history_panels, so a reader can never mistake a simulated month for an observed one. It is written from a run’s own files, one writer per simulator, so whatever reads forecasts reads one shape whichever simulator ran. The month check applies (bundle.panel.month); there is no history window to check, since a forecast has none. well_pressures@0.1 stands beside it for the pressures of the same run (WBHP, WTHP): one row per well and month, bhp at the well’s ref_depth and thp, null where the well has no lift table, since the simulator computes a tubing-head pressure only through one; the same month check applies. Every assembled deck asks the run for them: each well’s WBHP and each producer’s WTHP stand in its SUMMARY.

The givens, the deliverable spec and the surfaces

Section titled “The givens, the deliverable spec and the surfaces”

givens@0.1 holds the numbers and facts a later step reads by name, one row per given: name (the key, a short identifier: a letter, then letters, digits, _, . or -), value, units, method, source_path, source_page, vintage, low, high, derived_from and note. A value is a number or a text, so the column is text, built as text before polars sees the rows: a value that reads as a finite number, sent as a number or as a text, is written as that number’s shortest decimal text, a function of the number alone and never of the rows beside it (245 and 245.0 are 245.0, 1e20 is 1e+20, 0.20 is 0.2), so the same given gives the same bytes alone, beside a text or past the hundredth row. Any other value is a text value, a spelling with no finite number among them (1e999, inf, nan), and a text value takes units none and no band; a number sent as NaN or infinity refuses (bundle.column.nan, bundle.column.nonfinite), since a value is never absent. The band’s low and high, like every float column of every registered sheet, are built as floats before polars sees the rows, an integer as its float and a null as a null, so a band of 1 and 2 writes the same bytes as 1.0 and 2.0, and a column whose values mix kinds, a bool among numbers included, refuses by its name (bundle.column.dtype). units is a word of the units vocabulary, judged exactly (below); none also stands for a fraction, so a numeric value with units none is a fraction and stands. method is read, derived or assumed: a read row names the bundle-relative file it was read from in source_path (and the page as the source numbers it in source_page, text: 12, iv); a derived row lists the givens it was computed from in derived_from, comma-separated; derived_from is null on a read or assumed row, never an empty text. vintage is the date the value speaks for, YYYY, YYYY-MM or YYYY-MM-DD, or unknown; low and high are an optional band in the value’s units.

givens = [
{"name": "p_gauge", "value": 231.0, "units": "barg", "method": "read",
"source_path": "reports/well-test.pdf", "source_page": "12", "vintage": "1987-03",
"low": None, "high": None, "derived_from": None, "note": None},
{"name": "p_atm", "value": 1.013, "units": "bar", "method": "assumed",
"source_path": None, "source_page": None, "vintage": "unknown",
"low": None, "high": None, "derived_from": None, "note": None},
{"name": "p_abs", "value": 232.013, "units": "barsa", "method": "derived",
"source_path": None, "source_page": None, "vintage": "1987-03",
"low": None, "high": None, "derived_from": "p_gauge, p_atm", "note": None},
{"name": "lithology", "value": "sandstone", "units": "none", "method": "read",
"source_path": "reports/core.pdf", "source_page": "iv", "vintage": "unknown",
"low": None, "high": None, "derived_from": None, "note": None},
]
path = ps.io.write_sheet(givens, path="ws/givens.parquet", key="name", schema="givens@0.1")
assert ps.io.read_sheet(path)[1]["schema"] == "givens@0.1"
try:
ps.io.write_sheet(
[{**givens[0], "source_path": None}],
path="ws/givens.parquet", key="name", schema="givens@0.1",
)
except ps.bundle.BundleRefusal as refusal:
assert refusal.reason == "bundle.givens.source_path_missing"
else:
raise AssertionError("a read row with no source_path was written")

Each refuses by name, at write and at the door: a name that is no identifier (bundle.givens.name), a read row with no source_path (bundle.givens.source_path_missing), a derived row with no derived_from (bundle.givens.derived_from_missing), a derived_from on a read or assumed row (bundle.givens.derived_from_stray), a derived_from that is not a list of names, the empty text included (bundle.givens.derived_from_malformed), a given derived from itself (bundle.givens.derived_from_self), a text value with a unit or a band (bundle.givens.text_value, naming units, low or high), a low above its high (bundle.band.order), a vintage that is neither a date nor unknown (bundle.givens.vintage; 1987-03-14T10:00 and 1987-W10-1 are not dates here), a number sent as NaN or infinity, a value that is neither a number nor a text (bundle.column.dtype), and a method or a units word outside its vocabulary (bundle.vocabulary). What the sheet cannot answer alone it leaves to the recorder that binds rows across statements: whether a derived_from name resolves to a current row, whether the names form a cycle, and whether a source_path is a file of the sealed bundle.

deliverable_spec@0.1 is what the study must deliver, one row per item (the key): kind (sheet, grid, control, stream, band or other), spec (the sheet id and its required columns; the grid’s extent and cell size; a control mode; a stream and its unit; a band’s quantiles), required (a bool), by (the role expected to supply it) and note.

deliverable_spec@0.2 keeps the key apart from the prose: spec is the item’s machine-readable key alone, and constraint, advisory and nullable, carries the prose beside it (the columns, the extent, the unit, the window), never read as the key. The key, by kind: sheet, a registered schema id (wells@0.2); grid, the grid include’s name or sha256; control, a control mode; stream, a quantity, oil, gas, water, water_injection or gas_injection (a forecast_streams column) or condensate (read from the oil column); band, a quantity and its percentiles (gas p10 p50 p90); note, the kind 0.2 adds, the id <name>@<n> of the profile whose note meets the item (science/ensemble-design@1); other, the item’s own word. At write and at the door, a sheet item whose spec is no registered id refuses deliverable.spec.unregistered (the message lists the kind’s ids when the spec starts with a registered kind), a stream item whose spec is no stream quantity refuses deliverable.spec.quantity, and a note item whose spec is no profile id refuses deliverable.spec.profile, each naming the row’s item. deliverable_spec@0.1 stays registered and is read as it was written: its spec is prose, so its sheet and stream rows are not keyed. The kind vocabulary is one list, so note is a word under 0.1 as well, and a note row is checked under both versions.

surfaces@0.1 is a mapped horizon, one row per node, keyed by (horizon, i, j): x and y in the bundle’s map CRS (the wells sheet’s x and y), z the node’s depth as a tvdss value, sigma z’s standard deviation (null = not reported), method (picked, tied to a well top, interpolated or assumed) and evidence (reported or inferred, the schedule events’ vocabulary, as contact_depths reuses it). A sheet carries no header, so the frame rides on every row, redundant but readable by any sheet reader: crs; origin_x and origin_y, the map position of the node at (index_origin_i, index_origin_j); spacing_i and spacing_j, the node spacing in the map unit; rotation, the angle in degrees from the map x axis to the i axis, counter-clockwise positive; index_origin_i and index_origin_j, the first node’s indices; n_i and n_j, the node counts; and node_order, the source grid’s order, i_fastest or j_fastest, spelled exactly and never null — twenty columns. The file itself is always ordered by its key, whatever the source grid’s order, as every sheet is. A horizon states one frame, the same on every row (bundle.surfaces.frame, naming the columns that differ), and every node lies inside it, i from index_origin_i to index_origin_i + n_i - 1 and j the same way (bundle.surfaces.node); spacing_i and spacing_j are above zero (bundle.surfaces.spacing) and sigma is not below it (bundle.surfaces.sigma); two seeds on one node refuse as a repeated key (bundle.key.duplicate). crs is free text, and nothing checks a node’s x and y against the frame. A recipe that names surfaces@0.1 reads it, like every sheet, from sidecars/tables/surfaces.parquet, every horizon in the one sheet; the assembler reads nothing from it. Taking surfaces and a pillar spacing as the grid’s geometry is a later cut.

The rows’ x and y stay authoritative; the door does not check them against the frame (a row-against-frame check within a tolerance is a later door addition). The frame is right-handed by definition: j lies 90 degrees counter-clockwise of i, and (x, y) = (origin_x, origin_y) + ((i - index_origin_i) · spacing_i, (j - index_origin_j) · spacing_j) rotated by rotation degrees counter-clockwise about the origin. A writer holding a left-handed array (j clockwise of i) reverses its j index before it writes, so the frame’s origin is the new first node and n_j is unchanged: a left-handed y = 0, -1, -2 is written as j = 2, 1, 0 with the origin at y = -2. Nothing is reshaped by node_order, since the file is ordered by its key.

ps.bundle.export_schemas()["vocabularies"]["units"], exported as vocabularies.units in SCHEMAS.json, holds every unit word the bundle knows, spelled exactly: the words a reader states (none, %, degC, degF, bar, barsa, bara, barg, psi, psia, psig, kg/cm2, g/cm3, kg/m3, kg/l, ohm.m, mS/m, m, ft, us/ft, us/m, gAPI, mD), then every sheet column’s METRIC and FIELD word, collected from the registry rather than listed twice. A pressure word states its reference: bar is a pressure whose reference is not stated, barsa is absolute, barg gauge, and psi, psia and psig the same three ways; bara is the absolute pressure spelled as a givens word and an event’s params word, while a sheet column’s unit is judged against that column’s own word, so a column whose word is barsa refuses bara; a consumer that needs an absolute pressure has the word to refuse a bar by. ohm.m = 1000 / (mS/m) is the stated conversion between a resistivity and a conductivity. A fraction carries none; % is its own word and is never silently converted. The plural bars, GAPI and MMHO/M are a reader’s spellings, not words: a reader maps them to bar, gAPI and mS/m before a row is written, and givens.units refuses them (bundle.vocabulary).

A write_sheet units map spells a column’s word the way this vocabulary does. The writer reads the common variants of a column’s own word (case, spaces, a METRIC: or FIELD: prefix) and stamps the vocabulary’s spelling, but a different word is a different unit: a bar or a bara sent for a column whose word is barsa refuses bundle.unit.mismatch, naming the column and barsa.

A sheet is proved whole on its own when it is written: ps.io.write_sheet(..., schema="<kind>@<version>") runs the bundle door’s own checks before a byte reaches the disk, and stamps the file with the schema id, the key and the units, which ps.io.read_sheet(path) hands back. A refiner’s exit runs before the engineer has authored a recipe, so this is how its sheet is proved. open keeps requiring the recipe: a bundle is the engineer’s binding of sheets, and it has one state, bound. An unstamped file is not refused at the door, by design: the recipe names the sheet’s kind, and the door validates the frame against that schema on first read. Stamp at the exit, not at the door.

At write (write_sheet(schema=)), from the file alone: the column set and dtypes, units per column and one unit system, the key, the vocabularies, and the row facts a file answers on its own: an unresolved conflict row (bundle.event.conflict), params that are not JSON or not a JSON object (bundle.event.params; a bare number, string, array or null refuses by that name), a number in params without its units twin spelled by name (bundle.schedule.params.units_missing), a number a class does not render (bundle.schedule.params.key_unrendered; the message lists the keys the class renders with their types, and the schema’s doc carries the table per class), a month that is not YYYY-MM (bundle.event.month, bundle.panel.month), a volume below zero in a panel, a prior_production or a forecast_streams row (bundle.panel.volume, naming the row), an end before its start (bundle.event.window), a zone whose permeability and basis disagree (bundle.zonation.perm_basis), a contacts row stating both water contacts (bundle.contacts.two_water_contacts), a given’s name, source, lineage, text value or vintage (bundle.givens.*), a band whose low lies above its high (bundle.band.order), an eqlnum below 1 (bundle.contact_depths.eqlnum), a deliverable whose spec is not its kind’s key (deliverable.spec.unregistered, .quantity, .profile), and a surface’s frame, a node outside it, a spacing at or below zero or a negative sigma (bundle.surfaces.frame, .node, .spacing, .sigma), and a faults row whose box is not positive and ordered on some axis (naming the first bad axis), or whose face’s own axis runs over more than one index, an X face over several i, a Y face over several j or a Z face over several k, naming the face and the axis (bundle.faults.box): FAULTS cannot state it, so the assembler never lowers one; a lift_table that is not an integer from 1 (bundle.event.params) or stands on a class other than a target or choke change (bundle.schedule.params.key_unrendered), a lift table with a hole in its grid or stating two wells or two datums (bundle.lift_tables.body, bundle.lift_tables.table) or a negative rate or ratio or a pressure at or below 0 (bundle.lift_tables.range), and a tubing interval that starts above the datum, does not run downward, has no bore, a roughness outside it or does not meet the one above it (bundle.tubing.interval).

At the door (Bundle.sheet), with the recipe: the same, plus the history window (bundle.panel.window), the recipe’s unit system, and the contacts pairing against the recipe’s phases (bundle.contacts.gwc_missing, bundle.contacts.owc_missing, bundle.contacts.oil_contact_in_gas_water, bundle.contacts.gwc_in_oil_system).

As the schedule enters the model at assembly (assemble), which reads the completions sheet too wherever the recipe names one, so a named completions file that is not there refuses here as well (bundle.sheet.missing): a well the wells sheet does not name (bundle.schedule.well_unknown), a class this version does not render (bundle.schedule.class_unrendered), a THP limit with no lift table (bundle.schedule.lift_table_missing: name the well’s lift_table, or state a bhp_limit), a lift table in a gas-water model (bundle.schedule.lift_two_phase), a lift_table the recipe’s lift_tables sheet does not state or states for another well (bundle.schedule.lift_table_unknown), a table whose well is no GAS producer of the wells sheet (bundle.lift_tables.well), an thp_limit, min_gas_rate, uptime or retest_days outside its range (bundle.schedule.params.range), a control with no limit (bundle.schedule.limit_missing), a unit word outside the recipe’s system, or a rate word for the other stream (bundle.schedule.params.units_mismatch), a producer stating two rate targets (bundle.schedule.target_ambiguous) or a control_mode at odds with its target (bundle.schedule.control_mode), an end on a class that has no state to undo (bundle.schedule.end_unused: leave end null, it is optional on write), a rate on an injector’s target change, or a rate key a producer’s control does not know (bundle.schedule.params.key_unrendered), a curtailment stating two limits (bundle.schedule.limit_ambiguous), an event before the history end or beyond the horizon (bundle.schedule.event_in_history, bundle.schedule.event_beyond_horizon), an injection class on a producer (bundle.schedule.not_an_injector), and a panel volume in a unit the renderer cannot convert (bundle.panel.units_unrendered) or in a word the recipe’s system does not state that stream in (bundle.panel.units_mismatch), a rate or limit unit the renderer cannot spell (bundle.schedule.units_unrendered), a curtailment on a group the wells sheet does not name (bundle.schedule.group_unknown), a rate above zero stated for a well and a month where the completions sheet states no open completion (bundle.schedule.well_closed, naming the rate with its unit, the month and the state of every completion over it. The panel reports a month and not a day, so a completion counts as open there when the state it carried into the month is open — the latest row dated before the first of the month — or when any row dated inside the month is open: a well that comes on mid-month reports a partial month and stands, a mid-month shut does not unmake the days before it whether the well was opened that month or years earlier, and an opening and a shut inside one month still produced that month. The two sheets disagree about that month, which is the rule; the assembler renders each cell’s state month by month by the same reading, so such a month would be a WCONHIST over shut connections that the simulator answers with nothing. A zero rate on a closed well stands, and the panel rows and the forecast’s rate params are read the same way), and a producer open anywhere in the forecast window with no control row (bundle.schedule.forecast_uncontrolled, naming the wells and the window: open at the end of history and not shut through the window, or reopened by a restart or a shut’s end inside it, with no target_change or choke_change row — without one the deck would run the well on its last history rates with no WCONPROD).

At assemble (assemble): a sheet the model needs that the recipe does not name (bundle.assemble.sheet_unnamed), a named sheet whose file is gone (bundle.assemble.sheet_missing), a region file assigning a region no sheet states (bundle.assemble.region_unstated), a well with no completion row (bundle.assemble.well.no_completion), a table family that contradicts the phases, or two gas tables (bundle.assemble.phases_mismatch), a phase set this version does not spell, or a PVT sheet stating any region but a lone region 1 (bundle.assemble.fluids.unsupported), a grid file this version does not read (bundle.assemble.grid.unsupported), a property array the model needs that the recipe does not name (bundle.assemble.grid.property_unnamed), a fault whose rows state two multipliers (bundle.assemble.fault.multiplier_disagrees), a contacts@0.1 sheet in a gas-water system (bundle.contacts.gwc_missing), a two-level PVT table the simulator would refuse (bundle.pvt.saturated_nodes, bundle.pvt.undersaturated_missing, bundle.pvt.duplicate_row; a PVT table with no rows at all answers bundle.pvt.saturated_nodes too, since a curve is read off the table’s own rows), a hydrocarbon heavier than its water at its region’s datum (bundle.pvt.density_order, naming both densities, the formula each came from and the formation-volume column to check), a completion row naming a well the wells sheet does not (bundle.schedule.well_unknown), a tubing row naming one (bundle.tubing.well), a completion outside the grid or not one-based (bundle.completion.coordinates), a completion in a dead cell (bundle.completion.dead_cell), a completion whose diameter or skin is not reported (bundle.assemble.completion.not_reported: COMPDAT needs it and null is never 0), the model layer’s own refusal (bundle.model.invalid with the rule id; bundle.model.input for a ValueError on the input), a property array bound by grid.for_grid to another grid than the geometry it pairs with (bundle.grid.property.mismatch), a geometry whose active cells have no thickness (bundle.grid.degenerate, with the count, the first (i, j, k) and the ZCORN corner order (nz, 2, ny, 2, nx, 2): a file written with the top and bottom corners interleaved reads without error and gives a grid of flat cells), and a grid with no active cell (bundle.grid.inactive: every other grid check passes on nothing; check the mask’s orientation).

A datum or a contact written as an elevation is a warning, not a refusal (bundle.contacts.outside_grid, a warnings line of check_grid(..., contacts=...)): every *_tvdss value is a depth below sea level, positive downward like the grid’s own, and a gwc_tvdss of -3320 puts the contact kilometres above the grid, so every cell initialises as water. Flow runs that deck as written (rc 0, all water), so under severity rule B the value stands and assemble builds it. What warns is this contract’s scoped reading, a conjunction — the value is strictly negative AND its mirrored depth lies inside its region’s active cells’ depths: the bundle reads that as an elevation sent where a depth is meant — and the line names the column, the value, the span and the mirror to send; it suggests the mirror and never applies it. It is no claim about physics: a zero datum and a negative value whose mirror is not in its region say nothing. A positive contact or datum outside the grid stands too: a contact below the grid’s bottom, a goc_tvdss above its top (no gas cap), a datum the simulator extrapolates from.

The effective ACTNUM and NTG property arrays are applied before grid checks. The base contact check, member contact shifts and grid report use the same active span per EQLNUM region; without EQLNUM they use the active whole-grid span. A stated region without active cells has no span: its placements report lies: "no_active_cells", span: null, and a warning. A contact shift into that region refuses because it has no supported placement. Missing contact files refuse bundle.sheet.missing; a sheet of the wrong kind refuses bundle.schema.mismatch. Infinite numeric sheet values refuse bundle.column.nonfinite at both write and read, while NaN retains bundle.column.nan; neither is a spelling for missing data.

For a newly audited model, the optional recipe entry "outputs": {"initial_state": true} requests saved initialization restart arrays through the model’s ps.Output(initial_restart=["basic=2"]). The existing grid request also saves static properties; the pinned Flow target writes the grid artifact. The saved .UNRST, .INIT and .EGRID files support pressure, saturation, cell mapping, depth and pore-volume measurements. Absent or false preserves the existing deck. The option does not change any physical input, well control or schedule interval. The fixture gate compares the complete simulated summary with and without the request for each supported fluid family. Old runs without saved initialization remain unavailable for that measurement; this option does not rewrite their decks.

Read the files with ps.results.open(folder): run.initial, run.restart(at=...), run.static and run.grid; see the native artifact contract. These readers inventory recorded measurements. They do not certify a model’s scientific validity or infer initialization from a later report.

A numeric column whose value can be unknown for a row is nullable, and its schema doc says so: null = not reported. The wells’ x, y and ref_depth; the tops’ md and tvdss; a zone’s phi, sw, ntg and (zonation@0.1) perm_md; a completion’s top_md, base_md, diameter and skin; a fault’s mult; a contact’s confidence; the staged contact depths; a top’s low and high (tops@0.2); a surface node’s sigma; a given’s source, band, lineage and note. A writer that does not know a value writes null, never 0 (a sw of 0 in a gas field was read as a fact once). Where the assembler needs the value it refuses by name rather than treat null as 0 (bundle.assemble.completion.not_reported); a null reference depth renders WELSPECS’ 1*; a fault with no reported multiplier is stated as FAULTS with no MULTFLT. Keys, counts and the PVT and SCAL table values stay non-nullable: a row exists to state them. The one exception is pvt_density’s oil: a gas-water system has no oil, so it leaves oil null (or the column out), and a system with oil refuses a null oil by name. That column was widened inside pvt_density@0.1 rather than in a new version, on purpose: every file 0.1 accepted before reads the same, a reader at an older pin still refuses a null oil by name (as it refused every gas-water bundle, which then had to invent an oil density), and a second version would make every recipe that names bare pvt_density on an unstamped file ambiguous. A new column or a changed meaning is still a new version.

PetroScript is licensed under the Apache License 2.0. © 2026 Blauweiss EDV LLC. PetroScript by Blauweiss AI.For AI agents: llms.txt