#14533 Orion events: Add group sections

This commit is contained in:
Kristian Bendiksen
2026-08-19 10:28:35 +02:00
parent 3932ac4519
commit d57abe525d
2 changed files with 136 additions and 13 deletions
+56 -13
View File
@@ -23,7 +23,7 @@ File format grammar, version 2.0 (EBNF-ish)::
document = header , { statement } ;
header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *)
statement = unit_directive | declaration | report_line | well_block_open
| schedule_block_open | event_line ;
| group_block_open | schedule_block_open | event_line ;
unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ;
report_line = "REPORT" , date_expr ; (* REPORT 2024-06-01 *)
@@ -39,6 +39,7 @@ File format grammar, version 2.0 (EBNF-ish)::
comp_op = ">" | ">=" | "<" | "<=" ;
well_block_open = "WELL" , ( quoted_string | ident ) ; (* no "=" present *)
group_block_open = "GROUP" , quoted_string ; (* group keyword events *)
schedule_block_open = "SCHEDULE" ; (* well-less keyword events *)
event_line = "@" , date_expr , event_type , { attribute } ;
@@ -57,8 +58,8 @@ 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``,
``SCHEDULE`` or ``@``. Anything else is an error. Keywords are uppercase and
case-sensitive (the ``DAYS`` suffix is also accepted as ``days``).
``GROUP``, ``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), ``WELL``
(well-name alias) and ``FILTER`` (cell filter expression) declarations share
@@ -74,8 +75,10 @@ Notes on the grammar:
* ``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. A bare
``SCHEDULE`` line opens a block of schedule-level keyword events not tied to
any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal.
``GROUP "<name>"`` opens a block of group-level Eclipse keyword events; the
group name is injected as the ``GROUP`` item when each event is applied.
A bare ``SCHEDULE`` line opens a block of schedule-level keyword events not
tied to any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal.
* ``REPORT <date_expr>`` (one date per line, anywhere after the header) names
a date that should appear as a bare ``DATES`` keyword in the generated
schedule even when no events fall on it — in Eclipse/Flow a ``DATES`` entry
@@ -91,9 +94,11 @@ Notes on the grammar:
``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.
types inside a GROUP block are Eclipse group keywords with the group name
injected as GROUP. 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.
* On a PERFORATION event, ``FILTER=<name>`` references a declared ``FILTER``
variable and ``FILTER="<expr>"`` is an inline anonymous filter expression.
The applier materializes each used filter as a case-level combined data
@@ -242,7 +247,7 @@ class AttrValue:
@dataclass
class OrionEvent:
"""One event line: a dated action on the enclosing WELL or SCHEDULE block."""
"""One event line in an enclosing WELL, GROUP or SCHEDULE block."""
event_type: str
event_date: Union[datetime.date, datetime.datetime]
@@ -260,6 +265,15 @@ class WellBlock:
loc: SourceLoc = SourceLoc(0, "")
@dataclass
class GroupBlock:
"""A ``GROUP`` block header followed by its keyword events."""
group_name: str
events: List[OrionEvent] = field(default_factory=list)
loc: SourceLoc = SourceLoc(0, "")
@dataclass
class OrionDocument:
"""Parsed, lossless representation of an ORIONEVENTS file."""
@@ -268,6 +282,7 @@ class OrionDocument:
unit_system: str = "METRIC"
variables: Dict[str, OrionValue] = field(default_factory=dict)
wells: List[WellBlock] = field(default_factory=list)
groups: List[GroupBlock] = field(default_factory=list)
schedule_events: List[OrionEvent] = field(default_factory=list)
report_dates: List[Union[datetime.date, datetime.datetime]] = field(
default_factory=list
@@ -286,6 +301,7 @@ _KEYWORDS = (
"DURATION",
"WELL",
"FILTER",
"GROUP",
"SCHEDULE",
"REPORT",
)
@@ -320,6 +336,7 @@ _RESULT_TYPE_ALIASES = {
"GENERATED": "GENERATED",
}
_WELL_BLOCK_RE = re.compile(rf'^WELL\s+(?:"(?P<qname>[^"]*)"|(?P<ref>{_IDENT}))$')
_GROUP_BLOCK_RE = re.compile(r'^GROUP\s+"(?P<name>[^"]*)"$')
_EVENT_RE = re.compile(rf"^@\s*{_DATE_BASE}{_TERMS}\s+(?P<rest>.+)$")
_TERM_RE = re.compile(rf"([-+])\s*(\d+|{_IDENT})")
_ATTR_RE = re.compile(r'(?P<key>[A-Za-z_]\w*)\s*=\s*(?:"(?P<qval>[^"]*)"|(?P<val>\S+))')
@@ -342,6 +359,7 @@ def parse_orion_events(text: str) -> OrionDocument:
unit_holder = ["METRIC"] # mutable so _parse_line can update it
variables: Dict[str, OrionValue] = {}
wells: List[WellBlock] = []
groups: List[GroupBlock] = []
schedule_events: List[OrionEvent] = []
report_dates: List[Union[datetime.date, datetime.datetime]] = []
warnings: List[ParseWarning] = []
@@ -374,6 +392,7 @@ def parse_orion_events(text: str) -> OrionDocument:
loc,
variables,
wells,
groups,
schedule_events,
report_dates,
warnings,
@@ -382,7 +401,7 @@ def parse_orion_events(text: str) -> OrionDocument:
)
except OrionParseError as exc:
errors.extend(exc.errors)
if line.split(None, 1)[0] in ("WELL", "SCHEDULE") or (
if line.split(None, 1)[0] in ("WELL", "GROUP", "SCHEDULE") or (
line.startswith("@") and current_events is None
):
# Suppress cascading errors from lines belonging to a broken
@@ -400,6 +419,7 @@ def parse_orion_events(text: str) -> OrionDocument:
unit_system=unit_holder[0],
variables=variables,
wells=wells,
groups=groups,
schedule_events=schedule_events,
report_dates=report_dates,
warnings=warnings,
@@ -428,6 +448,7 @@ def _parse_line(
loc: SourceLoc,
variables: Dict[str, OrionValue],
wells: List[WellBlock],
groups: List[GroupBlock],
schedule_events: List[OrionEvent],
report_dates: List[Union[datetime.date, datetime.datetime]],
warnings: List[ParseWarning],
@@ -454,6 +475,17 @@ def _parse_line(
unit_holder[0] = match.group("unit")
return current_events
if first == "GROUP":
match = _GROUP_BLOCK_RE.match(line)
if match is None:
raise OrionParseError(
f'Malformed GROUP line: {line!r} (expected GROUP "<group-name>")',
loc,
)
new_group = GroupBlock(group_name=match.group("name"), loc=loc)
groups.append(new_group)
return new_group.events
if first == "SCHEDULE":
if line != "SCHEDULE":
raise OrionParseError(
@@ -979,6 +1011,10 @@ def apply_orion_document(
dispatch = _apply_generic_well_keyword
dispatch(event, well_path, timeline, report, ctx)
for group in document.groups:
for event in group.events:
_apply_schedule_event(event, timeline, report, group.group_name)
for event in document.schedule_events:
_apply_schedule_event(event, timeline, report)
@@ -1116,14 +1152,17 @@ def _suspected_typo(event_type: str) -> Optional[str]:
def _apply_schedule_event(
event: OrionEvent, timeline: Any, report: ApplyReport
event: OrionEvent,
timeline: Any,
report: ApplyReport,
group_name: Optional[str] = None,
) -> None:
"""Apply one SCHEDULE-block event as a schedule-level Eclipse keyword."""
"""Apply one GROUP- or SCHEDULE-block event as an 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"
"needs a WELL block, not GROUP or SCHEDULE"
)
report.events_skipped += 1
return
@@ -1137,6 +1176,8 @@ def _apply_schedule_event(
)
continue
keyword_data[key] = attr.value
if group_name is not None:
keyword_data["GROUP"] = group_name
timeline.add_keyword_event(
event_date=_iso_event_date(event.event_date),
@@ -1442,6 +1483,7 @@ def _cli(argv: Optional[List[str]] = None) -> int:
return 1
event_count = sum(len(well.events) for well in document.wells)
group_event_count = sum(len(group.events) for group in document.groups)
print(
f"{args.file}: OK (ORIONEVENTS {document.version}, "
f"units {document.unit_system})"
@@ -1449,6 +1491,7 @@ def _cli(argv: Optional[List[str]] = None) -> int:
print(
f" {len(document.variables)} variable(s), {len(document.wells)} "
f"well block(s), {event_count} well event(s), "
f"{len(document.groups)} group block(s), {group_event_count} group event(s), "
f"{len(document.schedule_events)} schedule event(s), "
f"{len(document.report_dates)} report date(s)"
)
@@ -393,6 +393,34 @@ class TestParsing:
# WELL after SCHEDULE switches the sink back to the well block.
assert [len(w.events) for w in doc.wells] == [1, 1]
def test_group_blocks_parse_and_switch_event_sink(self):
text = (
'ORIONEVENTS 2.0\nGROUP "OP"\n'
" @2020-07-01 GEFAC FACTOR=1.0 TRANSFER=YES\n"
" @2020-07-01 GCONPROD CMODE=LRAT LRAT=20000\n"
'GROUP "WI"\n'
" @2020-07-01 GCONINJE TYPE=WATER CMODE=RATE RATE=16000\n"
"SCHEDULE\n"
" @2020-07-01 RPTRST BASIC=2\n"
)
doc = parse_orion_events(text)
assert [group.group_name for group in doc.groups] == ["OP", "WI"]
assert [event.event_type for event in doc.groups[0].events] == [
"GEFAC",
"GCONPROD",
]
assert [event.event_type for event in doc.groups[1].events] == ["GCONINJE"]
assert [event.event_type for event in doc.schedule_events] == ["RPTRST"]
def test_empty_group_block_ok(self):
doc = parse_orion_events('ORIONEVENTS 2.0\nGROUP "OP"\n')
assert doc.groups[0].group_name == "OP"
assert doc.groups[0].events == []
def test_malformed_group_line_rejected(self):
with pytest.raises(OrionParseError, match="Malformed GROUP line"):
parse_orion_events("ORIONEVENTS 2.0\nGROUP OP\n")
def test_boolean_attributes_are_typed_unless_quoted(self):
text = (
"ORIONEVENTS 2.0\n"
@@ -919,6 +947,29 @@ class TestApplying:
assert rptrst["keyword_data"] == {"BASIC": 2, "FREQ": 1}
assert "WELL" not in rptrst["keyword_data"]
def test_group_events_inject_group_name(self):
text = (
'ORIONEVENTS 2.0\nGROUP "OP"\n'
" @2020-07-01 GEFAC EFFICIENCY_FACTOR=1.0 USE_GEFAC_IN_NETWORK=YES\n"
" @2020-07-01 GCONPROD CONTROL_MODE=LRAT LIQUID_TARGET=20000 WATER_TARGET=20000 OIL_TARGET=20000\n"
'GROUP "WI"\n'
" @2020-07-01 GCONINJE PHASE=WATER CONTROL_MODE=RATE SURFACE_TARGET=16000\n"
)
timeline, report = self._apply(text)
assert report.events_applied == 3
assert [call["keyword_name"] for call in timeline.schedule_keyword_calls] == [
"GEFAC",
"GCONPROD",
"GCONINJE",
]
assert timeline.schedule_keyword_calls[0]["keyword_data"] == {
"GROUP": "OP",
"EFFICIENCY_FACTOR": 1.0,
"USE_GEFAC_IN_NETWORK": "YES",
}
assert timeline.schedule_keyword_calls[1]["keyword_data"]["GROUP"] == "OP"
assert timeline.schedule_keyword_calls[2]["keyword_data"]["GROUP"] == "WI"
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"
@@ -1158,6 +1209,35 @@ class TestOrionEventsIntegration:
assert f"{flag}=True" not in rptrst_block
assert "NORST" not in tokens
def test_group_sections_generate_group_keywords(self, project_with_case_and_wells):
project, case, timeline = project_with_case_and_wells
well = project.well_paths()[0]
document = parse_orion_events(
"ORIONEVENTS 2.0\n"
f'WELL "{well.name}"\n'
" @2020-07-01 WCONHIST STATUS=OPEN CMODE=ORAT\n"
'GROUP "OP"\n'
" @2020-07-01 GEFAC EFFICIENCY_FACTOR=1.0 USE_GEFAC_IN_NETWORK=YES\n"
" @2020-07-01 GCONPROD CONTROL_MODE=LRAT LIQUID_TARGET=20000 "
"WATER_TARGET=20000 OIL_TARGET=20000\n"
'GROUP "WI"\n'
" @2020-07-01 GCONINJE PHASE=WATER CONTROL_MODE=RATE "
"SURFACE_TARGET=16000\n"
)
report = apply_orion_document(document, timeline, project)
assert report.errors == []
assert report.events_applied == 4
schedule = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[]
)
assert "GEFAC" in schedule
assert "GCONPROD" in schedule
assert "GCONINJE" in schedule
assert "'OP'" in schedule
assert "'WI'" in schedule
def test_apply_creates_perforations_and_schedule(self, project_with_case_and_wells):
"""End-to-end: parse -> apply -> set_timestamp -> generate schedule."""
project, case, timeline = project_with_case_and_wells