diff --git a/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py b/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py index dd86999e6..e3ab03913 100644 --- a/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py +++ b/docs/rips/PythonExamples/experimental/well_event_schedule_orion.py @@ -9,16 +9,23 @@ 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 +1. SEGMENT, PERFORATION (incl. a time-of-day date), VALVE and STATE completion events on a well -2. A FILTER declaration (qualified result name) referenced by a perforation, +2. Partial WELLSPEC 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 -3. Well keyword events: WCONHIST and WELTARG (with attribute translation) and +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 +6. Well keyword events: WCONHIST and WELTARG (with attribute translation) and WRFTPLT (generic Eclipse well keyword pass-through) -4. SCHEDULE-level keyword events not tied to a well: RPTRST, GRUPTREE, TUNING -5. REPORT dates, passed to generate_schedule_text(additional_dates=...) so +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 they appear as bare DATES keywords (summary-report triggers) -6. Generating Eclipse schedule text from the resulting timeline +10. 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 @@ -52,11 +59,16 @@ def build_orion_text(well_name, with_filter): 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 + # WELLSPEC 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 + + # 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} - @STARTUP + RAMP PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1{filter_ref} + @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 # Time-of-day is preserved and emitted as the TIME field of DATES @@ -66,11 +78,19 @@ def build_orion_text(well_name, with_filter): @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 + # Matching owner/type/date lines merge. The second line extends the first; + # repeated attributes would use the value from the later line. + @2024-01-15 WCONHIST STATUS=OPEN CMODE=RESV COMMENT="Start production history controls" + @2024-01-15 WCONHIST ORAT=3999.99 WRAT=0.01 GRAT=550678.44 VFP=1 + + # WRFTPLT is passed through as a generic Eclipse keyword. @2024-05-01 WELTARG CMODE=ORAT VALUE=5000.0 @2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO +# MEMBER expands into one GRUPTREE record per unique comma-delimited member. +GROUP "OP" + @STARTUP MEMBER MEMBERS="{well_name},OBSERVER" COMMENT="Define operating group members" + # Schedule-level keywords (not tied to a well) SCHEDULE @STARTUP RPTRST BASIC=2 FREQ=1 @@ -110,7 +130,12 @@ def main(): 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)}") + source_well_event_count = sum(len(w.events) for w in document.wells) + normalized = rips.orion_events.coalesce_orion_document(document) + merged_well_event_count = sum(len(w.events) for w in normalized.wells) + print(f" Source well-event lines: {source_well_event_count}") + print(f" Events after same-date merge: {merged_well_event_count}") + print(f" Groups: {[group.group_name for group in document.groups]}") print(f" Schedule events: {len(document.schedule_events)}") print("\n3. Applying events to the timeline...") @@ -130,7 +155,14 @@ def main(): # Apply events up to a date to materialize completions timeline.set_timestamp(timestamp="2024-12-24") - print("\n4. Verifying created completions...") + print("\n4. Verifying created completions and WELLSPEC settings...") + completion_settings = well_path.completion_settings() + print(" Completion export settings after the latest WELLSPEC:") + 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}") + print(f" Phase: {completion_settings.well_type_for_export}") + perforations = well_path.completions().perforations().perforations() print(f" Perforations created: {len(perforations)}") for perf in perforations: @@ -147,12 +179,14 @@ def main(): if case is None: print(" No Eclipse case loaded - skipping schedule generation.") return - # REPORT dates from the ORIONEVENTS text become bare DATES keywords - # (summary-report triggers) via additional_dates. + # REPORT dates become bare DATES keywords via additional_dates. Aligned output + # adds column-title comments; the schedule header identifies its timestamp and + # user, and each generated WELSPECS record has a matching COMPORD INPUT record. schedule_text = timeline.generate_schedule_text( eclipse_case=case, export_msw_for_wells=[well_path], additional_dates=report.report_dates, + align_columns=True, ) if schedule_text: print(f" Generated schedule text ({len(schedule_text)} characters)") @@ -163,6 +197,8 @@ def main(): expected_keywords = [ "DATES", + "WELSPECS", + "COMPORD", "COMPDAT", "COMPLUMP", "WCONHIST", diff --git a/docs/rips/generated/generated_classes.py b/docs/rips/generated/generated_classes.py index 888aa4717..1df82a340 100644 --- a/docs/rips/generated/generated_classes.py +++ b/docs/rips/generated/generated_classes.py @@ -278,18 +278,18 @@ class WellStatus(StrEnum): STOP = "STOP" class WellType(StrEnum): + OIL = "OIL" + GAS = "GAS" + WATER = "WATER" + LIQUID = "LIQUID" + +class WellType2(StrEnum): OIL_PRODUCER = "OIL_PRODUCER" GAS_PRODUCER = "GAS_PRODUCER" WATER_PRODUCER = "WATER_PRODUCER" WATER_INJECTOR = "WATER_INJECTOR" GAS_INJECTOR = "GAS_INJECTOR" -class WellTypeForExport(StrEnum): - OIL = "OIL" - GAS = "GAS" - WATER = "WATER" - LIQUID = "LIQUID" - class CellFilterCollection(PdmObjectBase): """ Attributes: @@ -1773,11 +1773,13 @@ class WellEvent(PdmObjectBase): WellEvent Attributes: + comment (str): Comment well_path (Optional[WellPath]): Well Path """ __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.comment: str = "" self.well_path: Optional[WellPath] = None PdmObjectBase.__init__(self, pb2_object, channel) if WellEvent.__custom_init__ is not None: @@ -4109,6 +4111,23 @@ def add_well_keyword_event_internal(self, event_date: str="2024-01-01", well_pat return self._call_pdm_method_return_value("AddWellKeywordEventInternal", WellEventKeyword, event_date=event_date, well_path=well_path, keyword_name=keyword_name, item_names=item_names, item_types=item_types, item_values=item_values) + def add_wellspec_event(self, event_date: str="2024-01-01", well_path: Optional[WellPath]=None, group_name: str="", allow_cross_flow: bool=True, reference_depth: Optional[Optional[float]]=None, well_type: WellType=WellType.OIL) -> WellEventWellSpec: + """ + Add a WELLSPEC event to the timeline + + Arguments: + event_date (str): Event Date (YYYY-MM-DD) + well_path (Optional[WellPath]): Well Path + group_name (str): Group Name + allow_cross_flow (bool): Allow Well Cross-Flow + reference_depth (Optional[Optional[float]]): Reference Depth + well_type (WellType): One of [OIL, GAS, WATER, LIQUID] + Returns: + WellEventWellSpec + """ + return self._call_pdm_method_return_value("AddWellspecEvent", WellEventWellSpec, event_date=event_date, well_path=well_path, group_name=group_name, allow_cross_flow=allow_cross_flow, reference_depth=reference_depth, well_type=well_type) + + def events(self) -> List[WellEvent]: """Events @@ -4172,12 +4191,12 @@ class WellEventType(WellEvent): WellEventType Attributes: - well_type (WellType): One of [OIL_PRODUCER, GAS_PRODUCER, WATER_PRODUCER, WATER_INJECTOR, GAS_INJECTOR] + well_type (WellType2): One of [OIL_PRODUCER, GAS_PRODUCER, WATER_PRODUCER, WATER_INJECTOR, GAS_INJECTOR] """ __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.well_type: WellType = WellType.OIL_PRODUCER + self.well_type: WellType2 = WellType2.OIL_PRODUCER WellEvent.__init__(self, pb2_object, channel) if WellEventType.__custom_init__ is not None: WellEventType.__custom_init__(self, pb2_object=pb2_object, channel=channel) @@ -4217,6 +4236,27 @@ def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: if WellEventValve.__custom_init__ is not None: WellEventValve.__custom_init__(self, pb2_object=pb2_object, channel=channel) +class WellEventWellSpec(WellEvent): + """ + WellEventWellSpec + + Attributes: + allow_cross_flow (bool): Allow Cross-Flow + group_name (str): Group Name + reference_depth (Optional[float]): Reference Depth + well_type (WellType): One of [OIL, GAS, WATER, LIQUID] + """ + __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.allow_cross_flow: bool = True + self.group_name: str = "" + self.reference_depth: Optional[float] = None + self.well_type: WellType = WellType.OIL + WellEvent.__init__(self, pb2_object, channel) + if WellEventWellSpec.__custom_init__ is not None: + WellEventWellSpec.__custom_init__(self, pb2_object=pb2_object, channel=channel) + class PlotCurve(PdmObjectBase): __custom_init__ = None #: Assign a custom init routine to be run at __init__ @@ -4326,18 +4366,6 @@ 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: @@ -4381,6 +4409,18 @@ 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: @@ -4396,7 +4436,7 @@ class WellPathCompletionSettings(PdmObjectBase): reference_depth_for_export (Optional[float]): BHP Reference Depth well_bore_fluid_pvt_table (int): Wellbore Fluid PVT table well_name_for_export (str): Well Name - well_type_for_export (WellTypeForExport): One of [OIL, GAS, WATER, LIQUID] + well_type_for_export (WellType): One of [OIL, GAS, WATER, LIQUID] """ __custom_init__ = None #: Assign a custom init routine to be run at __init__ @@ -4413,7 +4453,7 @@ def __init__(self, pb2_object: Optional[PdmObject_pb2.PdmObject]=None, channel: self.reference_depth_for_export: Optional[float] = None self.well_bore_fluid_pvt_table: int = 0 self.well_name_for_export: str = "" - self.well_type_for_export: WellTypeForExport = WellTypeForExport.OIL + self.well_type_for_export: WellType = WellType.OIL PdmObjectBase.__init__(self, pb2_object, channel) if WellPathCompletionSettings.__custom_init__ is not None: WellPathCompletionSettings.__custom_init__(self, pb2_object=pb2_object, channel=channel) @@ -4857,6 +4897,7 @@ def class_dict() -> Dict[str, Type[PdmObjectBase]]: classes['WellEventTubing'] = WellEventTubing classes['WellEventType'] = WellEventType classes['WellEventValve'] = WellEventValve + classes['WellEventWellSpec'] = WellEventWellSpec classes['WellLog'] = WellLog classes['WellLogExtractionCurve'] = WellLogExtractionCurve classes['WellLogFileInterface'] = WellLogFileInterface diff --git a/docs/rips/orion_events.py b/docs/rips/orion_events.py index d7b3729ca..724450947 100644 --- a/docs/rips/orion_events.py +++ b/docs/rips/orion_events.py @@ -79,6 +79,10 @@ 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. + ``MEMBER MEMBERS="A,B"`` inside a GROUP block is shorthand for one GRUPTREE + record per unique member, with the enclosing group as parent. A schedule may + contain one attribute-free ``RESTART`` event; it truncates generated schedule + output before its timestamp and is not itself emitted as a keyword. * ``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,11 +95,18 @@ 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``, ``TUBING``, ``VALVE`` and ``STATE``, or any Eclipse well - keyword (``WCONHIST``, ``WELTARG``, ``WRFTPLT``, ``WCONPROD``, ...), which + ``PERFORATION``, ``SEGMENT``, ``VALVE``, ``STATE`` and ``WELLSPEC``, 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 GROUP block are Eclipse group keywords with the group name - injected as GROUP. Event types inside a SCHEDULE block are Eclipse schedule + ``WELLSPEC`` 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. + 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 + 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. @@ -119,6 +130,7 @@ from __future__ import annotations +import copy import datetime import difflib import os @@ -245,6 +257,16 @@ class AttrValue: quoted: bool +@dataclass +class WellSpecState: + """Fully resolved cumulative state for one WELLSPEC event.""" + + group: str + crossflow: bool + refdepth: Optional[float] + phase: str + + @dataclass class OrionEvent: """One event line in an enclosing WELL, GROUP or SCHEDULE block.""" @@ -254,6 +276,7 @@ class OrionEvent: attributes: Dict[str, AttrValue] loc: SourceLoc filter: Optional[EventFilter] = None + well_spec: Optional[WellSpecState] = None @dataclass @@ -411,6 +434,8 @@ def parse_orion_events(text: str) -> OrionDocument: if version is None: raise OrionParseError("Empty file: missing 'ORIONEVENTS' header") + errors.extend(_restart_validation_issues(wells, groups, schedule_events)) + errors.extend(_wellspec_validation_issues(wells)) if errors: raise OrionParseError(errors=errors) @@ -426,6 +451,62 @@ def parse_orion_events(text: str) -> OrionDocument: ) +def _restart_validation_issues( + wells: List[WellBlock], + groups: List[GroupBlock], + schedule_events: List[OrionEvent], +) -> List[ParseIssue]: + """Validate placement, cardinality and shape of RESTART events.""" + issues: List[ParseIssue] = [] + for well_block in wells: + 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) + ) + 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) + ) + + restart_events = [ + event for event in schedule_events if event.event_type.upper() == "RESTART" + ] + for event in restart_events: + if event.attributes: + issues.append(ParseIssue("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) + ) + return issues + + +def _wellspec_validation_issues(wells: List[WellBlock]) -> List[ParseIssue]: + """Reject multiple WELLSPEC 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": + 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})", + event.loc, + ) + ) + else: + seen[key] = event + return issues + + def _check_version(version: str, loc: SourceLoc) -> None: major = version.split(".")[0] if major == "2": @@ -897,11 +978,27 @@ class ApplyReport: # FILTER is applied on PERFORATION events; it is accepted on the other # completion events but ignored with a warning. _PERF_REQUIRED = ("MDSTART", "MDEND") -_PERF_KNOWN = {"MDSTART", "MDEND", "RADIUS", "SKIN", "COMPLETION_NUMBER", "FILTER"} -_TUBING_REQUIRED = ("MDSTART", "MDEND") -_TUBING_KNOWN = {"MDSTART", "MDEND", "INNER_DIAMETER", "ROUGHNESS"} +_PERF_KNOWN = { + "MDSTART", + "MDEND", + "RADIUS", + "SKIN", + "COMPLETION_NUMBER", + "FILTER", + "COMMENT", +} +_SEGMENT_REQUIRED = ("MDSTART", "MDEND") +_SEGMENT_KNOWN = { + "MDSTART", + "MDEND", + "INNER_DIAMETER", + "ROUGHNESS", + "PRESSURE_COMPONENTS", + "COMMENT", +} +_PRESSURE_COMPONENTS = {"H--", "HF-", "HFA"} _VALVE_REQUIRED = ("MD", "TYPE") -_VALVE_KNOWN = {"MD", "TYPE", "STATE", "CV", "AREA"} | { +_VALVE_KNOWN = {"MD", "TYPE", "STATE", "CV", "AREA", "COMMENT"} | { "AICD_STRENGTH", "AICD_DENSITY_CALIB_FLUID", "AICD_VISCOSITY_CALIB_FLUID", @@ -909,7 +1006,9 @@ class ApplyReport: "AICD_VISC_FUNC_EXP", } _STATE_REQUIRED = ("STATE",) -_STATE_KNOWN = {"STATE"} +_STATE_KNOWN = {"STATE", "COMMENT"} +_WELLSPEC_KNOWN = {"GROUP", "CROSSFLOW", "REFDEPTH", "PHASE", "COMMENT"} +_WELLSPEC_PHASES = {"OIL", "GAS", "WATER", "LIQUID"} _COMPLETION_IGNORED = {"FILTER"} _PERF_IGNORED = _COMPLETION_IGNORED # backwards-compatible alias @@ -927,6 +1026,15 @@ def _iso_event_date(event_date: Union[datetime.date, datetime.datetime]) -> str: 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") + if comment is None: + return + timeline_event.comment = str(comment.value) + timeline_event.update() + + def apply_orion_events_file( path: Union[str, "os.PathLike[str]"], timeline: Any, @@ -940,6 +1048,145 @@ def apply_orion_events_file( return apply_orion_document(document, timeline, project, case=case, **options) +def coalesce_orion_document(document: OrionDocument) -> OrionDocument: + """Return a copy with same-owner/type/timestamp 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. + """ + result = copy.deepcopy(document) + + def merge_events(events: List[OrionEvent]) -> List[OrionEvent]: + merged: List[OrionEvent] = [] + by_key: Dict[ + Tuple[Union[datetime.date, datetime.datetime], str], OrionEvent + ] = {} + for event in events: + key = (event.event_date, event.event_type.upper()) + existing = by_key.get(key) + if existing is None: + by_key[key] = event + merged.append(event) + continue + + existing.attributes.update(event.attributes) + if "FILTER" in event.attributes: + existing.filter = event.filter + existing.loc = event.loc + return merged + + def merge_well_blocks(blocks: List[WellBlock]) -> List[WellBlock]: + merged_blocks: List[WellBlock] = [] + by_name: Dict[str, WellBlock] = {} + for block in blocks: + block_events = block.events + existing = by_name.get(block.well_name) + if existing is None: + block.events = [] + by_name[block.well_name] = block + merged_blocks.append(block) + by_name[block.well_name].events.extend(block_events) + for block in merged_blocks: + block.events = merge_events(block.events) + return merged_blocks + + def merge_group_blocks(blocks: List[GroupBlock]) -> List[GroupBlock]: + merged_blocks: List[GroupBlock] = [] + by_name: Dict[str, GroupBlock] = {} + for block in blocks: + block_events = block.events + existing = by_name.get(block.group_name) + if existing is None: + block.events = [] + by_name[block.group_name] = block + merged_blocks.append(block) + by_name[block.group_name].events.extend(block_events) + for block in merged_blocks: + block.events = merge_events(block.events) + return merged_blocks + + result.wells = merge_well_blocks(result.wells) + result.groups = merge_group_blocks(result.groups) + result.schedule_events = merge_events(result.schedule_events) + return result + + +def _enum_text(value: Any) -> str: + """Return the serialized text of a generated enum or plain string.""" + return str(getattr(value, "value", value)).upper() + + +def _prepare_wellspec_events( + events: List[OrionEvent], completion_settings: Any, report: ApplyReport +) -> None: + """Validate WELLSPEC attributes and resolve partial updates chronologically.""" + state = WellSpecState( + group=str(completion_settings.group_name_for_export), + crossflow=bool(completion_settings.allow_well_cross_flow), + refdepth=completion_settings.reference_depth_for_export, + phase=_enum_text(completion_settings.well_type_for_export), + ) + + wellspecs = sorted( + (event for event in events if event.event_type.upper() == "WELLSPEC"), + key=lambda event: event.event_date, + ) + for event in wellspecs: + attrs = event.attributes + unknown = set(attrs) - _WELLSPEC_KNOWN + if unknown: + report.errors.append( + f"Line {event.loc.line}: unknown WELLSPEC attribute(s): " + f"{', '.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" + ) + report.events_skipped += 1 + continue + + next_state = copy.copy(state) + errors: List[str] = [] + if "GROUP" in attrs: + value = attrs["GROUP"].value + if not isinstance(value, str) or not value: + errors.append("GROUP must be a non-empty string") + else: + next_state.group = value + if "CROSSFLOW" in attrs: + value = attrs["CROSSFLOW"].value + if not isinstance(value, bool): + errors.append("CROSSFLOW must be True or False") + else: + next_state.crossflow = value + if "REFDEPTH" in attrs: + value = attrs["REFDEPTH"].value + if isinstance(value, bool) or not isinstance(value, (int, float)): + errors.append("REFDEPTH must be numeric") + else: + next_state.refdepth = float(value) + if "PHASE" in attrs: + value = attrs["PHASE"].value + phase = value.upper() if isinstance(value, str) else "" + if phase not in _WELLSPEC_PHASES: + errors.append("PHASE must be OIL, GAS, WATER, or LIQUID") + else: + next_state.phase = phase + + if errors: + report.errors.extend(f"Line {event.loc.line}: {error}" for error in errors) + report.events_skipped += 1 + continue + + state = next_state + event.well_spec = copy.copy(state) + + def apply_orion_document( document: OrionDocument, timeline: Any, @@ -976,6 +1223,7 @@ def apply_orion_document( """ _validate_policy(on_unknown_well, "on_unknown_well") _validate_policy(on_unknown_event, "on_unknown_event") + document = coalesce_orion_document(document) report = ApplyReport() report.report_dates = sorted({d.isoformat() for d in document.report_dates}) @@ -992,6 +1240,8 @@ def apply_orion_document( report.events_skipped += len(well.events) continue + _prepare_wellspec_events(well.events, well_path.completion_settings(), report) + for event in well.events: event_type = event.event_type.upper() dispatch = _EVENT_DISPATCH.get(event_type) @@ -1147,10 +1397,60 @@ def _materialize_filter(ctx: _FilterContext, event_filter: EventFilter) -> Any: def _suspected_typo(event_type: str) -> Optional[str]: """Return the built-in event type this one looks like a misspelling of.""" + if event_type == "TUBING": + return "SEGMENT" close = difflib.get_close_matches(event_type, _EVENT_DISPATCH, n=1, cutoff=0.8) return close[0] if close else None +def _apply_member_event( + event: OrionEvent, + timeline: Any, + report: ApplyReport, + group_name: Optional[str], +) -> 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.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))}" + ) + report.events_skipped += 1 + return + if "MEMBERS" not in event.attributes: + report.errors.append( + f"Line {event.loc.line}: MEMBER missing required attribute: MEMBERS" + ) + report.events_skipped += 1 + return + + raw_members = str(event.attributes["MEMBERS"].value) + 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" + ) + report.events_skipped += 1 + return + + unique_members = list(dict.fromkeys(members)) + for member in unique_members: + timeline_event = timeline.add_keyword_event( + event_date=_iso_event_date(event.event_date), + keyword_name="GRUPTREE", + keyword_data={"CHILD_GROUP": member, "PARENT_GROUP": group_name}, + ) + _apply_event_comment(event, timeline_event) + report.events_applied += 1 + + def _apply_schedule_event( event: OrionEvent, timeline: Any, @@ -1159,6 +1459,17 @@ 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 == "RESTART": + timeline.add_keyword_event( + event_date=_iso_event_date(event.event_date), + keyword_name="RESTART", + keyword_data={}, + ) + report.events_applied += 1 + return + if event_type == "MEMBER": + _apply_member_event(event, timeline, report, group_name) + return if event_type in _COMPLETION_EVENT_TYPES: report.errors.append( f"Line {event.loc.line}: {event_type} is a completion event and " @@ -1169,6 +1480,8 @@ def _apply_schedule_event( keyword_data: Dict[str, Any] = {} for key, attr in event.attributes.items(): + if key == "COMMENT": + continue if key in _IGNORED_KEYWORD_ATTRS: report.warnings.append( f"Line {event.loc.line}: attribute '{key}' on {event_type} " @@ -1179,11 +1492,12 @@ def _apply_schedule_event( if group_name is not None: keyword_data["GROUP"] = group_name - timeline.add_keyword_event( + timeline_event = timeline.add_keyword_event( event_date=_iso_event_date(event.event_date), keyword_name=event_type, keyword_data=keyword_data, ) + _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1265,10 +1579,11 @@ def _apply_perforation( perf_event = timeline.add_perf_event(**kwargs) if event.filter is not None and ctx is not None: perf_event.add_filter(filter=_materialize_filter(ctx, event.filter)) + _apply_event_comment(event, perf_event) report.events_applied += 1 -def _apply_tubing( +def _apply_segment( event: OrionEvent, well_path: Any, timeline: Any, @@ -1276,17 +1591,19 @@ def _apply_tubing( ctx: Optional[_FilterContext] = None, ) -> None: if not _check_completion_attrs( - event, "TUBING", _TUBING_KNOWN, _TUBING_REQUIRED, report + event, "SEGMENT", _SEGMENT_KNOWN, _SEGMENT_REQUIRED, report ): return attrs = event.attributes try: + start_md = float(_as_number(attrs["MDSTART"], event.loc)) + end_md = float(_as_number(attrs["MDEND"], event.loc)) 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)), + "start_md": start_md, + "end_md": end_md, } if "INNER_DIAMETER" in attrs: kwargs["inner_diameter"] = float( @@ -1294,12 +1611,27 @@ def _apply_tubing( ) if "ROUGHNESS" in attrs: kwargs["roughness"] = float(_as_number(attrs["ROUGHNESS"], event.loc)) + pressure_components = None + if "PRESSURE_COMPONENTS" in attrs: + pressure_components = str(attrs["PRESSURE_COMPONENTS"].value).upper() + if pressure_components not in _PRESSURE_COMPONENTS: + raise OrionParseError( + "PRESSURE_COMPONENTS must be H--, HF-, or HFA", event.loc + ) except OrionParseError as exc: report.errors.append(str(exc)) report.events_skipped += 1 return - timeline.add_tubing_event(**kwargs) + timeline_event = timeline.add_tubing_event(**kwargs) + well_path.completion_settings().add_custom_segment_interval( + start_md=start_md, end_md=end_md + ) + if pressure_components is not None: + msw_settings = well_path.msw_settings() + msw_settings.pressure_drop = pressure_components + msw_settings.update() + _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1345,7 +1677,8 @@ def _apply_valve( report.events_skipped += 1 return - timeline.add_valve_event(**kwargs) + timeline_event = timeline.add_valve_event(**kwargs) + _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1361,11 +1694,35 @@ def _apply_state( ): return - timeline.add_state_event( + timeline_event = timeline.add_state_event( event_date=_iso_event_date(event.event_date), well_path=well_path, well_state=str(event.attributes["STATE"].value), ) + _apply_event_comment(event, timeline_event) + report.events_applied += 1 + + +def _apply_wellspec( + event: OrionEvent, + well_path: Any, + timeline: Any, + report: ApplyReport, + ctx: Optional[_FilterContext] = None, +) -> None: + if event.well_spec is None: + return + + state = event.well_spec + timeline_event = timeline.add_wellspec_event( + event_date=_iso_event_date(event.event_date), + well_path=well_path, + group_name=state.group, + allow_cross_flow=state.crossflow, + reference_depth=state.refdepth, + well_type=state.phase, + ) + _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1379,6 +1736,8 @@ def _apply_keyword( ) -> None: keyword_data: Dict[str, Any] = {"WELL": well_path.name} for key, attr in event.attributes.items(): + if key == "COMMENT": + continue if key in _IGNORED_KEYWORD_ATTRS: report.warnings.append( f"Line {event.loc.line}: attribute '{key}' on {keyword_name} " @@ -1387,12 +1746,13 @@ def _apply_keyword( continue keyword_data[field_map.get(key, key)] = attr.value - timeline.add_well_keyword_event( + 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, ) + _apply_event_comment(event, timeline_event) report.events_applied += 1 @@ -1441,16 +1801,23 @@ def _apply_generic_well_keyword( # built-in, which is governed by the on_unknown_event policy). _EVENT_DISPATCH: Dict[str, _EventDispatch] = { "PERFORATION": _apply_perforation, - "TUBING": _apply_tubing, + "SEGMENT": _apply_segment, "VALVE": _apply_valve, "STATE": _apply_state, + "WELLSPEC": _apply_wellspec, "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") +_COMPLETION_EVENT_TYPES = ( + "PERFORATION", + "SEGMENT", + "VALVE", + "STATE", + "WELLSPEC", +) # --------------------------------------------------------------------------- diff --git a/docs/rips/tests/test_orion_events.py b/docs/rips/tests/test_orion_events.py index f2a4bf6fd..371f67135 100644 --- a/docs/rips/tests/test_orion_events.py +++ b/docs/rips/tests/test_orion_events.py @@ -25,6 +25,7 @@ OrionParseError, _cli, apply_orion_document, + coalesce_orion_document, parse_orion_events, ) @@ -54,9 +55,38 @@ # --------------------------------------------------------------------------- +class FakeCompletionSettings: + def __init__(self): + self.group_name_for_export = "FIELD" + self.allow_well_cross_flow = True + self.reference_depth_for_export = None + self.well_type_for_export = "OIL" + self.custom_segment_calls = [] + + def add_custom_segment_interval(self, **kwargs): + self.custom_segment_calls.append(kwargs) + + +class FakeMswSettings: + def __init__(self): + self.pressure_drop = "HF-" + self.update_calls = 0 + + def update(self): + self.update_calls += 1 + + class FakeWellPath: def __init__(self, name): self.name = name + self._completion_settings = FakeCompletionSettings() + self._msw_settings = FakeMswSettings() + + def completion_settings(self): + return self._completion_settings + + def msw_settings(self): + return self._msw_settings class FakeProject: @@ -126,10 +156,20 @@ def data_filter_collection(self): return self._data_filter_collection -class FakePerfEvent: +class FakeTimelineEvent: + def __init__(self): + self.comment = "" + self.update_calls = 0 + + def update(self): + self.update_calls += 1 + + +class FakePerfEvent(FakeTimelineEvent): """The object returned by add_perf_event; records attached filters.""" def __init__(self): + super().__init__() self.filters = [] def add_filter(self, filter): @@ -146,7 +186,9 @@ def __init__(self): self.tubing_calls = [] self.valve_calls = [] self.state_calls = [] + self.wellspec_calls = [] self.schedule_keyword_calls = [] + self.created_events = [] def add_perf_event(self, **kwargs): self.perf_calls.append(kwargs) @@ -154,20 +196,34 @@ def add_perf_event(self, **kwargs): self.perf_events.append(perf_event) return perf_event + def _new_event(self): + event = FakeTimelineEvent() + self.created_events.append(event) + return event + def add_well_keyword_event(self, **kwargs): self.keyword_calls.append(kwargs) + return self._new_event() def add_tubing_event(self, **kwargs): self.tubing_calls.append(kwargs) + return self._new_event() def add_valve_event(self, **kwargs): self.valve_calls.append(kwargs) + return self._new_event() def add_state_event(self, **kwargs): self.state_calls.append(kwargs) + return self._new_event() + + def add_wellspec_event(self, **kwargs): + self.wellspec_calls.append(kwargs) + return self._new_event() def add_keyword_event(self, **kwargs): self.schedule_keyword_calls.append(kwargs) + return self._new_event() # --------------------------------------------------------------------------- @@ -227,6 +283,15 @@ def test_quoted_filter_value_is_single_attribute(self): assert filter_attr.quoted is True assert doc.wells[0].events[0].attributes["MDEND"].value == 2 + def test_comment_attribute_is_preserved(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "W"\n' + ' @2018-01-01 WCONHIST STATUS=OPEN COMMENT="Startup target"\n' + ) + event = parse_orion_events(text).wells[0].events[0] + assert event.attributes["COMMENT"].value == "Startup target" + assert event.attributes["COMMENT"].quoted is True + def test_trailing_comment_ignored_but_not_inside_quotes(self): text = ( 'ORIONEVENTS 2.0\nWELL "W"\n' @@ -421,6 +486,25 @@ 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_duplicate_wellspec_for_well_and_date_is_rejected(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "W"\n' + " @2024-01-01 WELLSPEC GROUP=A\n" + 'WELL "W"\n' + " @2024-01-01 WELLSPEC PHASE=GAS\n" + ) + with pytest.raises(OrionParseError, match="WELLSPEC 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" + 'WELL "B"\n' + " @2024-01-01 WELLSPEC GROUP=GB\n" + ) + assert [len(well.events) for well in document.wells] == [1, 1] + def test_boolean_attributes_are_typed_unless_quoted(self): text = ( "ORIONEVENTS 2.0\n" @@ -434,6 +518,41 @@ def test_boolean_attributes_are_typed_unless_quoted(self): assert attributes["LABEL"].value == "True" assert isinstance(attributes["LABEL"].value, str) + def test_single_restart_event_parses(self): + document = parse_orion_events( + "ORIONEVENTS 2.0\nSCHEDULE\n @2024-02-01 RESTART\n" + ) + restart = document.schedule_events[0] + assert restart.event_type == "RESTART" + assert restart.attributes == {} + + @pytest.mark.parametrize( + "text,expected_error", + [ + ( + 'ORIONEVENTS 2.0\nWELL "W"\n @2024-01-01 RESTART\n', + "only valid in a SCHEDULE block", + ), + ( + 'ORIONEVENTS 2.0\nGROUP "G"\n @2024-01-01 RESTART\n', + "only valid in a SCHEDULE block", + ), + ( + "ORIONEVENTS 2.0\nSCHEDULE\n @2024-01-01 RESTART VALUE=1\n", + "takes no attributes", + ), + ( + "ORIONEVENTS 2.0\nSCHEDULE\n" + " @2024-01-01 RESTART\n" + " @2024-02-01 RESTART\n", + "Only one RESTART event", + ), + ], + ) + def test_invalid_restart_event_rejected(self, text, expected_error): + with pytest.raises(OrionParseError, match=expected_error): + parse_orion_events(text) + 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") @@ -755,6 +874,64 @@ def _apply(self, text, names=("55_33-A-1", "55_33-A-2"), **opts): report = apply_orion_document(doc, timeline, project, **opts) return timeline, report + def test_same_owner_type_and_date_events_are_merged(self): + text = ( + "ORIONEVENTS 2.0\n" + 'WELL "55_33-A-1"\n' + " @2018-01-01 WCONHIST STATUS=OPEN ORAT=100\n" + 'WELL "55_33-A-1"\n' + " @2018-01-01 wconhist STATUS=SHUT CMODE=ORAT\n" + " @2018-01-02 WCONHIST STATUS=OPEN\n" + 'WELL "55_33-A-2"\n' + " @2018-01-01 WCONHIST STATUS=OPEN\n" + ) + document = parse_orion_events(text) + merged = coalesce_orion_document(document) + + # Normalization does not mutate the parsed source representation. + assert len(document.wells) == 3 + assert len(merged.wells) == 2 + assert len(merged.wells[0].events) == 2 + + merged_event = merged.wells[0].events[0] + assert merged_event.event_type == "WCONHIST" + assert merged_event.attributes["STATUS"].value == "SHUT" + assert merged_event.attributes["ORAT"].value == 100 + assert merged_event.attributes["CMODE"].value == "ORAT" + + timeline, report = self._apply(text) + assert report.events_applied == 3 + assert len(timeline.keyword_calls) == 3 + assert timeline.keyword_calls[0]["keyword_data"]["STATUS"] == "SHUT" + assert timeline.keyword_calls[0]["keyword_data"]["ORAT"] == 100 + assert timeline.keyword_calls[0]["keyword_data"]["CMODE"] == "ORAT" + + def test_group_and_schedule_events_merge_only_within_owner(self): + text = ( + "ORIONEVENTS 2.0\n" + 'GROUP "OP"\n' + " @2018-01-01 GCONPROD CONTROL_MODE=ORAT\n" + 'GROUP "OP"\n' + " @2018-01-01 GCONPROD OIL_TARGET=100\n" + 'GROUP "OTHER"\n' + " @2018-01-01 GCONPROD OIL_TARGET=200\n" + "SCHEDULE\n" + " @2018-01-01 RPTRST BASIC=1\n" + "SCHEDULE\n" + " @2018-01-01 RPTRST FREQ=2\n" + ) + merged = coalesce_orion_document(parse_orion_events(text)) + + assert len(merged.groups) == 2 + assert len(merged.groups[0].events) == 1 + assert set(merged.groups[0].events[0].attributes) == { + "CONTROL_MODE", + "OIL_TARGET", + } + assert len(merged.groups[1].events) == 1 + assert len(merged.schedule_events) == 1 + assert set(merged.schedule_events[0].attributes) == {"BASIC", "FREQ"} + def test_perforation_mapping_radius_to_diameter(self): timeline, report = self._apply(SAMPLE) assert report.events_applied == 4 # 2 perfs + WCONHIST + WELTARG @@ -779,6 +956,29 @@ def test_wconhist_field_translation_and_well_injection(self): assert data["VFP_TABLE"] == 1 # VFP -> VFP_TABLE assert "VFP" not in data + def test_comment_is_applied_to_timeline_event_not_keyword_data(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + ' @2018-01-01 WCONHIST STATUS=OPEN COMMENT="Startup target"\n' + ) + timeline, report = self._apply(text) + + assert report.errors == [] + assert "COMMENT" not in timeline.keyword_calls[0]["keyword_data"] + assert timeline.created_events[0].comment == "Startup target" + assert timeline.created_events[0].update_calls == 1 + + def test_perforation_comment_is_applied(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + " @2018-01-01 PERFORATION MDSTART=1 MDEND=2 COMMENT=Interval\n" + ) + timeline, report = self._apply(text) + + assert report.errors == [] + assert timeline.perf_events[0].comment == "Interval" + assert timeline.perf_events[0].update_calls == 1 + def test_weltarg_value_translation(self): timeline, _ = self._apply(SAMPLE) weltarg = next( @@ -872,10 +1072,56 @@ def test_perforation_unknown_attr_is_error(self): assert report.events_skipped == 1 assert any("ZZZ" in e for e in report.errors) - def test_tubing_mapping(self): + def test_wellspec_partial_updates_are_cumulative_by_date(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" + " @2019-01-01 WELLSPEC CROSSFLOW=False PHASE=gas\n" + " @2018-01-01 WELLSPEC GROUP=my_group REFDEPTH=1002 PHASE=water\n" + ) + timeline, report = self._apply(text) + + assert report.errors == [] + assert report.events_applied == 2 + # Calls retain source order, but snapshots are resolved chronologically. + assert timeline.wellspec_calls[0] == { + "event_date": "2019-01-01", + "well_path": timeline.wellspec_calls[0]["well_path"], + "group_name": "my_group", + "allow_cross_flow": False, + "reference_depth": 1002.0, + "well_type": "GAS", + } + assert timeline.wellspec_calls[1]["group_name"] == "my_group" + assert timeline.wellspec_calls[1]["allow_cross_flow"] is True + assert timeline.wellspec_calls[1]["well_type"] == "WATER" + + @pytest.mark.parametrize( + "attributes,expected_error", + [ + ("CROSSFLOW=YES", "CROSSFLOW must be True or False"), + ("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"), + ("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' + ) + timeline, report = self._apply(text) + + assert report.events_applied == 0 + assert report.events_skipped == 1 + assert expected_error in report.errors[0] + assert timeline.wellspec_calls == [] + + def test_segment_mapping_creates_custom_interval_and_sets_pressure_drop(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + " @2024-01-01 SEGMENT MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 " + "ROUGHNESS=1.0e-5 PRESSURE_COMPONENTS=HFA\n" ) timeline, report = self._apply(text) assert report.events_applied == 1 @@ -885,6 +1131,37 @@ def test_tubing_mapping(self): assert call["inner_diameter"] == 0.15 assert call["roughness"] == pytest.approx(1.0e-5) + well = call["well_path"] + assert well.completion_settings().custom_segment_calls == [ + {"start_md": 0.0, "end_md": 2500.0} + ] + assert well.msw_settings().pressure_drop == "HFA" + assert well.msw_settings().update_calls == 1 + + def test_segment_rejects_invalid_pressure_components(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + " @2024-01-01 SEGMENT MDSTART=0 MDEND=2500 " + "PRESSURE_COMPONENTS=INVALID\n" + ) + timeline, report = self._apply(text) + + assert report.events_applied == 0 + assert report.events_skipped == 1 + assert "PRESSURE_COMPONENTS must be H--, HF-, or HFA" in report.errors[0] + assert timeline.tubing_calls == [] + + def test_tubing_is_reported_as_renamed_event(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + " @2024-01-01 TUBING MDSTART=0 MDEND=2500\n" + ) + timeline, report = self._apply(text) + + assert report.events_skipped == 1 + assert timeline.keyword_calls == [] + assert any("did you mean 'SEGMENT'" in warning for warning in report.warnings) + def test_valve_mapping(self): text = ( 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' @@ -947,6 +1224,20 @@ def test_schedule_events_applied_without_well(self): assert rptrst["keyword_data"] == {"BASIC": 2, "FREQ": 1} assert "WELL" not in rptrst["keyword_data"] + def test_restart_event_creates_non_emitting_timeline_marker(self): + text = "ORIONEVENTS 2.0\nSCHEDULE\n @2024-02-01 RESTART\n" + timeline, report = self._apply(text) + + assert report.events_applied == 1 + assert report.errors == [] + assert timeline.schedule_keyword_calls == [ + { + "event_date": "2024-02-01", + "keyword_name": "RESTART", + "keyword_data": {}, + } + ] + def test_group_events_inject_group_name(self): text = ( 'ORIONEVENTS 2.0\nGROUP "OP"\n' @@ -970,6 +1261,50 @@ def test_group_events_inject_group_name(self): assert timeline.schedule_keyword_calls[1]["keyword_data"]["GROUP"] == "OP" assert timeline.schedule_keyword_calls[2]["keyword_data"]["GROUP"] == "WI" + def test_member_event_expands_to_unique_grouptree_events(self): + text = ( + 'ORIONEVENTS 2.0\nGROUP "PRODUCERS"\n' + ' @2024-01-01 MEMBER MEMBERS="WELL_A, WELL_B,WELL_A" ' + 'COMMENT="Group membership"\n' + ) + timeline, report = self._apply(text) + + assert report.events_applied == 2 + assert report.errors == [] + assert [call["keyword_data"] for call in timeline.schedule_keyword_calls] == [ + {"CHILD_GROUP": "WELL_A", "PARENT_GROUP": "PRODUCERS"}, + {"CHILD_GROUP": "WELL_B", "PARENT_GROUP": "PRODUCERS"}, + ] + assert all( + event.comment == "Group membership" for event in timeline.created_events + ) + + @pytest.mark.parametrize( + "block,event,expected_error", + [ + ("SCHEDULE", 'MEMBER MEMBERS="A"', "needs a GROUP block"), + ('GROUP "G"', "MEMBER", "missing required attribute"), + ( + 'GROUP "G"', + 'MEMBER MEMBERS="A,,B"', + "non-empty names", + ), + ( + 'GROUP "G"', + 'MEMBER MEMBERS="A" EXTRA=1', + "unknown MEMBER attribute", + ), + ], + ) + def test_invalid_member_event_is_skipped(self, block, event, expected_error): + text = f"ORIONEVENTS 2.0\n{block}\n @2024-01-01 {event}\n" + timeline, report = self._apply(text) + + assert report.events_applied == 0 + assert report.events_skipped == 1 + assert timeline.schedule_keyword_calls == [] + assert expected_error in report.errors[0] + 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" @@ -1012,8 +1347,8 @@ def _apply(self, text, case=None, cases=None, **opts): def _perf_text(self, decls, *filter_values): events = "".join( - f" @2018-01-01 PERFORATION MDSTART=1 MDEND=2 FILTER={value}\n" - for value in filter_values + f" @2018-01-{index:02d} PERFORATION MDSTART=1 MDEND=2 FILTER={value}\n" + for index, value in enumerate(filter_values, start=1) ) return "ORIONEVENTS 2.0\n" + decls + 'WELL "55_33-A-1"\n' + events @@ -1209,6 +1544,24 @@ def test_rptrst_boolean_values_emit_bare_mnemonics( assert f"{flag}=True" not in rptrst_block assert "NORST" not in tokens + def test_event_comment_precedes_generated_keyword( + 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 COMMENT="Startup target"\n' + ) + + report = apply_orion_document(document, timeline, project) + assert report.errors == [] + + schedule = timeline.generate_schedule_text(eclipse_case=case) + assert "-- Startup target\nWCONHIST\n" in schedule + assert "COMMENT" not in schedule + 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] @@ -1238,6 +1591,109 @@ def test_group_sections_generate_group_keywords(self, project_with_case_and_well assert "'OP'" in schedule assert "'WI'" in schedule + def test_member_event_generates_grouptree(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" + 'GROUP "PRODUCERS"\n' + ' @2024-01-01 MEMBER MEMBERS="WELL_A,WELL_B"\n' + ) + + report = apply_orion_document(document, timeline, project) + assert report.errors == [] + assert report.events_applied == 3 + + schedule = timeline.generate_schedule_text(eclipse_case=case) + grouptree_block = schedule.split("GRUPTREE", 1)[1].split("\n/\n", 1)[0] + normalized_block = " ".join(grouptree_block.split()) + assert "'WELL_A' 'PRODUCERS'" in normalized_block + assert "'WELL_B' 'PRODUCERS'" in normalized_block + + def test_wellspec_updates_settings_and_generates_cumulative_welspecs( + self, project_with_case_and_wells + ): + project, case, timeline = project_with_case_and_wells + well = next(wp for wp in project.well_paths() if "A" in wp.name) + 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" + ) + + report = apply_orion_document(document, timeline, project) + assert report.errors == [] + assert report.events_applied == 2 + + schedule = timeline.generate_schedule_text( + eclipse_case=case, first_date_as_comment=False, align_columns=True + ) + assert schedule.count("WELSPECS\n") == 2 + blocks = schedule.split("WELSPECS\n")[1:] + first_record = " ".join(blocks[0].split("\n/\n", 1)[0].split()) + second_record = " ".join(blocks[1].split("\n/\n", 1)[0].split()) + + assert "'my_group'" in first_record + assert "1002" in first_record + assert "'WATER'" in first_record + assert "'YES'" in first_record + assert "1*" not in first_record.split("'my_group'", 1)[1].split("1002", 1)[0] + + assert "'my_group'" in second_record + assert "1000" in second_record + assert "'OIL'" in second_record + assert "'NO'" in second_record + + timeline.set_timestamp(timestamp="2018-06-01") + settings = well.completion_settings() + assert settings.group_name_for_export == "my_group" + assert settings.allow_well_cross_flow is True + assert settings.reference_depth_for_export == 1002 + assert settings.well_type_for_export == "WATER" + + timeline.set_timestamp(timestamp="2019-06-01") + settings = well.completion_settings() + assert settings.group_name_for_export == "my_group" + assert settings.allow_well_cross_flow is False + assert settings.reference_depth_for_export == 1000 + assert settings.well_type_for_export == "OIL" + + def test_restart_truncates_generated_schedule(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 CMODE=ORAT ORAT=100\n" + " @2024-02-01 WCONHIST STATUS=OPEN CMODE=ORAT ORAT=200\n" + " @2024-03-01 WCONHIST STATUS=OPEN CMODE=ORAT ORAT=300\n" + "REPORT 2024-01-15\n" + "REPORT 2024-04-01\n" + "SCHEDULE\n" + " @2024-02-01 RESTART\n" + ) + + report = apply_orion_document(document, timeline, project) + assert report.errors == [] + + schedule = timeline.generate_schedule_text( + eclipse_case=case, + first_date_as_comment=False, + additional_dates=report.report_dates, + ) + assert "1 'JAN' 2024" not in schedule + assert "15 'JAN' 2024" not in schedule + assert "1 'FEB' 2024" in schedule + assert "1 'MAR' 2024" in schedule + assert "1 'APR' 2024" in schedule + assert " 100" not in schedule + assert " 200" in schedule + assert " 300" in schedule + assert "RESTART\n" not 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 @@ -1295,7 +1751,7 @@ def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_well "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 SEGMENT MDSTART=0 MDEND=2500 INNER_DIAMETER=0.15 ROUGHNESS=1.0e-5 PRESSURE_COMPONENTS=HFA\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" @@ -1314,6 +1770,13 @@ def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_well assert report.warnings == [] assert report.events_applied == 11 + custom_segments = well.descendants(rips.CustomSegmentInterval) + assert any( + segment.start_md == 0.0 and segment.end_md == 2500.0 + for segment in custom_segments + ) + assert well.msw_settings().pressure_drop == "HFA" + timeline.set_timestamp(timestamp="2024-12-24") schedule = timeline.generate_schedule_text( eclipse_case=case, export_msw_for_wells=[well] diff --git a/docs/rips/tests/test_well_events.py b/docs/rips/tests/test_well_events.py index a01027153..60dff1aa8 100644 --- a/docs/rips/tests/test_well_events.py +++ b/docs/rips/tests/test_well_events.py @@ -5,6 +5,7 @@ """ import os +import re import sys import pytest @@ -1441,6 +1442,49 @@ def test_schedule_contains_compdat_keyword(self, project_with_case_and_well): assert "COMPDAT" in schedule_text + def test_schedule_header_contains_generation_metadata( + self, project_with_case_and_well + ): + """Generated schedules identify when and by whom they were created.""" + project, case, timeline = project_with_case_and_well + well_path = project.well_paths()[0] + + 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_text = timeline.generate_schedule_text(eclipse_case=case) + lines = schedule_text.splitlines() + + assert lines[0] == "-- Generated by ResInsight" + assert re.fullmatch( + r"-- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", + lines[1], + ) + assert lines[2].startswith("-- User: ") + assert lines[2] != "-- User: " + + 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] + + event = timeline.add_well_keyword_event( + event_date="2024-01-01", + well_path=well_path, + keyword_name="WCONHIST", + keyword_data={"WELL": well_path.name, "STATUS": "OPEN"}, + ) + event.comment = "Startup target\nWELSPECS" + event.update() + + schedule = timeline.generate_schedule_text(eclipse_case=case) + assert "-- Startup target\n-- WELSPECS\nWCONHIST\n" in schedule + def test_align_columns_adds_headers_and_alignment(self, project_with_case_and_well): """align_columns=True must add a '--'-prefixed column-header comment per keyword and indent right-aligned data rows, while the default (align_columns=False) keeps the @@ -1525,10 +1569,26 @@ def test_align_columns_adds_headers_and_alignment(self, project_with_case_and_we f"WCONHIST data row should keep per-column '1*' markers: {data_line!r}" ) - # Same keywords are produced either way. - for keyword in ("DATES", "COMPDAT", "WCONHIST"): + # Every core tabular keyword has a column-title comment in aligned output, while compact + # output contains the same keywords without those comments. + expected_headers = { + "DATES": ("DAY", "MONTH", "YEAR"), + "WELSPECS": ("WELL", "GROUP", "HEAD_I", "HEAD_J"), + "COMPORD": ("WELL", "ORD_TYP"), + "COMPDAT": ("WELL", "I", "J", "K1", "K2", "STATE"), + "WCONHIST": ("WELL", "STATUS", "CMODE"), + } + for keyword, column_names in expected_headers.items(): assert keyword in aligned and keyword in default + aligned_lines = aligned.split(f"{keyword}\n", 1)[1].splitlines() + assert aligned_lines[0].startswith("--") + assert all(name in aligned_lines[0] for name in column_names) + assert aligned_lines[1].startswith(" ") + + compact_first_line = default.split(f"{keyword}\n", 1)[1].splitlines()[0] + assert not compact_first_line.startswith("--") + def test_perf_completion_number_triggers_complump(self, project_with_case_and_well): """#13273 follow-up: a completion_number on add_perf_event must surface as a COMPLUMP keyword (with that number) in the generated schedule. @@ -1846,6 +1906,17 @@ def test_keywords_grouped_across_wells(self, project_with_case_and_well): f"Well {wp.name!r} missing from grouped WELSPECS block: {welspecs_block!r}" ) + assert schedule_text.count("COMPORD\n") == 1 + compord_block = schedule_text.split("COMPORD\n", 1)[1].split("\n/\n", 1)[0] + assert compord_block.count("INPUT") == 2 + for wp in well_paths[:2]: + assert wp.name.replace(" ", "") in compord_block.replace(" ", ""), ( + f"Well {wp.name!r} missing from grouped COMPORD block: {compord_block!r}" + ) + + assert schedule_text.index("WELSPECS\n") < schedule_text.index("COMPORD\n") + assert schedule_text.index("COMPORD\n") < schedule_text.index("COMPDAT\n") + def test_per_well_keywords_sorted_by_well_name(self, project_with_case_and_well): """Per-well keyword records are emitted in deck-name-sorted well order, so WELSPECS and COMPDAT share the same ascending well order rather than an arbitrary one.