Python: Extend ORIONEVENTS with completion events, SCHEDULE block and datetimes

Extend the 2.0 format to cover the full WellEventTimeline API demonstrated
by well_event_schedule.py.

Add built-in TUBING, VALVE and STATE event types mapping to
add_tubing_event, add_valve_event (including the AICD parameters) and
add_state_event, validated with the same required/unknown-attribute
handling as PERFORATION.

Pass any other event type inside a WELL block through generically as an
Eclipse well keyword via add_well_keyword_event with the well name
injected, so WRFTPLT, WCONPROD and the rest work without enumeration.
Event types that closely resemble a misspelled built-in are handled by
the on_unknown_event policy with a did-you-mean hint instead of being
passed through.

Add a SCHEDULE block: a bare SCHEDULE line opens a block of well-less
keyword events (RPTRST, GRUPTREE, TUNING, ...) applied through
add_keyword_event; completion events in a SCHEDULE block are rejected.
Parsed schedule events live in OrionDocument.schedule_events.

Accept ISO datetimes in event dates (@2024-05-15T14:45:30.500); the
time-of-day survives into the generated DATES keyword's TIME field with
millisecond precision. Day-offset arithmetic applies to datetimes too.

Ship well_event_schedule_orion.py, an ORIONEVENTS counterpart to
well_event_schedule.py, and extend the test suite from 55 to 70 tests
including an integration test that applies every event kind and verifies
the generated schedule text.
This commit is contained in:
Kristian Bendiksen committed 2026-08-07 14:34:35 +02:00
1 parent 03236ee737
commit de942cc8aa
3 files changed
+662 -74

No files matched your search

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Example: the well_event_schedule.py timeline expressed as an ORIONEVENTS file.
This is the ORIONEVENTS counterpart to well_event_schedule.py: instead of
calling the WellEventTimeline API methods one by one, the same events are
written as ORIONEVENTS 2.0 text (see rips/orion_events.py for the grammar) and
applied in one go with rips.orion_events.apply_orion_document().
It demonstrates the full event coverage of the format:
1. TUBING, PERFORATION (incl. a time-of-day date), VALVE and STATE completion
events on a well
2. Well keyword events: WCONHIST and WELTARG (with attribute translation) and
WRFTPLT (generic Eclipse well keyword pass-through)
3. SCHEDULE-level keyword events not tied to a well: RPTRST, GRUPTREE, TUNING
4. Generating Eclipse schedule text from the resulting timeline
The ORIONEVENTS text is built inline with the name of the first well path in
the project (like well_event_schedule.py, which uses wells[0]), so the example
works with any project that has at least one well path.
"""
import rips
def build_orion_text(well_name):
return f"""\
ORIONEVENTS 2.0
UNIT METRIC
# Typed declarations
DATE STARTUP = 2024-01-01
DURATION RAMP = 31 DAYS
WELL W1 = "{well_name}"
WELL W1
# Tubing installed early (MD 0-2500m)
@STARTUP TUBING MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5
# Perforations; COMPLETION_NUMBER groups connections for COMPLUMP
@STARTUP + RAMP PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1
@2024-04-01 PERFORATION MDSTART=2400 MDEND=2600 RADIUS=0.05 SKIN=0.3 COMPLETION_NUMBER=2
# Time-of-day is preserved and emitted as the TIME field of DATES
@2024-05-15T14:45:30.500 PERFORATION MDSTART=2300 MDEND=2350 RADIUS=0.05 SKIN=0.4 COMPLETION_NUMBER=3
# Valve in the first perforation; state event for documentation
@2024-03-01 VALVE MD=2100 TYPE=ICV STATE=OPEN CV=0.7 AREA=0.0001
@2024-02-15 STATE STATE=OPEN
# Well keyword events; WRFTPLT is passed through as a generic Eclipse keyword
@2024-01-15 WCONHIST STATUS=OPEN CMODE=RESV ORAT=3999.99 WRAT=0.01 GRAT=550678.44 VFP=1
@2024-05-01 WELTARG CMODE=ORAT VALUE=5000.0
@2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO
# Schedule-level keywords (not tied to a well)
SCHEDULE
@STARTUP RPTRST BASIC=2 FREQ=1
@STARTUP GRUPTREE CHILD=OP PARENT=FIELD
@STARTUP TUNING TSINIT=1 TSMAXZ=30 TMAXWC=1 NEWTMX=12 NEWTMN=1 LITMAX=50 LITMIN=1 MXWSIT=50 MXWPIT=50
"""
def main():
resinsight = rips.Instance.find()
project = resinsight.project
print("Well Event Schedule (ORIONEVENTS) Example")
print("=" * 50)
print("\n1. Finding well")
wells = project.well_paths()
if not wells:
print(" No well paths in project - load a project with wells first.")
return
well_path = wells[0]
print(" Well name:", well_path.name)
print("\n2. Parsing ORIONEVENTS text...")
orion_text = build_orion_text(well_path.name)
print(orion_text)
document = rips.orion_events.parse_orion_events(orion_text)
print(f" Wells: {[w.well_name for w in document.wells]}")
print(f" Well events: {sum(len(w.events) for w in document.wells)}")
print(f" Schedule events: {len(document.schedule_events)}")
print("\n3. Applying events to the timeline...")
well_path_coll = project.descendants(rips.WellPathCollection)[0]
timeline = well_path_coll.event_timeline()
report = rips.orion_events.apply_orion_document(document, timeline, project)
print(f" Events applied: {report.events_applied}")
print(f" Events skipped: {report.events_skipped}")
for warning in report.warnings:
print(f" WARNING: {warning}")
for error in report.errors:
print(f" ERROR: {error}")
# Apply events up to a date to materialize completions
timeline.set_timestamp(timestamp="2024-12-24")
print("\n4. Verifying created completions...")
perforations = well_path.completions().perforations().perforations()
print(f" Perforations created: {len(perforations)}")
for perf in perforations:
print(
f" - MD {perf.start_measured_depth:.0f} to {perf.end_measured_depth:.0f}m"
)
print("\n5. Generating Eclipse schedule text from events...")
cases = project.cases()
if not cases:
print(" No Eclipse case loaded - skipping schedule generation.")
return
case = cases[0]
schedule_text = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[well_path]
)
if schedule_text:
print(f" Generated schedule text ({len(schedule_text)} characters)")
print(" " + "=" * 60)
for line in schedule_text.split("\n"):
print(f" {line}")
print(" " + "=" * 60)
expected_keywords = [
"DATES",
"COMPDAT",
"COMPLUMP",
"WCONHIST",
"WELTARG",
"WRFTPLT",
"RPTRST",
"GRUPTREE",
"TUNING",
]
found = [kw for kw in expected_keywords if kw in schedule_text]
print(f"\n Keywords found: {', '.join(found)}")
if "14:45:30.500" in schedule_text:
print(" DATES keyword preserves event time-of-day (14:45:30.500)")
else:
print(" Warning: No schedule text was generated")
print("\nExample completed.")
if __name__ == "__main__":
main()
+304 -72
View File
@@ -22,7 +22,8 @@ File format grammar, version 2.0 (EBNF-ish)::
document = header , { statement } ;
header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *)
statement = unit_directive | declaration | well_block_open | event_line ;
statement = unit_directive | declaration | well_block_open
| schedule_block_open | event_line ;
unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ;
declaration = date_decl | duration_decl | well_decl ;
@@ -30,14 +31,17 @@ File format grammar, version 2.0 (EBNF-ish)::
duration_decl = "DURATION" , ident , "=" , duration_expr ; (* DURATION RAMP = 5 DAYS *)
well_decl = "WELL" , ident , "=" , quoted_string ; (* WELL A1 = "55_33-A-1" *)
well_block_open = "WELL" , ( quoted_string | ident ) ; (* no "=" present *)
well_block_open = "WELL" , ( quoted_string | ident ) ; (* no "=" present *)
schedule_block_open = "SCHEDULE" ; (* well-less keyword events *)
event_line = "@" , date_expr , event_type , { attribute } ;
date_expr = ( iso_date | date_ident ) , { sign , term } ;
date_expr = ( iso_date | iso_datetime | date_ident ) , { sign , term } ;
duration_expr = ( integer | dur_ident ) , { sign , term } , [ "DAYS" | "days" ] ;
term = integer | dur_ident ; (* whole days *)
sign = "+" | "-" ;
iso_date = 4digit , "-" , 2digit , "-" , 2digit ;
iso_datetime = iso_date , "T" , 2digit , ":" , 2digit , ":" , 2digit ,
[ "." , digits ] ; (* 2024-05-15T14:45:30.500 *)
ident = letter_or_underscore , { word_char } ;
attribute = ident , "=" , ( quoted_string | bareword ) ;
comment = "#" , rest-of-line ; (* line or trailing *)
@@ -45,9 +49,9 @@ File format grammar, version 2.0 (EBNF-ish)::
Notes on the grammar:
* The format is line-oriented; every non-blank line is dispatched on its first
token: ``ORIONEVENTS`` (once), ``UNIT``, ``DATE``, ``DURATION``, ``WELL`` or
``@``. Anything else is an error. Keywords are uppercase and case-sensitive
(the ``DAYS`` suffix is also accepted as ``days``).
token: ``ORIONEVENTS`` (once), ``UNIT``, ``DATE``, ``DURATION``, ``WELL``,
``SCHEDULE`` or ``@``. Anything else is an error. Keywords are uppercase and
case-sensitive (the ``DAYS`` suffix is also accepted as ``days``).
* Comments start with ``#`` (outside of double quotes) and run to end of line.
* Variables are **typed**: ``DATE``, ``DURATION`` (whole days) and ``WELL``
(well-name alias) declarations share one namespace and must precede use.
@@ -56,16 +60,26 @@ Notes on the grammar:
last value wins; redeclaring with a different type is an error.
* Date arithmetic is a chain of signed whole-day terms, each an integer or a
``DURATION`` variable: ``@START + RAMP - 2``. Whitespace around ``+``/``-``
is optional but conventional.
is optional but conventional. An event date may carry a time-of-day
(``@2024-05-15T14:45:30.500``), which the schedule generator preserves as
the optional TIME field of the DATES keyword.
* ``WELL <ident>`` opens an event block for a declared ``WELL`` alias;
``WELL "<name>"`` opens a block for the literal well name and never consults
variables. A ``WELL`` line containing ``=`` is always a declaration. Empty
well blocks are legal.
variables. A ``WELL`` line containing ``=`` is always a declaration. A bare
``SCHEDULE`` line opens a block of schedule-level keyword events not tied to
any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal.
* Double quotes are used everywhere: well names and attribute values, e.g.
``FILTER="SOIL(0) > 0.8 AND PERMX > 200"``.
* Every attribute is ``KEY=VALUE``; bare positional tokens are rejected. Any
key parses; keys the applier does not support yet (``FILTER``, ``PERFID``,
``DSHIFT``) are ignored with a warning when the document is applied.
* Every attribute is ``KEY=VALUE``; bare positional tokens are rejected.
* Event types inside a WELL block are either the built-in completion events
``PERFORATION``, ``TUBING``, ``VALVE`` and ``STATE``, or any Eclipse well
keyword (``WCONHIST``, ``WELTARG``, ``WRFTPLT``, ``WCONPROD``, ...), which
is passed through generically with the well name injected as WELL. Event
types inside a SCHEDULE block are Eclipse schedule keywords passed through
as-is. An event type that closely resembles a built-in is treated as a typo
per the ``on_unknown_event`` policy instead of being passed through.
* Any attribute key parses; keys the applier does not support yet (``FILTER``,
``PERFID``, ``DSHIFT``) are ignored with a warning when applied.
* The parser recovers per line and reports **all** errors in one pass: the
raised :class:`OrionParseError` carries one :class:`ParseIssue` per problem.
Unknown names come with "did you mean" suggestions where possible.
@@ -80,7 +94,7 @@ import difflib
import os
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Union
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from .exception import RipsError
@@ -143,10 +157,14 @@ class ParseWarning:
@dataclass(frozen=True)
class OrionValue:
"""A typed variable: kind is ``DATE``, ``DURATION`` or ``WELL``."""
"""A typed variable: kind is ``DATE``, ``DURATION`` or ``WELL``.
A ``DATE`` value is a :class:`datetime.date`, or a :class:`datetime.datetime`
when declared with a time-of-day.
"""
kind: str
value: Union[datetime.date, int, str]
value: Union[datetime.date, datetime.datetime, int, str]
loc: SourceLoc
@@ -161,10 +179,10 @@ class AttrValue:
@dataclass
class OrionEvent:
"""One event line: a dated action on the enclosing well."""
"""One event line: a dated action on the enclosing WELL or SCHEDULE block."""
event_type: str
event_date: datetime.date
event_date: Union[datetime.date, datetime.datetime]
attributes: Dict[str, AttrValue]
loc: SourceLoc
@@ -186,6 +204,7 @@ class OrionDocument:
unit_system: str = "METRIC"
variables: Dict[str, OrionValue] = field(default_factory=dict)
wells: List[WellBlock] = field(default_factory=list)
schedule_events: List[OrionEvent] = field(default_factory=list)
warnings: List[ParseWarning] = field(default_factory=list)
@@ -193,10 +212,11 @@ class OrionDocument:
# Layer A: pure parser
# ---------------------------------------------------------------------------
_KEYWORDS = ("ORIONEVENTS", "UNIT", "DATE", "DURATION", "WELL")
_KEYWORDS = ("ORIONEVENTS", "UNIT", "DATE", "DURATION", "WELL", "SCHEDULE")
_IDENT = r"[A-Za-z_]\w*"
_DATE_BASE = rf"(?P<base>\d{{4}}-\d{{2}}-\d{{2}}|{_IDENT})"
_ISO_DATE = r"\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?)?"
_DATE_BASE = rf"(?P<base>{_ISO_DATE}|{_IDENT})"
_TERMS = rf"(?P<terms>(?:\s*[-+]\s*(?:\d+|{_IDENT}))*)"
_HEADER_RE = re.compile(r"^ORIONEVENTS\s+(?P<version>\d+\.\d+)$")
@@ -230,9 +250,12 @@ def parse_orion_events(text: str) -> OrionDocument:
unit_holder = ["METRIC"] # mutable so _parse_line can update it
variables: Dict[str, OrionValue] = {}
wells: List[WellBlock] = []
schedule_events: List[OrionEvent] = []
warnings: List[ParseWarning] = []
errors: List[ParseIssue] = []
current_well: Optional[WellBlock] = None
# Event lines append to the current sink: a WellBlock's event list or the
# document-level schedule_events list.
current_events: Optional[List[OrionEvent]] = None
for lineno, raw_line in enumerate(text.splitlines(), start=1):
loc = SourceLoc(line=lineno, text=raw_line)
@@ -253,17 +276,24 @@ def parse_orion_events(text: str) -> OrionDocument:
continue
try:
current_well = _parse_line(
line, loc, variables, wells, warnings, current_well, unit_holder
current_events = _parse_line(
line,
loc,
variables,
wells,
schedule_events,
warnings,
current_events,
unit_holder,
)
except OrionParseError as exc:
errors.extend(exc.errors)
if line.split(None, 1)[0] == "WELL" or (
line.startswith("@") and current_well is None
if line.split(None, 1)[0] in ("WELL", "SCHEDULE") or (
line.startswith("@") and current_events is None
):
# Suppress cascading errors from lines belonging to a broken
# (or missing) block: swallow them into a discarded block.
current_well = WellBlock(well_name="", loc=loc)
# (or missing) block: swallow them into a discarded list.
current_events = []
if version is None:
raise OrionParseError("Empty file: missing 'ORIONEVENTS' header")
@@ -276,6 +306,7 @@ def parse_orion_events(text: str) -> OrionDocument:
unit_system=unit_holder[0],
variables=variables,
wells=wells,
schedule_events=schedule_events,
warnings=warnings,
)
@@ -302,16 +333,19 @@ def _parse_line(
loc: SourceLoc,
variables: Dict[str, OrionValue],
wells: List[WellBlock],
schedule_events: List[OrionEvent],
warnings: List[ParseWarning],
current_well: Optional[WellBlock],
current_events: Optional[List[OrionEvent]],
unit_holder: List[str],
) -> Optional[WellBlock]:
"""Dispatch one non-header line; returns the (possibly new) current well."""
) -> Optional[List[OrionEvent]]:
"""Dispatch one non-header line; returns the current event sink."""
if line.startswith("@"):
if current_well is None:
raise OrionParseError("Event line found before any WELL block", loc)
current_well.events.append(_parse_event_line(line, variables, loc))
return current_well
if current_events is None:
raise OrionParseError(
"Event line found before any WELL or SCHEDULE block", loc
)
current_events.append(_parse_event_line(line, variables, loc))
return current_events
first = line.split(None, 1)[0]
@@ -322,7 +356,14 @@ def _parse_line(
f"Malformed UNIT line: {line!r} (expected UNIT METRIC|FIELD|LAB)", loc
)
unit_holder[0] = match.group("unit")
return current_well
return current_events
if first == "SCHEDULE":
if line != "SCHEDULE":
raise OrionParseError(
f"Malformed SCHEDULE line: {line!r} (SCHEDULE takes no arguments)", loc
)
return schedule_events
if first == "DATE":
match = _DATE_DECL_RE.match(line)
@@ -342,7 +383,7 @@ def _parse_line(
warnings,
loc,
)
return current_well
return current_events
if first == "DURATION":
match = _DURATION_DECL_RE.match(line)
@@ -362,7 +403,7 @@ def _parse_line(
warnings,
loc,
)
return current_well
return current_events
if first == "WELL":
decl_match = _WELL_DECL_RE.match(line)
@@ -374,7 +415,7 @@ def _parse_line(
warnings,
loc,
)
return current_well
return current_events
block_match = _WELL_BLOCK_RE.match(line)
if block_match is None:
raise OrionParseError(
@@ -389,7 +430,7 @@ def _parse_line(
)
new_well = WellBlock(well_name=name, loc=loc)
wells.append(new_well)
return new_well
return new_well.events
raise OrionParseError(_unrecognized_line_message(line, first), loc)
@@ -474,11 +515,15 @@ def _eval_terms(terms: str, variables: Dict[str, OrionValue], loc: SourceLoc) ->
def _eval_date_expr(
base: str, terms: str, variables: Dict[str, OrionValue], loc: SourceLoc
) -> datetime.date:
"""Evaluate an ISO date or DATE variable plus optional signed day terms."""
) -> Union[datetime.date, datetime.datetime]:
"""Evaluate an ISO date(time) or DATE variable plus optional signed day terms."""
result: Union[datetime.date, datetime.datetime]
if base[0].isdigit():
try:
result = datetime.date.fromisoformat(base)
if "T" in base:
result = datetime.datetime.fromisoformat(base)
else:
result = datetime.date.fromisoformat(base)
except ValueError as exc:
raise OrionParseError(f"Invalid date '{base}': {exc}", loc)
else:
@@ -594,16 +639,39 @@ _POLICIES = ("warn", "error", "skip")
# Attributes accepted on a keyword event but intentionally not emitted.
_IGNORED_KEYWORD_ATTRS = {"DSHIFT", "FILTER", "PERFID"}
# PERFORATION attribute handling.
# Completion event attribute handling: (required, known-optional) per type.
# FILTER/PERFID are accepted on any completion event but ignored with a warning.
_PERF_REQUIRED = ("MDSTART", "MDEND")
_PERF_KNOWN = {"MDSTART", "MDEND", "RADIUS", "SKIN", "COMPLETION_NUMBER"}
_PERF_IGNORED = {"FILTER", "PERFID"}
_TUBING_REQUIRED = ("MDSTART", "MDEND")
_TUBING_KNOWN = {"MDSTART", "MDEND", "INNER_DIAMETER", "ROUGHNESS"}
_VALVE_REQUIRED = ("MD", "TYPE")
_VALVE_KNOWN = {"MD", "TYPE", "STATE", "CV", "AREA"} | {
"AICD_STRENGTH",
"AICD_DENSITY_CALIB_FLUID",
"AICD_VISCOSITY_CALIB_FLUID",
"AICD_VOL_FLOW_EXP",
"AICD_VISC_FUNC_EXP",
}
_STATE_REQUIRED = ("STATE",)
_STATE_KNOWN = {"STATE"}
_COMPLETION_IGNORED = {"FILTER", "PERFID"}
_PERF_IGNORED = _COMPLETION_IGNORED # backwards-compatible alias
# ORIONEVENTS -> Eclipse item-name translations per keyword.
_WCONHIST_FIELD_MAP = {"VFP": "VFP_TABLE"}
_WELTARG_FIELD_MAP = {"VALUE": "NEW_VALUE"}
def _iso_event_date(event_date: Union[datetime.date, datetime.datetime]) -> str:
"""Format an event date for the timeline API, keeping ms time-of-day."""
if isinstance(event_date, datetime.datetime):
if event_date.microsecond:
return event_date.isoformat(timespec="milliseconds")
return event_date.isoformat()
return event_date.isoformat()
def apply_orion_events_file(
path: Union[str, "os.PathLike[str]"],
timeline: Any,
@@ -631,7 +699,9 @@ def apply_orion_document(
project: A ``rips.Project`` used to resolve well names to well paths.
on_unknown_well: ``"warn"`` (default), ``"error"`` or ``"skip"`` -
behavior when a well name has no matching well path.
on_unknown_event: same policy values, for unknown event types.
on_unknown_event: same policy values, for event types that closely
resemble a misspelled built-in event type. Other unrecognized
event types are passed through as generic Eclipse keywords.
Returns:
ApplyReport: counts plus collected warnings/errors.
@@ -652,63 +722,119 @@ def apply_orion_document(
continue
for event in well.events:
dispatch = _EVENT_DISPATCH.get(event.event_type.upper())
event_type = event.event_type.upper()
dispatch = _EVENT_DISPATCH.get(event_type)
if dispatch is None:
message = (
f"Unknown event type '{event.event_type}' (line {event.loc.line})"
)
close = difflib.get_close_matches(
event.event_type.upper(), _EVENT_DISPATCH, n=1, cutoff=0.6
)
if close:
message += f"; did you mean '{close[0]}'?"
if on_unknown_event == "error":
raise RipsError(message)
if on_unknown_event == "warn":
report.warnings.append(message)
report.events_skipped += 1
continue
typo_of = _suspected_typo(event_type)
if typo_of is not None:
message = (
f"Unknown event type '{event.event_type}' "
f"(line {event.loc.line}); did you mean '{typo_of}'?"
)
if on_unknown_event == "error":
raise RipsError(message)
if on_unknown_event == "warn":
report.warnings.append(message)
report.events_skipped += 1
continue
dispatch = _apply_generic_well_keyword
dispatch(event, well_path, timeline, report)
for event in document.schedule_events:
_apply_schedule_event(event, timeline, report)
return report
def _suspected_typo(event_type: str) -> Optional[str]:
"""Return the built-in event type this one looks like a misspelling of."""
close = difflib.get_close_matches(event_type, _EVENT_DISPATCH, n=1, cutoff=0.8)
return close[0] if close else None
def _apply_schedule_event(
event: OrionEvent, timeline: Any, report: ApplyReport
) -> None:
"""Apply one SCHEDULE-block event as a schedule-level Eclipse keyword."""
event_type = event.event_type.upper()
if event_type in _COMPLETION_EVENT_TYPES:
report.errors.append(
f"Line {event.loc.line}: {event_type} is a completion event and "
"needs a WELL block, not SCHEDULE"
)
report.events_skipped += 1
return
keyword_data: Dict[str, Any] = {}
for key, attr in event.attributes.items():
if key in _IGNORED_KEYWORD_ATTRS:
report.warnings.append(
f"Line {event.loc.line}: attribute '{key}' on {event_type} "
"is ignored (not yet supported)"
)
continue
keyword_data[key] = attr.value
timeline.add_keyword_event(
event_date=_iso_event_date(event.event_date),
keyword_name=event_type,
keyword_data=keyword_data,
)
report.events_applied += 1
def _validate_policy(value: str, name: str) -> None:
if value not in _POLICIES:
raise ValueError(f"{name} must be one of {_POLICIES}, got {value!r}")
def _apply_perforation(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
def _check_completion_attrs(
event: OrionEvent,
type_name: str,
known: Set[str],
required: Tuple[str, ...],
report: ApplyReport,
) -> bool:
"""Validate a completion event's attributes; False means skip the event."""
attrs = event.attributes
unknown = set(attrs) - _PERF_KNOWN - _PERF_IGNORED
unknown = set(attrs) - known - _COMPLETION_IGNORED
if unknown:
report.errors.append(
f"Line {event.loc.line}: unknown PERFORATION attribute(s): "
f"Line {event.loc.line}: unknown {type_name} attribute(s): "
f"{', '.join(sorted(unknown))}"
)
report.events_skipped += 1
return
return False
for key in sorted(_PERF_IGNORED & set(attrs)):
for key in sorted(_COMPLETION_IGNORED & set(attrs)):
report.warnings.append(
f"Line {event.loc.line}: attribute '{key}' on PERFORATION "
f"Line {event.loc.line}: attribute '{key}' on {type_name} "
"is ignored (not yet supported)"
)
missing = [name for name in _PERF_REQUIRED if name not in attrs]
missing = [name for name in required if name not in attrs]
if missing:
report.errors.append(
f"Line {event.loc.line}: PERFORATION missing required "
f"Line {event.loc.line}: {type_name} missing required "
f"attribute(s): {', '.join(missing)}"
)
report.events_skipped += 1
return False
return True
def _apply_perforation(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
if not _check_completion_attrs(
event, "PERFORATION", _PERF_KNOWN, _PERF_REQUIRED, report
):
return
attrs = event.attributes
try:
kwargs: Dict[str, Any] = {
"event_date": event.event_date.isoformat(),
"event_date": _iso_event_date(event.event_date),
"well_path": well_path,
"start_md": float(_as_number(attrs["MDSTART"], event.loc)),
"end_md": float(_as_number(attrs["MDEND"], event.loc)),
@@ -731,6 +857,95 @@ def _apply_perforation(
report.events_applied += 1
def _apply_tubing(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
if not _check_completion_attrs(
event, "TUBING", _TUBING_KNOWN, _TUBING_REQUIRED, report
):
return
attrs = event.attributes
try:
kwargs: Dict[str, Any] = {
"event_date": _iso_event_date(event.event_date),
"well_path": well_path,
"start_md": float(_as_number(attrs["MDSTART"], event.loc)),
"end_md": float(_as_number(attrs["MDEND"], event.loc)),
}
if "INNER_DIAMETER" in attrs:
kwargs["inner_diameter"] = float(
_as_number(attrs["INNER_DIAMETER"], event.loc)
)
if "ROUGHNESS" in attrs:
kwargs["roughness"] = float(_as_number(attrs["ROUGHNESS"], event.loc))
except OrionParseError as exc:
report.errors.append(str(exc))
report.events_skipped += 1
return
timeline.add_tubing_event(**kwargs)
report.events_applied += 1
# VALVE attribute -> add_valve_event keyword-argument name (numeric values).
_VALVE_NUMERIC_KWARGS = {
"CV": "flow_coefficient",
"AREA": "area",
"AICD_STRENGTH": "aicd_strength",
"AICD_DENSITY_CALIB_FLUID": "aicd_density_calib_fluid",
"AICD_VISCOSITY_CALIB_FLUID": "aicd_viscosity_calib_fluid",
"AICD_VOL_FLOW_EXP": "aicd_vol_flow_exp",
"AICD_VISC_FUNC_EXP": "aicd_visc_func_exp",
}
def _apply_valve(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
if not _check_completion_attrs(
event, "VALVE", _VALVE_KNOWN, _VALVE_REQUIRED, report
):
return
attrs = event.attributes
try:
kwargs: Dict[str, Any] = {
"event_date": _iso_event_date(event.event_date),
"well_path": well_path,
"measured_depth": float(_as_number(attrs["MD"], event.loc)),
"valve_type": str(attrs["TYPE"].value),
}
if "STATE" in attrs:
kwargs["state"] = str(attrs["STATE"].value)
for attr_name, kwarg_name in _VALVE_NUMERIC_KWARGS.items():
if attr_name in attrs:
kwargs[kwarg_name] = float(_as_number(attrs[attr_name], event.loc))
except OrionParseError as exc:
report.errors.append(str(exc))
report.events_skipped += 1
return
timeline.add_valve_event(**kwargs)
report.events_applied += 1
def _apply_state(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
if not _check_completion_attrs(
event, "STATE", _STATE_KNOWN, _STATE_REQUIRED, report
):
return
timeline.add_state_event(
event_date=_iso_event_date(event.event_date),
well_path=well_path,
well_state=str(event.attributes["STATE"].value),
)
report.events_applied += 1
def _apply_keyword(
event: OrionEvent,
well_path: Any,
@@ -750,7 +965,7 @@ def _apply_keyword(
keyword_data[field_map.get(key, key)] = attr.value
timeline.add_well_keyword_event(
event_date=event.event_date.isoformat(),
event_date=_iso_event_date(event.event_date),
well_path=well_path,
keyword_name=keyword_name,
keyword_data=keyword_data,
@@ -776,14 +991,30 @@ def _as_number(attr: AttrValue, loc: SourceLoc) -> Union[int, float]:
return attr.value
def _apply_generic_well_keyword(
event: OrionEvent, well_path: Any, timeline: Any, report: ApplyReport
) -> None:
_apply_keyword(event, well_path, timeline, report, event.event_type.upper(), {})
_EventDispatch = Callable[[OrionEvent, Any, Any, ApplyReport], None]
# Built-in event types. Any other event type inside a WELL block is passed
# through as a generic Eclipse well keyword (unless it looks like a typo of a
# built-in, which is governed by the on_unknown_event policy).
_EVENT_DISPATCH: Dict[str, _EventDispatch] = {
"PERFORATION": _apply_perforation,
"TUBING": _apply_tubing,
"VALVE": _apply_valve,
"STATE": _apply_state,
"WCONHIST": _apply_wconhist,
"WELTARG": _apply_weltarg,
}
# Completion event types that require a well and cannot appear in a SCHEDULE
# block or be emitted as Eclipse keywords.
_COMPLETION_EVENT_TYPES = ("PERFORATION", "TUBING", "VALVE", "STATE")
# ---------------------------------------------------------------------------
# Standalone validator CLI: python3 -m rips.orion_events <file.orion>
@@ -821,7 +1052,8 @@ def _cli(argv: Optional[List[str]] = None) -> int:
)
print(
f" {len(document.variables)} variable(s), {len(document.wells)} "
f"well block(s), {event_count} event(s)"
f"well block(s), {event_count} well event(s), "
f"{len(document.schedule_events)} schedule event(s)"
)
for warning in document.warnings:
print(f" Warning line {warning.loc.line}: {warning.message}")
@@ -73,6 +73,10 @@ class FakeTimeline:
def __init__(self):
self.perf_calls = []
self.keyword_calls = []
self.tubing_calls = []
self.valve_calls = []
self.state_calls = []
self.schedule_keyword_calls = []
def add_perf_event(self, **kwargs):
self.perf_calls.append(kwargs)
@@ -80,6 +84,18 @@ class FakeTimeline:
def add_well_keyword_event(self, **kwargs):
self.keyword_calls.append(kwargs)
def add_tubing_event(self, **kwargs):
self.tubing_calls.append(kwargs)
def add_valve_event(self, **kwargs):
self.valve_calls.append(kwargs)
def add_state_event(self, **kwargs):
self.state_calls.append(kwargs)
def add_keyword_event(self, **kwargs):
self.schedule_keyword_calls.append(kwargs)
# ---------------------------------------------------------------------------
# Layer A: parsing
@@ -223,7 +239,7 @@ class TestParsing:
parse_orion_events('UNIT METRIC\nWELL "W"\n')
def test_event_before_well_block_raises(self):
with pytest.raises(OrionParseError, match="before any WELL block"):
with pytest.raises(OrionParseError, match="before any WELL or SCHEDULE block"):
parse_orion_events(
"ORIONEVENTS 2.0\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2\n"
)
@@ -293,6 +309,56 @@ class TestParsing:
assert any("Duplicate" in w.message for w in doc.warnings)
assert doc.variables["X"].value == datetime.date(2019, 1, 1)
def test_schedule_block_parses(self):
text = (
'ORIONEVENTS 2.0\nWELL "W"\n'
" @2024-01-01 WCONHIST STATUS=OPEN\n"
"SCHEDULE\n"
" @2024-01-01 RPTRST BASIC=2 FREQ=1\n"
" @2024-01-01 GRUPTREE CHILD=OP PARENT=FIELD\n"
'WELL "W"\n'
" @2024-02-01 WELTARG CMODE=ORAT VALUE=5000\n"
)
doc = parse_orion_events(text)
assert [e.event_type for e in doc.schedule_events] == ["RPTRST", "GRUPTREE"]
assert doc.schedule_events[0].attributes["BASIC"].value == 2
# WELL after SCHEDULE switches the sink back to the well block.
assert [len(w.events) for w in doc.wells] == [1, 1]
def test_schedule_line_with_arguments_rejected(self):
with pytest.raises(OrionParseError, match="SCHEDULE takes no arguments"):
parse_orion_events("ORIONEVENTS 2.0\nSCHEDULE NOW\n")
def test_datetime_literal_event(self):
text = (
'ORIONEVENTS 2.0\nWELL "W"\n'
" @2024-05-15T14:45:30.500 PERFORATION MDSTART=1 MDEND=2\n"
)
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.datetime(
2024, 5, 15, 14, 45, 30, 500000
)
def test_datetime_with_day_offset(self):
text = (
'ORIONEVENTS 2.0\nWELL "W"\n'
" @2024-05-15T14:45:30 + 2 PERFORATION MDSTART=1 MDEND=2\n"
)
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.datetime(
2024, 5, 17, 14, 45, 30
)
def test_date_variable_with_time_of_day(self):
text = (
"ORIONEVENTS 2.0\nDATE T0 = 2024-05-15T14:45:30.500\n"
'WELL "W"\n @T0 + 1 PERFORATION MDSTART=1 MDEND=2\n'
)
doc = parse_orion_events(text)
assert doc.wells[0].events[0].event_date == datetime.datetime(
2024, 5, 16, 14, 45, 30, 500000
)
class TestDiagnostics:
def test_all_errors_reported_in_one_pass(self):
@@ -344,7 +410,7 @@ class TestValidatorCli:
assert _cli([path]) == 0
out = capsys.readouterr().out
assert "OK" in out
assert "4 variable(s), 2 well block(s), 4 event(s)" in out
assert "4 variable(s), 2 well block(s), 4 well event(s)" in out
def test_invalid_file_exits_nonzero_with_errors(self, tmp_path, capsys):
path = self._write(
@@ -484,6 +550,98 @@ class TestApplying:
assert report.events_skipped == 1
assert any("ZZZ" in e for e in report.errors)
def test_tubing_mapping(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2024-01-01 TUBING MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5\n"
)
timeline, report = self._apply(text)
assert report.events_applied == 1
call = timeline.tubing_calls[0]
assert call["start_md"] == 0.0
assert call["end_md"] == 2500.0
assert call["inner_diameter"] == 0.15
assert call["roughness"] == pytest.approx(1.0e-5)
def test_valve_mapping(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2024-03-01 VALVE MD=2100 TYPE=ICV STATE=OPEN CV=0.7 AREA=0.0001\n"
)
timeline, report = self._apply(text)
assert report.events_applied == 1
call = timeline.valve_calls[0]
assert call["measured_depth"] == 2100.0
assert call["valve_type"] == "ICV"
assert call["state"] == "OPEN"
assert call["flow_coefficient"] == 0.7
assert call["area"] == pytest.approx(0.0001)
def test_valve_missing_type_is_error(self):
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2024-03-01 VALVE MD=2100\n'
_, report = self._apply(text)
assert report.events_skipped == 1
assert any("TYPE" in e for e in report.errors)
def test_state_mapping(self):
text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2024-02-15 STATE STATE=SHUT\n'
timeline, report = self._apply(text)
assert report.events_applied == 1
assert timeline.state_calls[0]["well_state"] == "SHUT"
def test_generic_well_keyword_pass_through(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO\n"
)
timeline, report = self._apply(text)
assert report.events_applied == 1
assert report.warnings == []
call = timeline.keyword_calls[0]
assert call["keyword_name"] == "WRFTPLT"
assert call["keyword_data"]["WELL"] == "55_33-A-1"
assert call["keyword_data"]["OUTPUT_RFT"] == "YES"
def test_typo_of_builtin_is_not_passed_through(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2024-01-01 PERFORATIN MDSTART=1 MDEND=2\n"
)
timeline, report = self._apply(text)
assert report.events_skipped == 1
assert timeline.keyword_calls == []
assert any("did you mean 'PERFORATION'" in w for w in report.warnings)
def test_schedule_events_applied_without_well(self):
text = (
"ORIONEVENTS 2.0\nSCHEDULE\n"
" @2024-01-01 RPTRST BASIC=2 FREQ=1\n"
" @2024-01-01 TUNING TSINIT=1 TSMAXZ=30\n"
)
timeline, report = self._apply(text)
assert report.events_applied == 2
rptrst = timeline.schedule_keyword_calls[0]
assert rptrst["keyword_name"] == "RPTRST"
assert rptrst["keyword_data"] == {"BASIC": 2, "FREQ": 1}
assert "WELL" not in rptrst["keyword_data"]
def test_completion_event_in_schedule_block_is_error(self):
text = (
"ORIONEVENTS 2.0\nSCHEDULE\n @2024-01-01 PERFORATION MDSTART=1 MDEND=2\n"
)
timeline, report = self._apply(text)
assert report.events_skipped == 1
assert timeline.schedule_keyword_calls == []
assert any("needs a WELL block" in e for e in report.errors)
def test_datetime_event_date_keeps_milliseconds(self):
text = (
'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n'
" @2024-05-15T14:45:30.500 PERFORATION MDSTART=1 MDEND=2\n"
)
timeline, _ = self._apply(text)
assert timeline.perf_calls[0]["event_date"] == "2024-05-15T14:45:30.500"
def test_invalid_policy_value_raises(self):
doc = parse_orion_events(SAMPLE)
with pytest.raises(ValueError):
@@ -557,6 +715,54 @@ class TestOrionEventsIntegration:
assert "WCONHIST" in schedule
assert "WELTARG" in schedule
def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_wells):
"""All event kinds from well_event_schedule.py expressed as ORIONEVENTS."""
project, case, timeline = project_with_case_and_wells
well = next(wp for wp in project.well_paths() if "A" in wp.name)
text = (
"ORIONEVENTS 2.0\n"
"UNIT METRIC\n"
"DATE STARTUP = 2024-01-01\n"
"DURATION RAMP = 31 DAYS\n"
f'WELL "{well.name}"\n'
" @STARTUP TUBING MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5\n"
" @STARTUP + RAMP PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1\n"
" @2024-05-15T14:45:30.500 PERFORATION MDSTART=2300 MDEND=2350 RADIUS=0.05 SKIN=0.4 COMPLETION_NUMBER=2\n"
" @2024-03-01 VALVE MD=2100 TYPE=ICV STATE=OPEN CV=0.7 AREA=0.0001\n"
" @2024-02-15 STATE STATE=OPEN\n"
" @2024-01-15 WCONHIST STATUS=OPEN CMODE=RESV ORAT=3999.99 VFP=1\n"
" @2024-05-01 WELTARG CMODE=ORAT VALUE=5000.0\n"
" @2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO\n"
"SCHEDULE\n"
" @STARTUP RPTRST BASIC=2 FREQ=1\n"
" @STARTUP GRUPTREE CHILD=OP PARENT=FIELD\n"
" @STARTUP TUNING TSINIT=1 TSMAXZ=30 NEWTMX=12\n"
)
document = parse_orion_events(text)
report = apply_orion_document(document, timeline, project)
assert report.errors == []
assert report.warnings == []
assert report.events_applied == 11
timeline.set_timestamp(timestamp="2024-12-24")
schedule = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[well]
)
for keyword in (
"COMPDAT",
"COMPLUMP",
"WCONHIST",
"WELTARG",
"WRFTPLT",
"RPTRST",
"GRUPTREE",
"TUNING",
):
assert keyword in schedule, f"{keyword} missing from schedule"
# The datetime perforation must surface as a DATES entry with TIME.
assert "14:45:30.500" in schedule
def test_apply_unknown_well_warns_and_applies_nothing(
self, project_with_case_and_wells
):