mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-25 22:50:25 -05:00
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:
1 parent
03236ee737
commit
de942cc8aa
3 files changed
+662
-74
No files matched your search
+150
@@ -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()
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user