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.
The layout
Section titled “The layout”<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-typedThe 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.
Writing a sheet
Section titled “Writing a sheet”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.
Write validates when it knows the schema
Section titled “Write validates when it knows the schema”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 in the deck is one line
Section titled “A name in the deck is one line”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.
A depth is positive downward
Section titled “A depth is positive downward”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.
Opening a bundle
Section titled “Opening a bundle”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 jsonfrom 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}What refuses at the door
Section titled “What refuses at the door”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.
The schema file
Section titled “The schema file”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"]) == 16Rendering the schedule
Section titled “Rendering the schedule”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:
| class | card |
|---|---|
shut_in, plug_abandon, test, injection_stop | WELOPEN ... SHUT (never STOP) |
restart | WELOPEN ... OPEN |
target_change, choke_change | on 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_change | WCONINJE with the disclosed rate (rate_units, phase) and, or, a bhp_limit |
curtailment | GCONPROD 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.SCHEMASassert "lift_table" in ps.bundle.SCHEMAS["schedule_events@0.1"].docA producer’s operations
Section titled “A producer’s operations”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.
The tubing, which the deck never carries
Section titled “The tubing, which the deck never carries”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)Assembling the model
Section titled “Assembling the model”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) andgrid.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_gridbinds each array it names (one ofgrid.propertiesorgrid.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 refusesbundle.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’spayload_sha256is 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 notid,revisionorpayload_sha256, refusesbundle.recipe.unknown_field, a missing keybundle.recipe.missing_field, and an id or revision below 1 or a hash that is not 64 lowercase hexadecimal digitsbundle.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 tablepvt_pvtgare 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’ssourcenames its sheet, file and checksum.pvt_pvtoandpvt_pvtgare two-level tables and theirrowcolumn is a key, never an order: the door groupspvt_pvtobyrs(each node’s rows by rising pressure, the bubble point first) andpvt_pvtgbypressure(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.pyassembles 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 ofpvt_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_fluidssays 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. contactsbecomes EQUIL, one record per region: a gas-water region’sgwc_tvdss, an oil region’sowc_tvdssandgoc_tvdss, and a gas condensate’sgwc_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 stampedsheet:contacts@0.2 row=<region>, which the render names beside EQUIL underdebug_comments=True.wellsandcompletionsbecome 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’sref_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,jandkare one-based cell indices inside the grid; a completion outside it refusesbundle.completion.coordinatesnaming 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 — refusesbundle.completion.dead_cellnaming the well, the cell and the property, since the simulator does not survive such a connection (akrange must skip a layer that carries no properties).xandystay a map fact the deck never states.- The model layer’s own checks are the model’s input speaking, never an
unavailability: a
ModelErrorwhile the model is built refusesbundle.model.invalidwith the rule id ([relperm.endpoint_sum] …), and aValueErrorthe model layer raises on the input refusesbundle.model.inputwith its message; a door refusal raised inside passes through unchanged. faultsbecomes 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.
An ensemble: members that differ
Section titled “An ensemble: members that differ”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.
What is not here yet
Section titled “What is not here yet”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.
Permeability with its basis
Section titled “Permeability with its basis”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.
The results sheet
Section titled “The results sheet”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.
One place for every unit word
Section titled “One place for every unit word”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.
Proving one file before a recipe exists
Section titled “Proving one file before a recipe exists”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.
Where each refusal fires
Section titled “Where each refusal fires”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.
Saving initialization measurements
Section titled “Saving initialization measurements”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.
Absent is null, never 0
Section titled “Absent is null, never 0”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