From 2f22741a5968e41341e6103c3b58637ebd3a2d89 Mon Sep 17 00:00:00 2001 From: magnesj <1793152+magnesj@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:20:00 +0000 Subject: [PATCH] Update rips module and proto files --- .../experimental/import_orion_events.py | 46 +- .../experimental/well_event_schedule_orion.py | 50 ++- .../view_example.py | 9 + docs/rips/generated/generated_classes.py | 104 ++++- docs/rips/orion_events.py | 412 ++++++++++++++---- docs/rips/tests/test_orion_events.py | 280 +++++++++++- docs/rips/tests/test_project.py | 12 + docs/rips/tests/test_well_events.py | 32 ++ docs/rips/tests/test_wells.py | 21 + docs/rips/well_events.py | 250 ++++++----- 10 files changed, 969 insertions(+), 247 deletions(-) diff --git a/docs/rips/PythonExamples/experimental/import_orion_events.py b/docs/rips/PythonExamples/experimental/import_orion_events.py index b0a7182ff4..6ce0628fec 100644 --- a/docs/rips/PythonExamples/experimental/import_orion_events.py +++ b/docs/rips/PythonExamples/experimental/import_orion_events.py @@ -5,10 +5,12 @@ This example shows how to: 1. Parse an ORIONEVENTS text file into a structured document -2. Apply its events to the well event timeline (perforations, WCONHIST, WELTARG), +2. Normalize matching keyword events while retaining same-date perforations +3. Apply its events to the well event timeline (perforations, WCONHIST, WELTARG), materializing FILTER declarations as case-level combined data filters attached to the perforations -3. Generate Eclipse schedule text from the resulting timeline +4. Insert multiline RAW_TEXT at a selected position in the generated schedule +5. Generate Eclipse schedule text from the resulting timeline The ORIONEVENTS format is a compact, human-authored description of dated well events. See rips/orion_events.py for the grammar. A sample input file ships at @@ -46,6 +48,36 @@ def main(): f" Variables: { {k: f'{v.kind} {v.value}' for k, v in document.variables.items()} }" ) + # Normalization merges matching keyword events, but events that create or + # expand domain objects remain separate. The sample has three perforations + # at A1_STARTUP; all three are retained. + normalized = rips.orion_events.coalesce_orion_document(document) + source_perforation_count = sum( + event.event_type == "PERFORATION" + for well in document.wells + for event in well.events + ) + normalized_perforation_count = sum( + event.event_type == "PERFORATION" + for well in normalized.wells + for event in well.events + ) + print( + " Perforations retained during normalization: " + f"{source_perforation_count} -> {normalized_perforation_count}" + ) + + # RAW_TEXT bodies bypass keyword parsing and formatting. Placement and an + # optional anchor control where each block appears in the generated schedule. + raw_text_events = [ + event for event in document.schedule_events if event.event_type == "RAW_TEXT" + ] + for event in raw_text_events: + print( + f" RAW_TEXT: {event.raw_placement} {event.raw_anchor or ''} " + f"(priority {event.raw_priority})" + ) + # The sample file uses FILTER declarations, so a case is needed to resolve # the referenced result names and to own the created combined filters. cases = project.cases() @@ -67,6 +99,7 @@ def main(): ) print(f" Events applied: {report.events_applied}") print(f" Events skipped: {report.events_skipped}") + print(f" Report dates: {report.report_dates}") for warning in report.warnings: print(f" WARNING: {warning}") for error in report.errors: @@ -86,11 +119,14 @@ def main(): # Generate Eclipse schedule text from the timeline. print("\n4. Generating Eclipse schedule text...") - if report.events_applied == 0: - print(" No events applied - skipping schedule generation.") + if report.events_applied == 0 and not report.report_dates: + print(" No events or report dates found - skipping schedule generation.") return schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=project.well_paths() + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + additional_dates=report.report_dates, + align_columns=True, ) if schedule_text: print(f" Generated schedule text ({len(schedule_text)} characters):") diff --git a/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py b/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py index e3ab03913f..6e92ddae0b 100644 --- a/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py +++ b/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py @@ -11,21 +11,22 @@ It demonstrates the full event coverage of the format: 1. SEGMENT, PERFORATION (incl. a time-of-day date), VALVE and STATE completion events on a well -2. Partial WELLSPEC updates that cumulatively change completion export settings +2. Partial WELSPECS updates that cumulatively change completion export settings and generate dated WELSPECS records 3. A FILTER declaration (qualified result name) referenced by a perforation, materialized as a case-level combined data filter 4. COMMENT attributes preserved on timeline events and emitted before their generated schedule keywords -5. Same-owner/type/date WCONHIST lines merged into one event, with later - attributes extending or overriding earlier attributes +5. Same-owner/type/date WCONHIST lines merged into one event, while same-date + perforations remain separate 6. Well keyword events: WCONHIST and WELTARG (with attribute translation) and WRFTPLT (generic Eclipse well keyword pass-through) 7. A GROUP-level MEMBER event expanded to one GRUPTREE record per member 8. SCHEDULE-level keyword events not tied to a well: RPTRST, GRUPTREE, TUNING -9. REPORT dates, passed to generate_schedule_text(additional_dates=...) so +9. Multiline RAW_TEXT inserted at a chosen position without parsing its contents +10. REPORT dates, passed to generate_schedule_text(additional_dates=...) so they appear as bare DATES keywords (summary-report triggers) -10. Schedule metadata, COMPORD generation and aligned-column output +11. Schedule metadata, COMPORD generation and aligned-column output 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 @@ -59,17 +60,18 @@ def build_orion_text(well_name, with_filter): WELL W1 = "{well_name}" WELL W1 - # WELLSPEC updates completion export settings and emits WELSPECS. Attributes + # WELSPECS updates completion export settings and emits WELSPECS. Attributes # are optional: the second event inherits GROUP from the first event. - @2024-01-05 WELLSPEC GROUP="ORION_GROUP" CROSSFLOW=True REFDEPTH=1002 PHASE=WATER - @2024-04-15 WELLSPEC CROSSFLOW=False REFDEPTH=1000 PHASE=OIL + @2024-01-05 WELSPECS GROUP="ORION_GROUP" CROSSFLOW=True REFDEPTH=1002 PHASE=WATER + @2024-04-15 WELSPECS CROSSFLOW=False REFDEPTH=1000 PHASE=OIL # COMMENT is stored on the event and safely emitted as a schedule comment. @STARTUP SEGMENT MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5 PRESSURE_COMPONENTS=HFA COMMENT="Install production segment" - # Perforations; COMPLETION_NUMBER groups connections for COMPLUMP.{filter_comment} + # Perforations; COMPLETION_NUMBER groups connections for COMPLUMP. Same-date + # perforations are kept as separate events during normalization.{filter_comment} @STARTUP + RAMP PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1{filter_ref} COMMENT="Open high-priority interval" - @2024-04-01 PERFORATION MDSTART=2400 MDEND=2600 RADIUS=0.05 SKIN=0.3 COMPLETION_NUMBER=2 + @STARTUP + RAMP 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 @@ -97,6 +99,15 @@ def build_orion_text(well_name, with_filter): @STARTUP GRUPTREE CHILD_GROUP=OP PARENT_GROUP=FIELD @STARTUP TUNING TSINIT=1 TSMAXZ=30 TMAXWC=1 NEWTMX=12 NEWTMN=1 LITMAX=50 LITMIN=1 MXWSIT=50 MXWPIT=50 + # RAW_TEXT preserves its body verbatim. This block is emitted after RPTRST; + # PRIORITY orders multiple raw blocks sharing the same placement and anchor. + @STARTUP RAW_TEXT PLACEMENT=AFTER_KEYWORD ANCHOR=RPTRST PRIORITY=10 +-- Custom schedule text not modeled by the timeline API +WTRACER + '{well_name}' 'ORION_TRACER' 1.0 / +/ +END_RAW_TEXT + # Report dates: emitted as bare DATES keywords so Eclipse/Flow writes a # summary report at these dates even though no events fall on them. REPORT 2024-07-01 @@ -131,10 +142,24 @@ def main(): document = rips.orion_events.parse_orion_events(orion_text) print(f" Wells: {[w.well_name for w in document.wells]}") source_well_event_count = sum(len(w.events) for w in document.wells) + source_perforation_count = sum( + event.event_type == "PERFORATION" + for well in document.wells + for event in well.events + ) normalized = rips.orion_events.coalesce_orion_document(document) merged_well_event_count = sum(len(w.events) for w in normalized.wells) + merged_perforation_count = sum( + event.event_type == "PERFORATION" + for well in normalized.wells + for event in well.events + ) print(f" Source well-event lines: {source_well_event_count}") print(f" Events after same-date merge: {merged_well_event_count}") + print( + " Perforations retained during merge: " + f"{source_perforation_count} -> {merged_perforation_count}" + ) print(f" Groups: {[group.group_name for group in document.groups]}") print(f" Schedule events: {len(document.schedule_events)}") @@ -155,9 +180,9 @@ def main(): # Apply events up to a date to materialize completions timeline.set_timestamp(timestamp="2024-12-24") - print("\n4. Verifying created completions and WELLSPEC settings...") + print("\n4. Verifying created completions and WELSPECS settings...") completion_settings = well_path.completion_settings() - print(" Completion export settings after the latest WELLSPEC:") + print(" Completion export settings after the latest WELSPECS:") print(f" Group: {completion_settings.group_name_for_export}") print(f" Cross-flow: {completion_settings.allow_well_cross_flow}") print(f" Ref. depth: {completion_settings.reference_depth_for_export}") @@ -207,6 +232,7 @@ def main(): "RPTRST", "GRUPTREE", "TUNING", + "WTRACER", ] found = [kw for kw in expected_keywords if kw in schedule_text] print(f"\n Keywords found: {', '.join(found)}") diff --git a/docs/rips/PythonExamples/surfaces_and_visualization/view_example.py b/docs/rips/PythonExamples/surfaces_and_visualization/view_example.py index 2a2f16aae1..8130cb3e43 100644 --- a/docs/rips/PythonExamples/surfaces_and_visualization/view_example.py +++ b/docs/rips/PythonExamples/surfaces_and_visualization/view_example.py @@ -26,3 +26,12 @@ view.show_grid_box = False view.set_visible(False) view.update() + +# Tile all visible 3D views +resinsight.project.tile_views() + +# Link the views, then unlink the last view from the group +project_views = resinsight.project.views() +if len(project_views) >= 2: + resinsight.project.link_views(views=project_views) + resinsight.project.unlink_views(views=[project_views[-1]]) diff --git a/docs/rips/generated/generated_classes.py b/docs/rips/generated/generated_classes.py index 9703895b26..dd193dd59f 100644 --- a/docs/rips/generated/generated_classes.py +++ b/docs/rips/generated/generated_classes.py @@ -147,6 +147,12 @@ class PhaseSelection(StrEnum): PHASE_GAS = "PHASE_GAS" PHASE_WAT = "PHASE_WAT" +class Placement(StrEnum): + AFTER_DATE = "AFTER_DATE" + BEFORE_KEYWORD = "BEFORE_KEYWORD" + AFTER_KEYWORD = "AFTER_KEYWORD" + END_OF_DATE = "END_OF_DATE" + class PorePressureReservoirSource(StrEnum): GRID = "GRID" LAS_FILE = "LAS_FILE" @@ -2399,6 +2405,27 @@ def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: if PressureTableItem.__custom_init__ is not None: PressureTableItem.__custom_init__(self, pb2_object=pb2_object, channel=channel) +class RawTextEvent(WellEvent): + """ + RawTextEvent + + Attributes: + anchor_keyword (str): Anchor Keyword + placement (Placement): One of [AFTER_DATE, BEFORE_KEYWORD, AFTER_KEYWORD, END_OF_DATE] + priority (int): Priority + text (str): Text + """ + __custom_init__ = None #: Assign a custom init routine to be run at __init__ + + def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: Optional[grpc.Channel]=None) -> None: + self.anchor_keyword: str = "" + self.placement: Placement = Placement.AFTER_DATE + self.priority: int = 0 + self.text: str = "" + WellEvent.__init__(self, pb2_object, channel) + if RawTextEvent.__custom_init__ is not None: + RawTextEvent.__custom_init__(self, pb2_object=pb2_object, channel=channel) + class RegularSurface(SurfaceInterface): """ Attributes: @@ -2549,6 +2576,18 @@ def import_summary_case(self, file_name: str="") -> FileSummaryCase: return self._call_pdm_method_return_value("importSummaryCase", FileSummaryCase, file_name=file_name) + def link_views(self, views: List[View]=[]) -> None: + """ + Link the specified 3D views + + Arguments: + views (List[View]): + Returns: + + """ + self._call_pdm_method_void("linkViews", views=views) + + def summary_case(self, case_id: int=-1) -> Optional[FileSummaryCase]: """ Find Summary Case @@ -2573,6 +2612,30 @@ def surface_folder(self, folder_name: str="") -> SurfaceCollection: return self._call_pdm_method_return_value("surfaceFolder", SurfaceCollection, folder_name=folder_name) + def tile_views(self, ) -> None: + """ + Tile all visible 3D view windows + + Arguments: + + Returns: + + """ + self._call_pdm_method_void("tileViews") + + + def unlink_views(self, views: List[View]=[]) -> None: + """ + Unlink the specified 3D views + + Arguments: + views (List[View]): + Returns: + + """ + self._call_pdm_method_void("unlinkViews", views=views) + + def valve_templates(self, ) -> ValveTemplateCollection: """ Get Valve Template Collection @@ -4040,6 +4103,22 @@ def add_perf_event(self, event_date: str="2024-01-01", well_path: Optional[WellP return self._call_pdm_method_return_value("AddPerfEvent", WellEventPerf, event_date=event_date, well_path=well_path, start_md=start_md, end_md=end_md, diameter=diameter, skin_factor=skin_factor, state=state, completion_number=completion_number) + def add_raw_text_event_internal(self, event_date: str="2024-01-01", text: str="", placement: Placement=Placement.AFTER_DATE, anchor_keyword: str="", priority: int=0) -> RawTextEvent: + """ + Add raw text at a specific position in a dated schedule section + + Arguments: + event_date (str): Event Date (YYYY-MM-DD) + text (str): Raw schedule text + placement (Placement): One of [AFTER_DATE, BEFORE_KEYWORD, AFTER_KEYWORD, END_OF_DATE] + anchor_keyword (str): Keyword used for before/after placement + priority (int): Ascending order among raw text events at the same position + Returns: + RawTextEvent + """ + return self._call_pdm_method_return_value("AddRawTextEventInternal", RawTextEvent, event_date=event_date, text=text, placement=placement, anchor_keyword=anchor_keyword, priority=priority) + + def add_state_event(self, event_date: str="2024-01-01", well_path: Optional[WellPath]=None, well_state: WellState=WellState.OPEN) -> WellEventState: """ Add a well state event to the timeline @@ -4366,6 +4445,18 @@ def add_extraction_curve(self, case: Optional[Reservoir]=None, well_path: Option return self._call_pdm_method_return_value("AddExtractionCurve", WellLogExtractionCurve, case=case, well_path=well_path, property_type=property_type, property_name=property_name, time_step=time_step) +class FileWellPath(WellPath): + """ + Well Paths Loaded From File + + """ + __custom_init__ = None #: Assign a custom init routine to be run at __init__ + + def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: Optional[grpc.Channel]=None) -> None: + WellPath.__init__(self, pb2_object, channel) + if FileWellPath.__custom_init__ is not None: + FileWellPath.__custom_init__(self, pb2_object=pb2_object, channel=channel) + class WellPathAicdParameters(PdmObjectBase): """ Attributes: @@ -4409,18 +4500,6 @@ def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: if WellPathAicdParameters.__custom_init__ is not None: WellPathAicdParameters.__custom_init__(self, pb2_object=pb2_object, channel=channel) -class FileWellPath(WellPath): - """ - Well Paths Loaded From File - - """ - __custom_init__ = None #: Assign a custom init routine to be run at __init__ - - def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: Optional[grpc.Channel]=None) -> None: - WellPath.__init__(self, pb2_object, channel) - if FileWellPath.__custom_init__ is not None: - FileWellPath.__custom_init__(self, pb2_object=pb2_object, channel=channel) - class WellPathCompletionSettings(PdmObjectBase): """ Attributes: @@ -4853,6 +4932,7 @@ def class_dict() -> Dict[str, Type[PdmObjectBase]]: classes['PressureTableItem'] = PressureTableItem classes['Project'] = Project classes['PropertyFilter'] = PropertyFilter + classes['RawTextEvent'] = RawTextEvent classes['RegularFileSurface'] = RegularFileSurface classes['RegularSurface'] = RegularSurface classes['ResampleData'] = ResampleData diff --git a/docs/rips/orion_events.py b/docs/rips/orion_events.py index 7244509470..f52336344b 100644 --- a/docs/rips/orion_events.py +++ b/docs/rips/orion_events.py @@ -23,7 +23,8 @@ document = header , { statement } ; header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *) statement = unit_directive | declaration | report_line | well_block_open - | group_block_open | schedule_block_open | event_line ; + | group_block_open | schedule_block_open | event_line + | raw_text_event ; unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ; report_line = "REPORT" , date_expr ; (* REPORT 2024-06-01 *) @@ -42,6 +43,12 @@ group_block_open = "GROUP" , quoted_string ; (* group keyword events *) schedule_block_open = "SCHEDULE" ; (* well-less keyword events *) event_line = "@" , date_expr , event_type , { attribute } ; + raw_text_event = "@" , date_expr , "RAW_TEXT" , raw_text_attributes , newline, + { raw_line , newline } , "END_RAW_TEXT" ; + raw_text_attributes = "PLACEMENT=" , + ( "AFTER_DATE" | "BEFORE_KEYWORD" | + "AFTER_KEYWORD" | "END_OF_DATE" ) , + [ "ANCHOR=" , ident ] , [ "PRIORITY=" , integer ] ; date_expr = ( iso_date | iso_datetime | date_ident ) , { sign , term } ; duration_expr = ( integer | dur_ident ) , { sign , term } , [ "DAYS" | "days" ] ; @@ -91,18 +98,25 @@ strings on :attr:`ApplyReport.report_dates`, ready to pass to ``WellEventTimeline.generate_schedule_text(additional_dates=...)``. A ``REPORT`` line is not tied to any well and does not close an open block. +* ``RAW_TEXT`` is valid only inside a ``SCHEDULE`` block. Its body is copied + without parsing or formatting through the mandatory standalone + ``END_RAW_TEXT`` line. ``PLACEMENT`` is ``AFTER_DATE``, ``BEFORE_KEYWORD``, + ``AFTER_KEYWORD`` or ``END_OF_DATE``. Before/after-keyword placement requires + ``ANCHOR=``; the other placements forbid it. ``PRIORITY`` is + an optional integer (default 0); lower values are emitted first and source + order breaks ties. * Double quotes are used everywhere: well names, filter expressions and attribute values, e.g. ``FILTER="SOIL > 0.8 AND PERMX > 200"``. * Every attribute is ``KEY=VALUE``; bare positional tokens are rejected. * Event types inside a WELL block are either the built-in completion events - ``PERFORATION``, ``SEGMENT``, ``VALVE``, ``STATE`` and ``WELLSPEC``, or any + ``PERFORATION``, ``SEGMENT``, ``VALVE``, ``STATE`` and ``WELSPECS``, or any Eclipse well keyword (``WCONHIST``, ``WELTARG``, ``WRFTPLT``, ``WCONPROD``, ...), which is passed through generically with the well name injected as WELL. Event - ``WELLSPEC`` accepts partial updates to ``GROUP``, ``CROSSFLOW``, ``REFDEPTH`` + ``WELSPECS`` accepts partial updates to ``GROUP``, ``CROSSFLOW``, ``REFDEPTH`` and ``PHASE`` (OIL/GAS/WATER/LIQUID). Omitted values inherit the previous - WELLSPEC state, initially using the well's completion export settings. There - may be multiple WELLSPEC events for a well, but not at the same timestamp. + WELSPECS state, initially using the well's completion export settings. There + may be multiple WELSPECS events for a well, but not at the same timestamp. Each emits a WELSPECS record with the cumulative state. Event values are materialized back onto completion settings by ``timeline.set_timestamp()``. Event types inside a GROUP block are Eclipse group keywords with the group @@ -259,7 +273,7 @@ class AttrValue: @dataclass class WellSpecState: - """Fully resolved cumulative state for one WELLSPEC event.""" + """Fully resolved cumulative state for one WELSPECS event.""" group: str crossflow: bool @@ -277,6 +291,12 @@ class OrionEvent: loc: SourceLoc filter: Optional[EventFilter] = None well_spec: Optional[WellSpecState] = None + raw_text: Optional[str] = None + raw_placement: Optional[str] = None + raw_anchor: Optional[str] = None + raw_priority: int = 0 + scope: str = "SCHEDULE" + scope_name: Optional[str] = None @dataclass @@ -313,6 +333,47 @@ class OrionDocument: warnings: List[ParseWarning] = field(default_factory=list) +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 _event_context(event: OrionEvent) -> str: + """Return the source scope and timestamp identifying an event.""" + scope = event.scope + if event.scope_name is not None: + scope += f' "{event.scope_name}"' + return f"[{scope}, date {_iso_event_date(event.event_date)}]" + + +def _event_message(event: OrionEvent, message: str) -> str: + """Add source line, scope and timestamp to an event-level message.""" + return f"Line {event.loc.line} {_event_context(event)}: {message}" + + +def _set_event_scopes( + wells: List[WellBlock], + groups: List[GroupBlock], + schedule_events: List[OrionEvent], +) -> None: + """Attach enclosing block information to parsed events.""" + for well in wells: + for event in well.events: + event.scope = "WELL" + event.scope_name = well.well_name + for group in groups: + for event in group.events: + event.scope = "GROUP" + event.scope_name = group.group_name + for event in schedule_events: + event.scope = "SCHEDULE" + event.scope_name = None + + # --------------------------------------------------------------------------- # Layer A: pure parser # --------------------------------------------------------------------------- @@ -391,9 +452,13 @@ def parse_orion_events(text: str) -> OrionDocument: # 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) + source_lines = text.splitlines() + line_index = 0 + while line_index < len(source_lines): + raw_line = source_lines[line_index] + loc = SourceLoc(line=line_index + 1, text=raw_line) line = _strip_comment(raw_line).strip() + line_index += 1 if not line: continue @@ -409,6 +474,31 @@ def parse_orion_events(text: str) -> OrionDocument: _check_version(version, loc) continue + if _is_raw_text_header(line): + end_index = line_index + while ( + end_index < len(source_lines) + and source_lines[end_index].strip() != "END_RAW_TEXT" + ): + end_index += 1 + if end_index == len(source_lines): + errors.append(ParseIssue("Unterminated RAW_TEXT block", loc)) + break + + body_lines = source_lines[line_index:end_index] + line_index = end_index + 1 + try: + if current_events is not schedule_events: + raise OrionParseError( + "RAW_TEXT is only valid in a SCHEDULE block", loc + ) + current_events.append( + _parse_raw_text_event(line, body_lines, variables, loc) + ) + except OrionParseError as exc: + errors.extend(exc.errors) + continue + try: current_events = _parse_line( line, @@ -434,6 +524,7 @@ def parse_orion_events(text: str) -> OrionDocument: if version is None: raise OrionParseError("Empty file: missing 'ORIONEVENTS' header") + _set_event_scopes(wells, groups, schedule_events) errors.extend(_restart_validation_issues(wells, groups, schedule_events)) errors.extend(_wellspec_validation_issues(wells)) if errors: @@ -462,13 +553,21 @@ def _restart_validation_issues( for event in well_block.events: if event.event_type.upper() == "RESTART": issues.append( - ParseIssue("RESTART is only valid in a SCHEDULE block", event.loc) + ParseIssue( + f"{_event_context(event)}: RESTART is only valid in a " + "SCHEDULE block", + event.loc, + ) ) for group_block in groups: for event in group_block.events: if event.event_type.upper() == "RESTART": issues.append( - ParseIssue("RESTART is only valid in a SCHEDULE block", event.loc) + ParseIssue( + f"{_event_context(event)}: RESTART is only valid in a " + "SCHEDULE block", + event.loc, + ) ) restart_events = [ @@ -476,29 +575,37 @@ def _restart_validation_issues( ] for event in restart_events: if event.attributes: - issues.append(ParseIssue("RESTART takes no attributes", event.loc)) + issues.append( + ParseIssue( + f"{_event_context(event)}: RESTART takes no attributes", event.loc + ) + ) for event in restart_events[1:]: issues.append( - ParseIssue("Only one RESTART event is allowed per schedule", event.loc) + ParseIssue( + f"{_event_context(event)}: Only one RESTART event is allowed per " + "schedule", + event.loc, + ) ) return issues def _wellspec_validation_issues(wells: List[WellBlock]) -> List[ParseIssue]: - """Reject multiple WELLSPEC events for one well at the same timestamp.""" + """Reject multiple WELSPECS events for one well at the same timestamp.""" seen: Dict[Tuple[str, Union[datetime.date, datetime.datetime]], OrionEvent] = {} issues: List[ParseIssue] = [] for well in wells: for event in well.events: - if event.event_type.upper() != "WELLSPEC": + if event.event_type.upper() != "WELSPECS": continue key = (well.well_name, event.event_date) previous = seen.get(key) if previous is not None: issues.append( ParseIssue( - f"WELLSPEC already defined for well '{well.well_name}' " - f"at this date (line {previous.loc.line})", + f"{_event_context(event)}: WELSPECS already defined " + f"(first definition on line {previous.loc.line})", event.loc, ) ) @@ -864,6 +971,81 @@ def _strip_comment(line: str) -> str: return "".join(result) +def _is_raw_text_header(line: str) -> bool: + """Return whether an event line starts a multiline RAW_TEXT block.""" + match = _EVENT_RE.match(line) + if match is None: + return False + return match.group("rest").split(None, 1)[0].upper() == "RAW_TEXT" + + +def _parse_raw_text_event( + line: str, + body_lines: List[str], + variables: Dict[str, OrionValue], + loc: SourceLoc, +) -> OrionEvent: + """Parse and validate a RAW_TEXT header and attach its unparsed body.""" + event = _parse_event_line(line, variables, loc) + allowed = {"PLACEMENT", "ANCHOR", "PRIORITY"} + unknown = set(event.attributes) - allowed + if unknown: + raise OrionParseError( + f"Unknown RAW_TEXT attribute(s): {', '.join(sorted(unknown))}", loc + ) + if "PLACEMENT" not in event.attributes: + raise OrionParseError("RAW_TEXT requires PLACEMENT", loc) + if not body_lines: + raise OrionParseError("RAW_TEXT body must not be empty", loc) + + placement_value = event.attributes["PLACEMENT"].value + placement = placement_value.upper() if isinstance(placement_value, str) else "" + valid_placements = { + "AFTER_DATE", + "BEFORE_KEYWORD", + "AFTER_KEYWORD", + "END_OF_DATE", + } + if placement not in valid_placements: + raise OrionParseError( + "RAW_TEXT PLACEMENT must be AFTER_DATE, BEFORE_KEYWORD, " + "AFTER_KEYWORD, or END_OF_DATE", + loc, + ) + + anchor: Optional[str] = None + if "ANCHOR" in event.attributes: + anchor_value = event.attributes["ANCHOR"].value + if not isinstance(anchor_value, str) or not anchor_value.strip(): + raise OrionParseError("RAW_TEXT ANCHOR must be a keyword name", loc) + anchor = anchor_value.strip().upper() + + anchored = placement in {"BEFORE_KEYWORD", "AFTER_KEYWORD"} + if anchored and anchor is None: + raise OrionParseError( + "RAW_TEXT ANCHOR is required for BEFORE_KEYWORD and AFTER_KEYWORD", + loc, + ) + if not anchored and anchor is not None: + raise OrionParseError( + "RAW_TEXT ANCHOR is only valid for BEFORE_KEYWORD and AFTER_KEYWORD", + loc, + ) + + priority = 0 + if "PRIORITY" in event.attributes: + priority_value = event.attributes["PRIORITY"].value + if isinstance(priority_value, bool) or not isinstance(priority_value, int): + raise OrionParseError("RAW_TEXT PRIORITY must be an integer", loc) + priority = priority_value + + event.raw_text = "\n".join(body_lines) + "\n" + event.raw_placement = placement + event.raw_anchor = anchor + event.raw_priority = priority + return event + + def _parse_event_line( line: str, variables: Dict[str, OrionValue], loc: SourceLoc ) -> OrionEvent: @@ -968,6 +1150,23 @@ class ApplyReport: errors: List[str] = field(default_factory=list) +def _record_event_exception( + event: OrionEvent, error: Exception, report: ApplyReport +) -> None: + """Record an event failure with its Orion source context.""" + report.errors.append(_event_message(event, str(error))) + report.events_skipped += 1 + + +def _record_event_parse_error( + event: OrionEvent, error: OrionParseError, report: ApplyReport +) -> None: + """Record parser-style event failures without duplicating their line number.""" + for issue in error.errors: + report.errors.append(_event_message(event, issue.message)) + report.events_skipped += 1 + + # Policies for handling unresolved references: skip silently, warn, or raise. _POLICIES = ("warn", "error", "skip") @@ -1017,15 +1216,6 @@ class ApplyReport: _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_event_comment(event: OrionEvent, timeline_event: Any) -> None: """Copy an optional COMMENT attribute to the created timeline event.""" comment = event.attributes.get("COMMENT") @@ -1048,13 +1238,25 @@ def apply_orion_events_file( return apply_orion_document(document, timeline, project, case=case, **options) +_NON_COALESCING_EVENT_TYPES = { + "MEMBER", + "PERFORATION", + "RAW_TEXT", + "RESTART", + "SEGMENT", + "STATE", + "VALVE", + "WELSPECS", +} + + def coalesce_orion_document(document: OrionDocument) -> OrionDocument: - """Return a copy with same-owner/type/timestamp events merged. + """Return a copy with matching keyword events merged. - The first event retains its position. Attributes from later matching events - are applied in source order, adding missing values and overriding repeated - values. Owners are matched by well name, group name, or the global SCHEDULE - scope. + Keyword events with the same owner, type and timestamp are merged. The first + event retains its position, and attributes from later matching events are + applied in source order. Events that create or expand domain objects are + kept separate so, for example, same-date perforation intervals are not lost. """ result = copy.deepcopy(document) @@ -1064,7 +1266,12 @@ def merge_events(events: List[OrionEvent]) -> List[OrionEvent]: Tuple[Union[datetime.date, datetime.datetime], str], OrionEvent ] = {} for event in events: - key = (event.event_date, event.event_type.upper()) + event_type = event.event_type.upper() + if event_type in _NON_COALESCING_EVENT_TYPES: + merged.append(event) + continue + + key = (event.event_date, event_type) existing = by_key.get(key) if existing is None: by_key[key] = event @@ -1121,7 +1328,7 @@ def _enum_text(value: Any) -> str: def _prepare_wellspec_events( events: List[OrionEvent], completion_settings: Any, report: ApplyReport ) -> None: - """Validate WELLSPEC attributes and resolve partial updates chronologically.""" + """Validate WELSPECS attributes and resolve partial updates chronologically.""" state = WellSpecState( group=str(completion_settings.group_name_for_export), crossflow=bool(completion_settings.allow_well_cross_flow), @@ -1130,7 +1337,7 @@ def _prepare_wellspec_events( ) wellspecs = sorted( - (event for event in events if event.event_type.upper() == "WELLSPEC"), + (event for event in events if event.event_type.upper() == "WELSPECS"), key=lambda event: event.event_date, ) for event in wellspecs: @@ -1138,14 +1345,16 @@ def _prepare_wellspec_events( unknown = set(attrs) - _WELLSPEC_KNOWN if unknown: report.errors.append( - f"Line {event.loc.line}: unknown WELLSPEC attribute(s): " - f"{', '.join(sorted(unknown))}" + _event_message( + event, + f"unknown WELSPECS attribute(s): {', '.join(sorted(unknown))}", + ) ) report.events_skipped += 1 continue if not (set(attrs) - {"COMMENT"}): report.errors.append( - f"Line {event.loc.line}: WELLSPEC needs at least one setting attribute" + _event_message(event, "WELSPECS needs at least one setting attribute") ) report.events_skipped += 1 continue @@ -1179,7 +1388,7 @@ def _prepare_wellspec_events( next_state.phase = phase if errors: - report.errors.extend(f"Line {event.loc.line}: {error}" for error in errors) + report.errors.extend(_event_message(event, error) for error in errors) report.events_skipped += 1 continue @@ -1248,9 +1457,10 @@ def apply_orion_document( if dispatch is None: 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}'?" + message = _event_message( + event, + f"Unknown event type '{event.event_type}'; " + f"did you mean '{typo_of}'?", ) if on_unknown_event == "error": raise RipsError(message) @@ -1334,15 +1544,19 @@ def _prepare_filter_context( continue if term.result_type is not None: missing.append( - f"Line {event.loc.line}: filter '{label}': result " - f"'{term.result_name}' not found among " - f"{term.result_type} results" + _event_message( + event, + f"filter '{label}': result '{term.result_name}' not found " + f"among {term.result_type} results", + ) ) else: missing.append( - f"Line {event.loc.line}: filter '{label}': result " - f"'{term.result_name}' not found (searched " - f"{', '.join(_UNQUALIFIED_RESULT_SEARCH_ORDER)})" + _event_message( + event, + f"filter '{label}': result '{term.result_name}' not found " + f"(searched {', '.join(_UNQUALIFIED_RESULT_SEARCH_ORDER)})", + ) ) if missing: raise RipsError("\n".join(missing)) @@ -1411,21 +1625,23 @@ def _apply_member_event( ) -> None: """Expand a GROUP MEMBER event into one GRUPTREE event per member.""" if group_name is None: - report.errors.append(f"Line {event.loc.line}: MEMBER needs a GROUP block") + report.errors.append(_event_message(event, "MEMBER needs a GROUP block")) report.events_skipped += 1 return unknown = set(event.attributes) - {"MEMBERS", "COMMENT"} if unknown: report.errors.append( - f"Line {event.loc.line}: unknown MEMBER attribute(s): " - f"{', '.join(sorted(unknown))}" + _event_message( + event, + f"unknown MEMBER attribute(s): {', '.join(sorted(unknown))}", + ) ) report.events_skipped += 1 return if "MEMBERS" not in event.attributes: report.errors.append( - f"Line {event.loc.line}: MEMBER missing required attribute: MEMBERS" + _event_message(event, "MEMBER missing required attribute: MEMBERS") ) report.events_skipped += 1 return @@ -1434,8 +1650,9 @@ def _apply_member_event( members = [member.strip() for member in raw_members.split(",")] if not members or any(not member for member in members): report.errors.append( - f"Line {event.loc.line}: MEMBERS must be a comma-delimited list of " - "non-empty names" + _event_message( + event, "MEMBERS must be a comma-delimited list of non-empty names" + ) ) report.events_skipped += 1 return @@ -1459,6 +1676,16 @@ def _apply_schedule_event( ) -> None: """Apply one GROUP- or SCHEDULE-block event as an Eclipse keyword.""" event_type = event.event_type.upper() + if event_type == "RAW_TEXT": + timeline.add_raw_text_event( + event_date=_iso_event_date(event.event_date), + text=event.raw_text, + placement=event.raw_placement, + anchor_keyword=event.raw_anchor or "", + priority=event.raw_priority, + ) + report.events_applied += 1 + return if event_type == "RESTART": timeline.add_keyword_event( event_date=_iso_event_date(event.event_date), @@ -1472,8 +1699,11 @@ def _apply_schedule_event( return 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 GROUP or SCHEDULE" + _event_message( + event, + f"{event_type} is a completion event and needs a WELL block, " + "not GROUP or SCHEDULE", + ) ) report.events_skipped += 1 return @@ -1484,19 +1714,25 @@ def _apply_schedule_event( continue if key in _IGNORED_KEYWORD_ATTRS: report.warnings.append( - f"Line {event.loc.line}: attribute '{key}' on {event_type} " - "is ignored (not yet supported)" + _event_message( + event, + f"attribute '{key}' on {event_type} is ignored (not yet supported)", + ) ) continue keyword_data[key] = attr.value if group_name is not None: keyword_data["GROUP"] = group_name - timeline_event = timeline.add_keyword_event( - event_date=_iso_event_date(event.event_date), - keyword_name=event_type, - keyword_data=keyword_data, - ) + try: + timeline_event = timeline.add_keyword_event( + event_date=_iso_event_date(event.event_date), + keyword_name=event_type, + keyword_data=keyword_data, + ) + except RipsError as exc: + _record_event_exception(event, exc, report) + return _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1519,23 +1755,29 @@ def _check_completion_attrs( unknown = set(attrs) - known - ignored if unknown: report.errors.append( - f"Line {event.loc.line}: unknown {type_name} attribute(s): " - f"{', '.join(sorted(unknown))}" + _event_message( + event, + f"unknown {type_name} attribute(s): {', '.join(sorted(unknown))}", + ) ) report.events_skipped += 1 return False for key in sorted(ignored & set(attrs)): report.warnings.append( - f"Line {event.loc.line}: attribute '{key}' on {type_name} " - "is ignored (not yet supported)" + _event_message( + event, + f"attribute '{key}' on {type_name} is ignored (not yet supported)", + ) ) missing = [name for name in required if name not in attrs] if missing: report.errors.append( - f"Line {event.loc.line}: {type_name} missing required " - f"attribute(s): {', '.join(missing)}" + _event_message( + event, + f"{type_name} missing required attribute(s): {', '.join(missing)}", + ) ) report.events_skipped += 1 return False @@ -1572,8 +1814,7 @@ def _apply_perforation( _as_number(attrs["COMPLETION_NUMBER"], event.loc) ) except OrionParseError as exc: - report.errors.append(str(exc)) - report.events_skipped += 1 + _record_event_parse_error(event, exc, report) return perf_event = timeline.add_perf_event(**kwargs) @@ -1619,8 +1860,7 @@ def _apply_segment( "PRESSURE_COMPONENTS must be H--, HF-, or HFA", event.loc ) except OrionParseError as exc: - report.errors.append(str(exc)) - report.events_skipped += 1 + _record_event_parse_error(event, exc, report) return timeline_event = timeline.add_tubing_event(**kwargs) @@ -1673,8 +1913,7 @@ def _apply_valve( 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 + _record_event_parse_error(event, exc, report) return timeline_event = timeline.add_valve_event(**kwargs) @@ -1740,18 +1979,25 @@ def _apply_keyword( continue if key in _IGNORED_KEYWORD_ATTRS: report.warnings.append( - f"Line {event.loc.line}: attribute '{key}' on {keyword_name} " - "is ignored (not yet supported)" + _event_message( + event, + f"attribute '{key}' on {keyword_name} is ignored " + "(not yet supported)", + ) ) continue keyword_data[field_map.get(key, key)] = attr.value - timeline_event = timeline.add_well_keyword_event( - event_date=_iso_event_date(event.event_date), - well_path=well_path, - keyword_name=keyword_name, - keyword_data=keyword_data, - ) + try: + timeline_event = timeline.add_well_keyword_event( + event_date=_iso_event_date(event.event_date), + well_path=well_path, + keyword_name=keyword_name, + keyword_data=keyword_data, + ) + except RipsError as exc: + _record_event_exception(event, exc, report) + return _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1804,7 +2050,7 @@ def _apply_generic_well_keyword( "SEGMENT": _apply_segment, "VALVE": _apply_valve, "STATE": _apply_state, - "WELLSPEC": _apply_wellspec, + "WELSPECS": _apply_wellspec, "WCONHIST": _apply_wconhist, "WELTARG": _apply_weltarg, } @@ -1816,7 +2062,7 @@ def _apply_generic_well_keyword( "SEGMENT", "VALVE", "STATE", - "WELLSPEC", + "WELSPECS", ) diff --git a/docs/rips/tests/test_orion_events.py b/docs/rips/tests/test_orion_events.py index 371f671356..d17d86f78f 100644 --- a/docs/rips/tests/test_orion_events.py +++ b/docs/rips/tests/test_orion_events.py @@ -188,6 +188,7 @@ def __init__(self): self.state_calls = [] self.wellspec_calls = [] self.schedule_keyword_calls = [] + self.raw_text_calls = [] self.created_events = [] def add_perf_event(self, **kwargs): @@ -225,6 +226,10 @@ def add_keyword_event(self, **kwargs): self.schedule_keyword_calls.append(kwargs) return self._new_event() + def add_raw_text_event(self, **kwargs): + self.raw_text_calls.append(kwargs) + return self._new_event() + # --------------------------------------------------------------------------- # Layer A: parsing @@ -489,19 +494,19 @@ def test_malformed_group_line_rejected(self): def test_duplicate_wellspec_for_well_and_date_is_rejected(self): text = ( 'ORIONEVENTS 2.0\nWELL "W"\n' - " @2024-01-01 WELLSPEC GROUP=A\n" + " @2024-01-01 WELSPECS GROUP=A\n" 'WELL "W"\n' - " @2024-01-01 WELLSPEC PHASE=GAS\n" + " @2024-01-01 WELSPECS PHASE=GAS\n" ) - with pytest.raises(OrionParseError, match="WELLSPEC already defined.*line 3"): + with pytest.raises(OrionParseError, match="WELSPECS already defined.*line 3"): parse_orion_events(text) def test_wellspec_same_date_for_different_wells_is_allowed(self): document = parse_orion_events( 'ORIONEVENTS 2.0\nWELL "A"\n' - " @2024-01-01 WELLSPEC GROUP=GA\n" + " @2024-01-01 WELSPECS GROUP=GA\n" 'WELL "B"\n' - " @2024-01-01 WELLSPEC GROUP=GB\n" + " @2024-01-01 WELSPECS GROUP=GB\n" ) assert [len(well.events) for well in document.wells] == [1, 1] @@ -557,6 +562,69 @@ 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_raw_text_block_preserves_body_and_attributes(self): + text = ( + "ORIONEVENTS 2.0\nSCHEDULE\n" + " @2024-01-01 RAW_TEXT PLACEMENT=BEFORE_KEYWORD " + "ANCHOR=COMPDAT PRIORITY=-2\n" + "# not an ORION comment\n" + " @this is raw too\n" + "END\n" + "END_RAW_TEXT\n" + ) + event = parse_orion_events(text).schedule_events[0] + + assert event.event_type == "RAW_TEXT" + assert event.raw_text == "# not an ORION comment\n @this is raw too\nEND\n" + assert event.raw_placement == "BEFORE_KEYWORD" + assert event.raw_anchor == "COMPDAT" + assert event.raw_priority == -2 + + @pytest.mark.parametrize( + "header,error", + [ + ("RAW_TEXT", "requires PLACEMENT"), + ("RAW_TEXT PLACEMENT=NOPE", "PLACEMENT must be"), + ( + "RAW_TEXT PLACEMENT=BEFORE_KEYWORD", + "ANCHOR is required", + ), + ( + "RAW_TEXT PLACEMENT=AFTER_DATE ANCHOR=COMPDAT", + "ANCHOR is only valid", + ), + ( + 'RAW_TEXT PLACEMENT=END_OF_DATE PRIORITY="1"', + "PRIORITY must be an integer", + ), + ( + "RAW_TEXT PLACEMENT=END_OF_DATE EXTRA=1", + "Unknown RAW_TEXT attribute", + ), + ], + ) + def test_invalid_raw_text_header_rejected(self, header, error): + text = f"ORIONEVENTS 2.0\nSCHEDULE\n @2024-01-01 {header}\nx\nEND_RAW_TEXT\n" + with pytest.raises(OrionParseError, match=error): + parse_orion_events(text) + + def test_raw_text_outside_schedule_rejected(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "W"\n' + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE\n" + "x\nEND_RAW_TEXT\n" + ) + with pytest.raises(OrionParseError, match="only valid in a SCHEDULE"): + parse_orion_events(text) + + def test_unterminated_raw_text_rejected(self): + text = ( + "ORIONEVENTS 2.0\nSCHEDULE\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE\ntext\n" + ) + with pytest.raises(OrionParseError, match="Unterminated RAW_TEXT"): + parse_orion_events(text) + def test_report_lines_parse(self): text = ( "ORIONEVENTS 2.0\n" @@ -906,6 +974,28 @@ def test_same_owner_type_and_date_events_are_merged(self): assert timeline.keyword_calls[0]["keyword_data"]["ORAT"] == 100 assert timeline.keyword_calls[0]["keyword_data"]["CMODE"] == "ORAT" + def test_same_date_perforations_are_not_merged(self): + text = ( + "ORIONEVENTS 2.0\n" + 'WELL "55_33-A-1"\n' + " @2018-01-01 PERFORATION MDSTART=1000 MDEND=1100 COMPLETION_NUMBER=1\n" + " @2018-01-01 PERFORATION MDSTART=1200 MDEND=1300 COMPLETION_NUMBER=2\n" + ) + + document = parse_orion_events(text) + merged = coalesce_orion_document(document) + + assert len(merged.wells[0].events) == 2 + + timeline, report = self._apply(text) + assert report.events_applied == 2 + assert report.events_skipped == 0 + assert [(call["start_md"], call["end_md"]) for call in timeline.perf_calls] == [ + (1000.0, 1100.0), + (1200.0, 1300.0), + ] + assert [call["completion_number"] for call in timeline.perf_calls] == [1, 2] + def test_group_and_schedule_events_merge_only_within_owner(self): text = ( "ORIONEVENTS 2.0\n" @@ -1065,6 +1155,7 @@ def test_perforation_missing_required_attr_is_error(self): _, report = self._apply(text) assert report.events_skipped == 1 assert any("MDEND" in e for e in report.errors) + assert 'Line 3 [WELL "55_33-A-1", date 2018-01-01]' in report.errors[0] def test_perforation_unknown_attr_is_error(self): text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 ZZZ=3\n' @@ -1075,8 +1166,8 @@ def test_perforation_unknown_attr_is_error(self): def test_wellspec_partial_updates_are_cumulative_by_date(self): text = ( 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' - " @2019-01-01 WELLSPEC CROSSFLOW=False PHASE=gas\n" - " @2018-01-01 WELLSPEC GROUP=my_group REFDEPTH=1002 PHASE=water\n" + " @2019-01-01 WELSPECS CROSSFLOW=False PHASE=gas\n" + " @2018-01-01 WELSPECS GROUP=my_group REFDEPTH=1002 PHASE=water\n" ) timeline, report = self._apply(text) @@ -1102,13 +1193,13 @@ def test_wellspec_partial_updates_are_cumulative_by_date(self): ("REFDEPTH=deep", "REFDEPTH must be numeric"), ("PHASE=steam", "PHASE must be OIL, GAS, WATER, or LIQUID"), ("GROUP=1", "GROUP must be a non-empty string"), - ("UNKNOWN=1", "unknown WELLSPEC attribute"), + ("UNKNOWN=1", "unknown WELSPECS attribute"), ("COMMENT=empty", "needs at least one setting attribute"), ], ) def test_invalid_wellspec_is_reported_and_skipped(self, attributes, expected_error): text = ( - f'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 WELLSPEC {attributes}\n' + f'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2018-01-01 WELSPECS {attributes}\n' ) timeline, report = self._apply(text) @@ -1162,6 +1253,14 @@ def test_tubing_is_reported_as_renamed_event(self): assert timeline.keyword_calls == [] assert any("did you mean 'SEGMENT'" in warning for warning in report.warnings) + def test_wellspec_is_reported_as_renamed_event(self): + text = 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n @2024-01-01 WELLSPEC GROUP=FIELD\n' + timeline, report = self._apply(text) + + assert report.events_skipped == 1 + assert timeline.wellspec_calls == [] + assert any("did you mean 'WELSPECS'" in warning for warning in report.warnings) + def test_valve_mapping(self): text = ( 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' @@ -1238,6 +1337,29 @@ def test_restart_event_creates_non_emitting_timeline_marker(self): } ] + def test_raw_text_event_is_applied_without_coalescing(self): + text = ( + "ORIONEVENTS 2.0\nSCHEDULE\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE PRIORITY=2\n" + "first\nEND_RAW_TEXT\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE PRIORITY=1\n" + "second\nEND_RAW_TEXT\n" + ) + timeline, report = self._apply(text) + + assert report.events_applied == 2 + assert [call["text"] for call in timeline.raw_text_calls] == [ + "first\n", + "second\n", + ] + assert timeline.raw_text_calls[0] == { + "event_date": "2024-01-01", + "text": "first\n", + "placement": "AFTER_DATE", + "anchor_keyword": "", + "priority": 2, + } + def test_group_events_inject_group_name(self): text = ( 'ORIONEVENTS 2.0\nGROUP "OP"\n' @@ -1304,6 +1426,8 @@ def test_invalid_member_event_is_skipped(self, block, event, expected_error): assert report.events_skipped == 1 assert timeline.schedule_keyword_calls == [] assert expected_error in report.errors[0] + expected_scope = 'GROUP "G"' if block.startswith("GROUP") else "SCHEDULE" + assert f"[{expected_scope}, date 2024-01-01]" in report.errors[0] def test_completion_event_in_schedule_block_is_error(self): text = ( @@ -1491,29 +1615,49 @@ def project_with_case_and_wells(self, rips_instance, initialize_test): well_path_coll = project.descendants(rips.WellPathCollection)[0] return project, case, well_path_coll.event_timeline() - def test_compdat_invalid_item_names_report_valid_names( + def test_wconhist_invalid_item_names_report_context_and_valid_names( self, project_with_case_and_wells ): - """Invalid COMPDAT items produce an actionable error (issue #14535).""" 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' - " @2024-01-01 COMPDAT STATUS=OPEN TRANSMISSIBILITY=1.0\n" + " @2024-01-01 WCONHIST STATUS=OPEN INVALID_FIELD=1.0\n" ) - with pytest.raises(rips.RipsError) as exc_info: - apply_orion_document(document, timeline, project) + report = apply_orion_document(document, timeline, project) - error_msg = str(exc_info.value) - assert "Keyword 'COMPDAT' contains invalid item names" in error_msg - assert "STATUS, TRANSMISSIBILITY" in error_msg + assert report.events_applied == 0 + assert report.events_skipped == 1 + assert len(report.errors) == 1 + error_msg = report.errors[0] + assert f'Line 3 [WELL "{well.name}", date 2024-01-01]' in error_msg assert ( - "Valid item names are: WELL, I, J, K1, K2, STATE, SAT_TABLE, " - "CONNECTION_TRANSMISSIBILITY_FACTOR, DIAMETER, Kh, SKIN, D_FACTOR, " - "DIR, PR" - ) in error_msg + "Keyword 'WCONHIST' contains invalid item names: INVALID_FIELD" in error_msg + ) + assert "Valid item names are:" in error_msg + + def test_unknown_keyword_reports_context_and_continues( + 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' + " @2024-01-01 NOT_A_KEYWORD VALUE=1\n" + " @2024-01-02 WCONHIST STATUS=OPEN\n" + ) + + report = apply_orion_document(document, timeline, project) + + assert report.events_applied == 1 + assert report.events_skipped == 1 + assert len(report.errors) == 1 + error_msg = report.errors[0] + assert f'Line 3 [WELL "{well.name}", date 2024-01-01]' in error_msg + assert "Keyword 'NOT_A_KEYWORD' is not recognized by opm-common" in error_msg def test_rptrst_boolean_values_emit_bare_mnemonics( self, project_with_case_and_wells @@ -1544,6 +1688,78 @@ def test_rptrst_boolean_values_emit_bare_mnemonics( assert f"{flag}=True" not in rptrst_block assert "NORST" not in tokens + def test_raw_text_placement_and_priority(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' + " @2024-01-01 WCONHIST STATUS=OPEN\n" + "SCHEDULE\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE PRIORITY=5\n" + "-- after-date-late\nEND_RAW_TEXT\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE PRIORITY=-1\n" + "-- after-date-early\nEND_RAW_TEXT\n" + " @2024-01-01 RAW_TEXT PLACEMENT=BEFORE_KEYWORD ANCHOR=WCONHIST\n" + "-- before-wconhist\nEND_RAW_TEXT\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_KEYWORD ANCHOR=WCONHIST\n" + "-- after-wconhist\nEND_RAW_TEXT\n" + " @2024-01-01 RAW_TEXT PLACEMENT=END_OF_DATE\n" + "-- end-of-date\nEND_RAW_TEXT\n" + ) + + report = apply_orion_document(document, timeline, project) + assert report.errors == [] + schedule = timeline.generate_schedule_text( + eclipse_case=case, first_date_as_comment=False + ) + + positions = [ + schedule.index(marker) + for marker in ( + "DATES", + "-- after-date-early", + "-- after-date-late", + "-- before-wconhist", + "WCONHIST", + "-- after-wconhist", + "-- end-of-date", + ) + ] + assert positions == sorted(positions) + + def test_raw_text_only_schedule_is_generated(self, project_with_case_and_wells): + project, case, timeline = project_with_case_and_wells + document = parse_orion_events( + "ORIONEVENTS 2.0\nSCHEDULE\n" + " @2024-01-01 RAW_TEXT PLACEMENT=AFTER_DATE\n" + "-- raw-only\nEND_RAW_TEXT\n" + ) + + apply_orion_document(document, timeline, project) + schedule = timeline.generate_schedule_text( + eclipse_case=case, first_date_as_comment=False + ) + assert "DATES" in schedule + assert "-- raw-only\n" in schedule + + schedule_with_date_comment = timeline.generate_schedule_text(eclipse_case=case) + assert "-- Date: 1 JAN 2024\n-- raw-only\n" in schedule_with_date_comment + + def test_raw_text_missing_anchor_fails_generation( + self, project_with_case_and_wells + ): + project, case, timeline = project_with_case_and_wells + document = parse_orion_events( + "ORIONEVENTS 2.0\nSCHEDULE\n" + " @2024-01-01 RAW_TEXT PLACEMENT=BEFORE_KEYWORD ANCHOR=COMPDAT\n" + "text\nEND_RAW_TEXT\n" + ) + + apply_orion_document(document, timeline, project) + with pytest.raises(rips.RipsError, match="COMPDAT.*not emitted"): + timeline.generate_schedule_text(eclipse_case=case) + def test_event_comment_precedes_generated_keyword( self, project_with_case_and_wells ): @@ -1620,8 +1836,8 @@ def test_wellspec_updates_settings_and_generates_cumulative_welspecs( document = parse_orion_events( "ORIONEVENTS 2.0\n" f'WELL "{well.name}"\n' - " @2018-01-01 WELLSPEC GROUP=my_group REFDEPTH=1002 PHASE=water\n" - " @2019-01-01 WELLSPEC CROSSFLOW=False REFDEPTH=1000 PHASE=oil\n" + " @2018-01-01 WELSPECS GROUP=my_group REFDEPTH=1002 PHASE=water\n" + " @2019-01-01 WELSPECS CROSSFLOW=False REFDEPTH=1000 PHASE=oil\n" ) report = apply_orion_document(document, timeline, project) @@ -1694,6 +1910,24 @@ def test_restart_truncates_generated_schedule(self, project_with_case_and_wells) assert " 300" in schedule assert "RESTART\n" not in schedule + def test_report_only_document_generates_schedule(self, project_with_case_and_wells): + project, case, timeline = project_with_case_and_wells + document = parse_orion_events( + "ORIONEVENTS 2.0\nREPORT 2024-02-01\nREPORT 2024-06-01\n" + ) + + report = apply_orion_document(document, timeline, project) + schedule = timeline.generate_schedule_text( + eclipse_case=case, + first_date_as_comment=False, + additional_dates=report.report_dates, + ) + + assert report.events_applied == 0 + assert report.report_dates == ["2024-02-01", "2024-06-01"] + assert "1 'FEB' 2024" in schedule + assert "1 'JUN' 2024" 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 diff --git a/docs/rips/tests/test_project.py b/docs/rips/tests/test_project.py index 0c7f80bc7b..3283e4885f 100644 --- a/docs/rips/tests/test_project.py +++ b/docs/rips/tests/test_project.py @@ -111,6 +111,18 @@ def test_views_and_view_lookup(rips_instance, initialize_test): assert project.view(999999) is None +def test_link_and_unlink_views(rips_instance, initialize_test): + case_path = dataroot.PATH + "/TEST10K_FLT_LGR_NNC/TEST10K_FLT_LGR_NNC.EGRID" + case = rips_instance.project.load_case(case_path) + first_view = case.create_view() + second_view = case.create_view() + + project = rips_instance.project + project.link_views(views=[first_view, second_view]) + project.unlink_views(views=[second_view]) + project.unlink_views(views=[first_view]) + + def test_well_path_by_name(rips_instance, initialize_test): well_files = [dataroot.PATH + "/TEST10K_FLT_LGR_NNC/wellpath_a.dev"] rips_instance.project.import_well_paths(well_path_files=well_files) diff --git a/docs/rips/tests/test_well_events.py b/docs/rips/tests/test_well_events.py index 60dff1aa8a..3a6e37307b 100644 --- a/docs/rips/tests/test_well_events.py +++ b/docs/rips/tests/test_well_events.py @@ -1469,6 +1469,38 @@ def test_schedule_header_contains_generation_metadata( assert lines[2].startswith("-- User: ") assert lines[2] != "-- User: " + def test_schedule_header_uses_platform_user_variable(self, monkeypatch): + """Prefer USER on Unix and USERNAME on Windows when both are set.""" + if sys.platform == "win32": + monkeypatch.setenv("USERNAME", "preferred-schedule-user") + monkeypatch.setenv("USER", "wrong-schedule-user") + else: + monkeypatch.setenv("USER", "preferred-schedule-user") + monkeypatch.setenv("USERNAME", "wrong-schedule-user") + + instance = rips.Instance.launch(console=True) + try: + case_root = dataroot.PATH + "/TEST10K_FLT_LGR_NNC" + project = instance.project + case = project.load_case(path=case_root + "/TEST10K_FLT_LGR_NNC.EGRID") + project.import_well_paths(well_path_files=[case_root + "/wellpath_a.dev"]) + well_path = project.well_paths()[0] + timeline = project.descendants(rips.WellPathCollection)[0].event_timeline() + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path, + start_md=2000.0, + end_md=2200.0, + diameter=0.1, + state="OPEN", + ) + + schedule = timeline.generate_schedule_text(eclipse_case=case) + assert "-- User: preferred-schedule-user\n" in schedule + assert "wrong-schedule-user" not in schedule + finally: + instance.exit() + def test_event_comment_lines_are_safely_emitted(self, project_with_case_and_well): project, case, timeline = project_with_case_and_well well_path = project.well_paths()[0] diff --git a/docs/rips/tests/test_wells.py b/docs/rips/tests/test_wells.py index 832ecd8812..58f699759a 100644 --- a/docs/rips/tests/test_wells.py +++ b/docs/rips/tests/test_wells.py @@ -408,3 +408,24 @@ def test_import_rmswell_with_w_extension(rips_instance, initialize_test): # File has 5 points from MD 0-20m, resampled at 1.0m interval gives 21 points result = wells[0].trajectory_properties(resampling_interval=1.0) assert len(result["measured_depth"]) == 21 + + +def test_rmswell_renamed_name_persists(rips_instance, initialize_test, tmp_path): + well_path_file = os.path.abspath( + "../../../ApplicationLibCode/UnitTests/TestData/RifRmsWellPathReader/55_33-1.rmswell" + ) + project = rips_instance.project + project.import_well_paths([well_path_file]) + + well = project.well_paths()[0] + well.name = "Renamed RMS Well" + well.update() + + project_file = tmp_path / "renamed-rms-well.rsp" + project.save(str(project_file)) + project.close() + + reopened_project = project.open(str(project_file)) + reopened_wells = reopened_project.well_paths() + assert len(reopened_wells) == 1 + assert reopened_wells[0].name == "Renamed RMS Well" diff --git a/docs/rips/well_events.py b/docs/rips/well_events.py index cb9d57fcff..1c100d73f8 100644 --- a/docs/rips/well_events.py +++ b/docs/rips/well_events.py @@ -13,6 +13,7 @@ from .resinsight_classes import EclipseCase from .generated.generated_classes import ( KeywordEvent, + Placement, WellEventKeyword, WellEventTimeline, WellPath, @@ -65,58 +66,58 @@ def add_well_keyword_event( TypeError: If keyword_data contains unsupported value types Example: - ```python - # Get the timeline - well_path = project.well_paths()[0] - timeline = well_path.event_timeline() - - # Add WCONHIST - Historical production data - timeline.add_well_keyword_event( - event_date="2018-04-01", - well_path=well_path, - keyword_name="WCONHIST", - keyword_data={ - "WELL": well_path.name, - "STATUS": "OPEN", - "CMODE": "RESV", - "ORAT": 3999.98999, - "WRAT": 0.01, - "GRAT": 550678.438, - "VFP_TABLE": 1 - } - ) + .. code-block:: python + + # Get the timeline + well_path = project.well_paths()[0] + timeline = well_path.event_timeline() + + # Add WCONHIST - Historical production data + timeline.add_well_keyword_event( + event_date="2018-04-01", + well_path=well_path, + keyword_name="WCONHIST", + keyword_data={ + "WELL": well_path.name, + "STATUS": "OPEN", + "CMODE": "RESV", + "ORAT": 3999.98999, + "WRAT": 0.01, + "GRAT": 550678.438, + "VFP_TABLE": 1, + }, + ) - # Add WELTARG - Change target - timeline.add_well_keyword_event( - event_date="2018-05-01", - well_path=well_path, - keyword_name="WELTARG", - keyword_data={ - "WELL": well_path.name, - "CMODE": "ORAT", - "NEW_VALUE": 5000.0 - } - ) + # Add WELTARG - Change target + timeline.add_well_keyword_event( + event_date="2018-05-01", + well_path=well_path, + keyword_name="WELTARG", + keyword_data={ + "WELL": well_path.name, + "CMODE": "ORAT", + "NEW_VALUE": 5000.0, + }, + ) - # Add WRFTPLT - Enable RFT output - timeline.add_well_keyword_event( - event_date="2018-06-01", - well_path=well_path, - keyword_name="WRFTPLT", - keyword_data={ - "WELL": well_path.name, - "OUTPUT_RFT": "YES", - "OUTPUT_PLT": "NO" - } - ) + # Add WRFTPLT - Enable RFT output + timeline.add_well_keyword_event( + event_date="2018-06-01", + well_path=well_path, + keyword_name="WRFTPLT", + keyword_data={ + "WELL": well_path.name, + "OUTPUT_RFT": "YES", + "OUTPUT_PLT": "NO", + }, + ) - # Generate schedule - case = project.cases()[0] - schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[well_path] - ) - print(schedule_text) - ``` + # Generate schedule + case = project.cases()[0] + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=[well_path] + ) + print(schedule_text) """ # Type inference and conversion item_names = [] @@ -198,41 +199,41 @@ def add_keyword_event( TypeError: If keyword_data contains unsupported value types Example: - ```python - # Get the timeline - well_path_coll = project.descendants(rips.WellPathCollection)[0] - timeline = well_path_coll.event_timeline() - - # Add RPTRST - Report restart settings (schedule-level, not well-specific) - timeline.add_keyword_event( - event_date="2024-01-01", - keyword_name="RPTRST", - keyword_data={ - "BASIC": 2, - "FREQ": 1, - } - ) + .. code-block:: python + + # Get the timeline + well_path_coll = project.descendants(rips.WellPathCollection)[0] + timeline = well_path_coll.event_timeline() + + # Add RPTRST - Report restart settings (schedule-level, not well-specific) + timeline.add_keyword_event( + event_date="2024-01-01", + keyword_name="RPTRST", + keyword_data={ + "BASIC": 2, + "FREQ": 1, + }, + ) - # Add GRUPTREE - Group tree definition - timeline.add_keyword_event( - event_date="2024-01-01", - keyword_name="GRUPTREE", - keyword_data={ - "CHILD_GROUP": "OP", - "PARENT_GROUP": "FIELD", - } - ) + # Add GRUPTREE - Group tree definition + timeline.add_keyword_event( + event_date="2024-01-01", + keyword_name="GRUPTREE", + keyword_data={ + "CHILD_GROUP": "OP", + "PARENT_GROUP": "FIELD", + }, + ) - # Add RPTSCHED - Report schedule settings - timeline.add_keyword_event( - event_date="2024-01-01", - keyword_name="RPTSCHED", - keyword_data={ - "FIP": 1, - "WELLS": 2, - } - ) - ``` + # Add RPTSCHED - Report schedule settings + timeline.add_keyword_event( + event_date="2024-01-01", + keyword_name="RPTSCHED", + keyword_data={ + "FIP": 1, + "WELLS": 2, + }, + ) """ # Type inference and conversion item_names = [] @@ -283,6 +284,31 @@ def add_keyword_event( ) +@add_method(WellEventTimeline) +def add_raw_text_event( + self: WellEventTimeline, + event_date: str | date | datetime, + text: str, + placement: str = "AFTER_DATE", + anchor_keyword: str = "", + priority: int = 0, +) -> Any: + """Add raw text at a specific position in a dated schedule section. + + ``placement`` is one of ``AFTER_DATE``, ``BEFORE_KEYWORD``, + ``AFTER_KEYWORD``, or ``END_OF_DATE``. ``anchor_keyword`` is required for + before/after-keyword placement and must be empty for the other placements. + Lower priority values are emitted first; source order breaks ties. + """ + return self.add_raw_text_event_internal( + event_date=_format_date(event_date), + text=text, + placement=Placement(placement), + anchor_keyword=anchor_keyword, + priority=priority, + ) + + @add_method(WellEventTimeline) def generate_schedule_text( self: WellEventTimeline, @@ -330,38 +356,38 @@ def generate_schedule_text( for all wells in the collection. Example: - ```python - # Get the timeline (shared across all wells) - well_path = project.well_paths()[0] - timeline = well_path.event_timeline() - - # Add events for multiple wells - timeline.add_perf_event( - event_date="2024-01-01", - well_name="WELL-1", - start_md=1000, - end_md=1500, - diameter=0.1, - skin_factor=0.5, - state="OPEN" - ) + .. code-block:: python + + # Get the timeline (shared across all wells) + well_path = project.well_paths()[0] + timeline = well_path.event_timeline() + + # Add events for multiple wells + timeline.add_perf_event( + event_date="2024-01-01", + well_name="WELL-1", + start_md=1000, + end_md=1500, + diameter=0.1, + skin_factor=0.5, + state="OPEN", + ) - timeline.add_perf_event( - event_date="2024-02-01", - well_name="WELL-2", - start_md=2000, - end_md=2500, - diameter=0.1, - state="OPEN" - ) + timeline.add_perf_event( + event_date="2024-02-01", + well_name="WELL-2", + start_md=2000, + end_md=2500, + diameter=0.1, + state="OPEN", + ) - # Generate schedule text, exporting MSW keywords for all wells - case = project.cases()[0] - schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=project.well_paths() - ) - print(schedule_text) - ``` + # Generate schedule text, exporting MSW keywords for all wells + case = project.cases()[0] + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=project.well_paths() + ) + print(schedule_text) """ container = self.generate_schedule( eclipse_case=eclipse_case,