diff --git a/GrpcInterface/Python/rips/orion_events.py b/GrpcInterface/Python/rips/orion_events.py index da11ba087a..d7b3729ca2 100644 --- a/GrpcInterface/Python/rips/orion_events.py +++ b/GrpcInterface/Python/rips/orion_events.py @@ -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 `` opens an event block for a declared ``WELL`` alias; ``WELL ""`` 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 ""`` 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 `` (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=`` references a declared ``FILTER`` variable and ``FILTER=""`` 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[^"]*)"|(?P{_IDENT}))$') +_GROUP_BLOCK_RE = re.compile(r'^GROUP\s+"(?P[^"]*)"$') _EVENT_RE = re.compile(rf"^@\s*{_DATE_BASE}{_TERMS}\s+(?P.+)$") _TERM_RE = re.compile(rf"([-+])\s*(\d+|{_IDENT})") _ATTR_RE = re.compile(r'(?P[A-Za-z_]\w*)\s*=\s*(?:"(?P[^"]*)"|(?P\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 "")', + 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)" ) diff --git a/GrpcInterface/Python/rips/tests/test_orion_events.py b/GrpcInterface/Python/rips/tests/test_orion_events.py index 96d358ae4d..f2a4bf6fdb 100644 --- a/GrpcInterface/Python/rips/tests/test_orion_events.py +++ b/GrpcInterface/Python/rips/tests/test_orion_events.py @@ -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