From 8dc1d56bd303b32d80a3c4be552966c2245c8cc2 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Mon, 19 Oct 2026 09:27:57 +0200 Subject: [PATCH] #14688 Sim Events: Add API to query event dates and wells Add event date and well queries with date, owner and event type/keyword filters to SimulatorEventsDocument and WellEventTimeline, including latest_event_date() and wells_with_msw_events() for building the export_msw_for_wells list. --- .../WellEvents/RimWellEventTimeline.cpp | 44 ++++ .../WellEvents/RimWellEventTimeline.h | 16 ++ .../RimcWellEventTimeline.cpp | 177 +++++++++++++++ .../RimcWellEventTimeline.h | 39 ++++ .../experimental/import_simulator_events.py | 8 +- .../well_event_schedule_simulator_events.py | 2 +- GrpcInterface/Python/rips/simulator_events.py | 132 +++++++++++ .../rips/tests/test_simulator_events.py | 84 +++++++ .../Python/rips/tests/test_well_events.py | 207 ++++++++++++++++++ GrpcInterface/Python/rips/well_events.py | 145 ++++++++++++ docs/simulatorEvents.md | 52 ++++- 11 files changed, 903 insertions(+), 3 deletions(-) diff --git a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp index e9ebe3a78d..64d018a201 100644 --- a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp +++ b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp @@ -219,6 +219,50 @@ QDateTime RimWellEventTimeline::lastAppliedTimestamp() const return m_lastAppliedTimestamp; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimWellEventTimeline::queryEvents( const EventQuery& query ) const +{ + auto keywordName = []( const RimWellEvent* event ) -> QString + { + if ( auto keywordEvent = dynamic_cast( event ) ) return keywordEvent->keywordName().toUpper(); + if ( auto scheduleKeywordEvent = dynamic_cast( event ) ) + return scheduleKeywordEvent->keywordName().toUpper(); + return {}; + }; + + auto matchesType = [&]( const RimWellEvent* event ) + { + if ( query.eventTypes.empty() && query.keywords.empty() ) return true; + if ( query.eventTypes.contains( event->eventType() ) ) return true; + if ( query.keywords.empty() ) return false; + + const QString keyword = keywordName( event ); + return !keyword.isEmpty() && query.keywords.contains( keyword ); + }; + + std::vector result; + for ( auto& event : m_events ) + { + if ( !event || !event->eventDate().isValid() ) continue; + + const QDateTime date = event->eventDate(); + if ( query.startDate.isValid() && date < query.startDate ) continue; + if ( query.endDate.isValid() && date > query.endDate ) continue; + if ( !query.wellPaths.empty() && !query.wellPaths.contains( event->wellPath() ) ) continue; + if ( !matchesType( event ) ) continue; + + result.push_back( event ); + } + + std::stable_sort( result.begin(), + result.end(), + []( const RimWellEvent* a, const RimWellEvent* b ) { return a->eventDate() < b->eventDate(); } ); + + return result; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h index 9613f164f5..a81db0746c 100644 --- a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h +++ b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h @@ -26,6 +26,9 @@ #include "cafPdmPtrArrayField.h" #include +#include + +#include #include class RimWellEventPerf; @@ -59,6 +62,16 @@ public: TYPE }; + // Filter for queryEvents(). Invalid dates leave the range open, empty sets disable the filter. + struct EventQuery + { + QDateTime startDate; + QDateTime endDate; + std::set wellPaths; + std::set eventTypes; + std::set keywords; // Upper case keyword names + }; + RimWellEventTimeline(); ~RimWellEventTimeline() override; @@ -73,6 +86,9 @@ public: std::vector getWellPathsWithEvents() const; std::vector getWellPathsWithEventsUpToDate( const QDateTime& date ) const; + // Events matching all filters in the query, sorted by date. Ignores the applied timestamp. + std::vector queryEvents( const EventQuery& query ) const; + // Add event methods (return the created event for further configuration) RimWellEventPerf* addPerforationEvent( RimWellPath* wellPath, const QDateTime& date ); RimWellEventValve* addValveEvent( RimWellPath* wellPath, const QDateTime& date ); diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp index a8ebfb56ad..fa585ef4e8 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp @@ -48,6 +48,52 @@ namespace { +//-------------------------------------------------------------------------------------------------- +/// Builds a timeline query from the scripting arguments. Empty dates leave the range open. Each event +/// type is either an event type name (SEGMENT, PERF, ...) or a keyword name (WCONHIST, ...). +//-------------------------------------------------------------------------------------------------- +std::expected createEventQuery( const QString& startDate, + const QString& endDate, + const std::vector& wellPaths, + const std::vector& eventTypes ) +{ + RimWellEventTimeline::EventQuery query; + + auto parseDate = []( const QString& text, QDateTime& date ) -> std::expected + { + if ( text.trimmed().isEmpty() ) return {}; + + date = QDateTime::fromString( text.trimmed(), Qt::ISODate ); + if ( !date.isValid() ) return std::unexpected( QString( "Invalid date format: %1. Expected YYYY-MM-DD" ).arg( text ) ); + return {}; + }; + + if ( auto result = parseDate( startDate, query.startDate ); !result ) return std::unexpected( result.error() ); + if ( auto result = parseDate( endDate, query.endDate ); !result ) return std::unexpected( result.error() ); + + for ( auto wellPath : wellPaths ) + { + if ( wellPath ) query.wellPaths.insert( wellPath ); + } + + for ( const auto& eventType : eventTypes ) + { + const QString name = eventType.trimmed().toUpper(); + if ( name.isEmpty() ) continue; + + if ( caf::AppEnum::isValid( name ) ) + { + query.eventTypes.insert( caf::AppEnum::fromText( name ) ); + } + else + { + query.keywords.insert( name ); + } + } + + return query; +} + std::expected validateKeywordItems( const QString& keywordName, const std::vector& itemNames ) { auto keywordInfo = RiaOpmKeywordTools::keywordInfo( keywordName ); @@ -898,3 +944,134 @@ QString RimcWellEventTimeline_generateSchedule::classKeywordReturnedType() const { return RimcDataContainerString::classKeywordStatic(); } + +CAF_PDM_OBJECT_METHOD_SOURCE_INIT( RimWellEventTimeline, RimcWellEventTimeline_eventDates, "QueryEventDates" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimcWellEventTimeline_eventDates::RimcWellEventTimeline_eventDates( caf::PdmObjectHandle* self ) + : caf::PdmObjectMethod( self, PdmObjectMethod::NullPointerType::NULL_IS_INVALID, PdmObjectMethod::ResultType::PERSISTENT_FALSE ) +{ + CAF_PDM_InitObject( "Event Dates", "", "", "Sorted, unique dates of the events matching the filters, as ISO date-time strings" ); + + CAF_PDM_InitScriptableField( &m_startDate, "StartDate", QString(), "", "", "", "Earliest date to include (inclusive). Empty: no limit" ); + CAF_PDM_InitScriptableField( &m_endDate, + "EndDate", + QString(), + "", + "", + "", + "Latest date to include (inclusive). A date-only value means midnight, excluding events later that day. " + "Empty: no limit" ); + CAF_PDM_InitScriptableFieldNoDefault( &m_wellPaths, + "WellPaths", + "", + "", + "", + "Only include events for these wells. Empty: events for all wells and schedule events" ); + CAF_PDM_InitScriptableFieldNoDefault( &m_eventTypes, + "EventTypes", + "", + "", + "", + "Event types (SEGMENT, PERF, VALVE, ...) or keyword names (WCONHIST, ...). Empty: all events" ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected RimcWellEventTimeline_eventDates::execute() +{ + auto timeline = self(); + + auto query = createEventQuery( m_startDate(), m_endDate(), m_wellPaths.ptrReferencedObjectsByType(), m_eventTypes() ); + if ( !query ) return std::unexpected( query.error() ); + + std::set dates; + for ( auto event : timeline->queryEvents( *query ) ) + { + dates.insert( event->eventDate() ); + } + + // Explicit format without a time zone suffix, as the event dates are stored in local time + std::vector dateTexts; + for ( const auto& date : dates ) + { + dateTexts.push_back( date.toString( "yyyy-MM-ddTHH:mm:ss" ) ); + } + + auto* dataObject = new RimcDataContainerString(); + dataObject->m_stringValues = dateTexts; + + return dataObject; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimcWellEventTimeline_eventDates::classKeywordReturnedType() const +{ + return RimcDataContainerString::classKeywordStatic(); +} + +CAF_PDM_OBJECT_METHOD_SOURCE_INIT( RimWellEventTimeline, RimcWellEventTimeline_wellsWithEvents, "QueryWellsWithEvents" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimcWellEventTimeline_wellsWithEvents::RimcWellEventTimeline_wellsWithEvents( caf::PdmObjectHandle* self ) + : caf::PdmObjectMethod( self, PdmObjectMethod::NullPointerType::NULL_IS_INVALID, PdmObjectMethod::ResultType::PERSISTENT_FALSE ) +{ + CAF_PDM_InitObject( "Wells With Events", + "", + "", + "Sorted names of the well paths with events matching the filters. " + "Well path names must be unique within the well path collection" ); + + CAF_PDM_InitScriptableField( &m_startDate, "StartDate", QString(), "", "", "", "Earliest date to include (inclusive). Empty: no limit" ); + CAF_PDM_InitScriptableField( &m_endDate, + "EndDate", + QString(), + "", + "", + "", + "Latest date to include (inclusive). A date-only value means midnight, excluding events later that day. " + "Empty: no limit" ); + CAF_PDM_InitScriptableFieldNoDefault( &m_eventTypes, + "EventTypes", + "", + "", + "", + "Event types (SEGMENT, PERF, VALVE, ...) or keyword names (WCONHIST, ...). Empty: all events" ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected RimcWellEventTimeline_wellsWithEvents::execute() +{ + auto timeline = self(); + + auto query = createEventQuery( m_startDate(), m_endDate(), {}, m_eventTypes() ); + if ( !query ) return std::unexpected( query.error() ); + + std::set wellNames; + for ( auto event : timeline->queryEvents( *query ) ) + { + if ( event->wellPath() ) wellNames.insert( event->wellPath()->name() ); + } + + auto* dataObject = new RimcDataContainerString(); + dataObject->m_stringValues = std::vector( wellNames.begin(), wellNames.end() ); + + return dataObject; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimcWellEventTimeline_wellsWithEvents::classKeywordReturnedType() const +{ + return RimcDataContainerString::classKeywordStatic(); +} diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h index efe46443d4..8a36b8074c 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h @@ -300,3 +300,42 @@ private: caf::PdmField m_firstDateAsComment; caf::PdmField m_alignColumns; }; + +//================================================================================================== +/// +//================================================================================================== +class RimcWellEventTimeline_eventDates : public caf::PdmObjectMethod +{ + CAF_PDM_HEADER_INIT; + +public: + RimcWellEventTimeline_eventDates( caf::PdmObjectHandle* self ); + + std::expected execute() override; + QString classKeywordReturnedType() const override; + +private: + caf::PdmField m_startDate; + caf::PdmField m_endDate; + caf::PdmPtrArrayField m_wellPaths; + caf::PdmField> m_eventTypes; +}; + +//================================================================================================== +/// +//================================================================================================== +class RimcWellEventTimeline_wellsWithEvents : public caf::PdmObjectMethod +{ + CAF_PDM_HEADER_INIT; + +public: + RimcWellEventTimeline_wellsWithEvents( caf::PdmObjectHandle* self ); + + std::expected execute() override; + QString classKeywordReturnedType() const override; + +private: + caf::PdmField m_startDate; + caf::PdmField m_endDate; + caf::PdmField> m_eventTypes; +}; diff --git a/GrpcInterface/Python/rips/PythonExamples/experimental/import_simulator_events.py b/GrpcInterface/Python/rips/PythonExamples/experimental/import_simulator_events.py index 323921fc5f..336f19d073 100644 --- a/GrpcInterface/Python/rips/PythonExamples/experimental/import_simulator_events.py +++ b/GrpcInterface/Python/rips/PythonExamples/experimental/import_simulator_events.py @@ -125,9 +125,15 @@ def main() -> None: if report.events_applied == 0: print(" No events found - skipping schedule generation.") return + # Export multi-segment-well keywords only for the wells with SEGMENT events. + msw_wells = timeline.wells_with_msw_events() + print( + f" Event dates: {timeline.earliest_event_date()} to {timeline.latest_event_date()}" + ) + print(f" MSW wells: {[well.name for well in msw_wells]}") schedule_text = timeline.generate_schedule_text( eclipse_case=case, - export_msw_for_wells=project.well_paths(), + export_msw_for_wells=msw_wells, align_columns=True, ) if schedule_text: diff --git a/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_simulator_events.py b/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_simulator_events.py index b592735312..759084c7d5 100644 --- a/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_simulator_events.py +++ b/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_simulator_events.py @@ -237,7 +237,7 @@ def main() -> None: # 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], + export_msw_for_wells=timeline.wells_with_msw_events(), align_columns=True, ) if schedule_text: diff --git a/GrpcInterface/Python/rips/simulator_events.py b/GrpcInterface/Python/rips/simulator_events.py index 38f1382208..d8f1697660 100644 --- a/GrpcInterface/Python/rips/simulator_events.py +++ b/GrpcInterface/Python/rips/simulator_events.py @@ -502,6 +502,138 @@ class SimulatorEventsDocument: """The dates of the expanded ``INSERT_DATE`` events, in document order.""" return [event.event_date for event in self.insert_date_events] + def events( + self, + start_date: Optional[datetime.datetime] = None, + end_date: Optional[datetime.datetime] = None, + wells: Optional[List[str]] = None, + groups: Optional[List[str]] = None, + event_types: Optional[List[str]] = None, + ) -> List[SimulatorEvent]: + """Return events for the selected owners, filtered by date and type. + + Events are sorted by date. Equal dates keep their document order (WELL + blocks, GROUP blocks, SCHEDULE events, then INSERT_DATE events). + When both ``wells`` and ``groups`` are given, matching WELL and GROUP + events are combined (union). Date and type filters apply to that union. + + Arguments: + start_date: Earliest event date to include (inclusive). ``None`` + leaves the range open. + end_date: Latest event date to include (inclusive). ``None`` + leaves the range open. + wells: Well names. Only events in matching WELL blocks are + included. + groups: Group names. Only events in matching GROUP blocks are + included. When ``wells`` and/or ``groups`` is given, SCHEDULE + and INSERT_DATE events are excluded. + event_types: Event types (``SEGMENT``, ``PERFORATION``, + ``INSERT_DATE``, ...) or keyword names (``WCONHIST``, + ``RPTRST``, ...), matched case-insensitively. + + An empty list or ``None`` disables the corresponding filter. + """ + well_names = set(wells or []) + group_names = set(groups or []) + type_names = {name.upper() for name in event_types or []} + + candidates: List[SimulatorEvent] = [] + if not group_names or well_names: + for well in self.wells: + if not well_names or well.well_name in well_names: + candidates.extend(well.events) + if not well_names or group_names: + for group in self.groups: + if not group_names or group.group_name in group_names: + candidates.extend(group.events) + if not well_names and not group_names: + candidates.extend(self.schedule_events) + candidates.extend(self.insert_date_events) + + selected = [ + event + for event in candidates + if (start_date is None or event.event_date >= start_date) + and (end_date is None or event.event_date <= end_date) + and (not type_names or event.event_type.upper() in type_names) + ] + return sorted(selected, key=lambda event: event.event_date) + + def event_dates(self, **filters: Any) -> List[datetime.datetime]: + """Return the sorted, unique dates of the events matching ``filters``. + + Accepts the same keyword arguments as :meth:`events`. + """ + return sorted({event.event_date for event in self.events(**filters)}) + + def earliest_event_date(self, **filters: Any) -> Optional[datetime.datetime]: + """Return the earliest date of the events matching ``filters``, or + ``None`` when no event matches. Accepts the arguments of :meth:`events`. + """ + dates = self.event_dates(**filters) + return dates[0] if dates else None + + def latest_event_date(self, **filters: Any) -> Optional[datetime.datetime]: + """Return the latest date of the events matching ``filters``, or + ``None`` when no event matches. Accepts the arguments of :meth:`events`. + """ + dates = self.event_dates(**filters) + return dates[-1] if dates else None + + def wells_with_events( + self, + start_date: Optional[datetime.datetime] = None, + end_date: Optional[datetime.datetime] = None, + event_types: Optional[List[str]] = None, + ) -> List[str]: + """Return the sorted names of the wells with events matching the filters. + + See :meth:`events` for the filter semantics. + """ + return sorted( + { + well.well_name + for well in self.wells + if self.events( + start_date=start_date, + end_date=end_date, + wells=[well.well_name], + event_types=event_types, + ) + } + ) + + def wells_with_msw_events(self) -> List[str]: + """Return the sorted names of wells with ``SEGMENT`` or ``VALVE`` events. + + Use these names to build ``export_msw_for_wells``. Wells with only + ``PERFORATION`` events are not included, even if segments were configured + directly on their well paths; add such MSW wells explicitly. + """ + return self.wells_with_events(event_types=["SEGMENT", "VALVE"]) + + def groups_with_events( + self, + start_date: Optional[datetime.datetime] = None, + end_date: Optional[datetime.datetime] = None, + event_types: Optional[List[str]] = None, + ) -> List[str]: + """Return the sorted names of the groups with events matching the + filters. See :meth:`events` for the filter semantics. + """ + return sorted( + { + group.group_name + for group in self.groups + if self.events( + start_date=start_date, + end_date=end_date, + groups=[group.group_name], + event_types=event_types, + ) + } + ) + def _iso_event_date(event_date: datetime.datetime) -> str: """Format an event date for the timeline API. diff --git a/GrpcInterface/Python/rips/tests/test_simulator_events.py b/GrpcInterface/Python/rips/tests/test_simulator_events.py index 3f9a7f3618..a5411eb202 100644 --- a/GrpcInterface/Python/rips/tests/test_simulator_events.py +++ b/GrpcInterface/Python/rips/tests/test_simulator_events.py @@ -1107,6 +1107,90 @@ class TestParsing: assert value == datetime.datetime(2018, 1, 1, 0, 0, 0) +class TestDocumentQueries: + """Query event dates, wells and groups from a parsed document (#14688).""" + + TEXT = ( + "SIMEVENTS 1.2\n" + 'WELL "OP_1"\n' + " 2024-01-01 SEGMENT MDSTART=0 MDEND=2500\n" + " 2024-01-01 PERFORATION MDSTART=2000 MDEND=2200\n" + " 2024-06-01 WCONHIST STATUS=OPEN\n" + 'WELL "OP_2"\n' + " 2024-03-01 PERFORATION MDSTART=2000 MDEND=2200\n" + 'GROUP "FIELD"\n' + " 2024-02-01 GCONPROD CMODE=LRAT LRAT=1000\n" + "SCHEDULE\n" + " 2025-01-01 RPTRST BASIC=2\n" + " 2030-01-01 INSERT_DATE\n" + ) + + @pytest.fixture + def doc(self): + return parse_simulator_events(self.TEXT) + + def test_event_dates_cover_all_blocks(self, doc): + D = datetime.datetime + assert doc.event_dates() == [ + D(2024, 1, 1), + D(2024, 2, 1), + D(2024, 3, 1), + D(2024, 6, 1), + D(2025, 1, 1), + D(2030, 1, 1), + ] + assert doc.latest_event_date() == D(2030, 1, 1) + assert doc.earliest_event_date() == D(2024, 1, 1) + assert doc.latest_event_date(event_types=["INSERT_DATE"]) == D(2030, 1, 1) + assert doc.latest_event_date(event_types=["NOSUCH"]) is None + + def test_date_bounds_are_inclusive(self, doc): + D = datetime.datetime + assert doc.event_dates(start_date=D(2024, 2, 1), end_date=D(2024, 6, 1)) == [ + D(2024, 2, 1), + D(2024, 3, 1), + D(2024, 6, 1), + ] + + def test_owner_and_type_filters(self, doc): + D = datetime.datetime + assert [e.event_type for e in doc.events(wells=["OP_1"])] == [ + "SEGMENT", + "PERFORATION", + "WCONHIST", + ] + assert [e.event_type for e in doc.events(groups=["FIELD"])] == ["GCONPROD"] + # Owner filters exclude SCHEDULE and INSERT_DATE events + assert doc.event_dates(wells=["OP_2"], groups=["FIELD"]) == [ + D(2024, 2, 1), + D(2024, 3, 1), + ] + assert doc.event_dates(event_types=["perforation", "rptrst"]) == [ + D(2024, 1, 1), + D(2024, 3, 1), + D(2025, 1, 1), + ] + + def test_wells_and_groups_with_events(self, doc): + assert doc.wells_with_events() == ["OP_1", "OP_2"] + assert doc.wells_with_events(start_date=datetime.datetime(2024, 2, 1)) == [ + "OP_1", + "OP_2", + ] + assert doc.wells_with_events(event_types=["WCONHIST"]) == ["OP_1"] + assert doc.wells_with_msw_events() == ["OP_1"] + assert doc.groups_with_events() == ["FIELD"] + assert doc.groups_with_events(event_types=["GCONINJE"]) == [] + + def test_msw_wells_include_valves_without_segment_events(self): + doc = parse_simulator_events( + self.TEXT + + 'WELL "VALVE_ONLY"\n' + + " 2024-03-01 VALVE MD=2100 TYPE=ICV STATE=OPEN CV=0.7 AREA=0.0001\n" + ) + assert doc.wells_with_msw_events() == ["OP_1", "VALVE_ONLY"] + + class TestDurations: """Go-style duration literals (issue #14651).""" diff --git a/GrpcInterface/Python/rips/tests/test_well_events.py b/GrpcInterface/Python/rips/tests/test_well_events.py index ad2c2e7042..bc8f978cde 100644 --- a/GrpcInterface/Python/rips/tests/test_well_events.py +++ b/GrpcInterface/Python/rips/tests/test_well_events.py @@ -3178,3 +3178,210 @@ class TestScheduleGenerationWithoutCase: assert any("COMPDAT" in w and export_name in w for w in warnings) assert any("WELSPECS" in w for w in warnings) assert any("WELSEGS" in w for w in warnings) + + +class TestTimelineQueries: + """#14688: query event dates and wells from the timeline.""" + + @pytest.fixture + def populated_timeline(self, rips_instance, initialize_test): + """Timeline with a SEGMENT well (A), a perforation-only well (B), a + schedule keyword and an insert date.""" + case_root = dataroot.PATH + "/TEST10K_FLT_LGR_NNC" + project = rips_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", + case_root + "/wellpath_b.dev", + ] + ) + timeline = project.descendants(rips.WellPathCollection)[0].event_timeline() + well_paths = project.well_paths() + well_a = [wp for wp in well_paths if "A" in wp.name][0] + well_b = [wp for wp in well_paths if "B" in wp.name][0] + + timeline.add_segment_event( + event_date="2024-01-01", + well_path=well_a, + start_md=0.0, + end_md=2500.0, + inner_diameter=0.15, + roughness=1.0e-5, + ) + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_a, + start_md=2000.0, + end_md=2200.0, + diameter=0.1, + state="OPEN", + ) + timeline.add_perf_event( + event_date="2024-03-01", + well_path=well_b, + start_md=2000.0, + end_md=2200.0, + diameter=0.1, + state="OPEN", + ) + timeline.add_well_keyword_event( + event_date="2024-06-01", + well_path=well_b, + keyword_name="WCONHIST", + keyword_data={"WELL": well_b.name, "STATUS": "OPEN"}, + ) + timeline.add_keyword_event( + event_date="2025-01-01", + keyword_name="RPTRST", + keyword_data={"BASIC": 2}, + ) + timeline.add_insert_date_event(event_date="2030-01-01") + return case, timeline, well_a, well_b + + def test_event_dates_and_bounds(self, populated_timeline): + from datetime import datetime + + _, timeline, _, _ = populated_timeline + + assert timeline.event_dates() == [ + datetime(2024, 1, 1), + datetime(2024, 3, 1), + datetime(2024, 6, 1), + datetime(2025, 1, 1), + datetime(2030, 1, 1), + ] + assert timeline.latest_event_date() == datetime(2030, 1, 1) + assert timeline.earliest_event_date() == datetime(2024, 1, 1) + assert timeline.latest_event_date(event_types=["INSERT_DATE"]) == datetime( + 2030, 1, 1 + ) + + # Inclusive bounds + assert timeline.event_dates(start_date="2024-03-01", end_date="2024-06-01") == [ + datetime(2024, 3, 1), + datetime(2024, 6, 1), + ] + assert timeline.latest_event_date(end_date=datetime(2024, 12, 31)) == datetime( + 2024, 6, 1 + ) + assert timeline.latest_event_date(start_date="2031-01-01") is None + + def test_event_dates_filters(self, populated_timeline): + from datetime import datetime + + _, timeline, well_a, well_b = populated_timeline + + assert timeline.event_dates(wells=[well_b]) == [ + datetime(2024, 3, 1), + datetime(2024, 6, 1), + ] + assert timeline.event_dates(event_types=["segment"]) == [datetime(2024, 1, 1)] + # Keyword names match well and schedule keyword events + assert timeline.event_dates(event_types=["WCONHIST", "rptrst"]) == [ + datetime(2024, 6, 1), + datetime(2025, 1, 1), + ] + assert timeline.event_dates(wells=[well_a], event_types=["WCONHIST"]) == [] + + def test_set_timestamp_does_not_limit_queries(self, populated_timeline): + from datetime import datetime + + _, timeline, _, _ = populated_timeline + timeline.set_timestamp(timestamp="2024-02-01") + assert timeline.latest_event_date() == datetime(2030, 1, 1) + + def test_wells_with_events(self, populated_timeline): + _, timeline, well_a, well_b = populated_timeline + + assert [wp.name for wp in timeline.wells_with_events()] == sorted( + [well_a.name, well_b.name] + ) + assert [ + wp.name for wp in timeline.wells_with_events(start_date="2024-02-01") + ] == [well_b.name] + assert [wp.name for wp in timeline.wells_with_msw_events()] == [well_a.name] + + def test_msw_wells_drive_schedule_export(self, populated_timeline): + case, timeline, well_a, well_b = populated_timeline + timeline.set_timestamp(timestamp="2030-01-01") + + schedule = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=timeline.wells_with_msw_events() + ) + lines = schedule.splitlines() + welsegs_wells = [ + lines[i + 1].split()[0].strip("'") + for i, line in enumerate(lines) + if line == "WELSEGS" + ] + assert welsegs_wells == [well_a.completion_settings().well_name_for_export] + assert well_b.completion_settings().well_name_for_export in schedule + + @pytest.mark.parametrize( + "valve_type, keyword", [("ICD", "WSEGVALV"), ("AICD", "WSEGAICD")] + ) + def test_msw_wells_include_valves_without_segment_events( + self, populated_timeline, valve_type, keyword + ): + case, timeline, well_a, well_b = populated_timeline + well_b.segment_collection().add_segment_interval( + start_md=0.0, end_md=2500.0, diameter=0.15, roughness_factor=1.0e-5 + ) + timeline.add_valve_event( + event_date="2024-03-01", + well_path=well_b, + measured_depth=2100.0, + valve_type=valve_type, + state="OPEN", + ) + assert timeline.event_dates(wells=[well_b], event_types=["SEGMENT"]) == [] + msw_wells = timeline.wells_with_msw_events() + assert [wp.name for wp in msw_wells] == sorted([well_a.name, well_b.name]) + + timeline.set_timestamp(timestamp="2030-01-01") + schedule = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=msw_wells + ) + assert f"\n{keyword}\n" in schedule + + @pytest.mark.parametrize("event_types", [None, ["SEGMENT"]]) + def test_wells_with_events_rejects_ambiguous_names( + self, populated_timeline, event_types + ): + _, timeline, well_a, well_b = populated_timeline + well_b.name = well_a.name + well_b.update() + assert well_a.address() != well_b.address() + with pytest.raises(ValueError, match="Ambiguous well path name"): + timeline.wells_with_events(event_types=event_types) + + def test_date_only_end_excludes_later_events(self, populated_timeline): + from datetime import datetime + + _, timeline, _, well_b = populated_timeline + timeline.add_valve_event( + event_date="2024-03-01T12:30:00", well_path=well_b, measured_depth=2100.0 + ) + assert timeline.event_dates(wells=[well_b], end_date="2024-03-01") == [ + datetime(2024, 3, 1) + ] + assert timeline.event_dates(wells=[well_b], end_date="2024-03-01T12:30:00") == [ + datetime(2024, 3, 1), + datetime(2024, 3, 1, 12, 30), + ] + assert ( + timeline.wells_with_events(end_date="2024-03-01", event_types=["VALVE"]) + == [] + ) + assert [ + wp.name + for wp in timeline.wells_with_events( + end_date="2024-03-01T12:30:00", event_types=["VALVE"] + ) + ] == [well_b.name] + + def test_invalid_date_raises(self, populated_timeline): + _, timeline, _, _ = populated_timeline + with pytest.raises(rips.RipsError): + timeline.event_dates(start_date="not-a-date") diff --git a/GrpcInterface/Python/rips/well_events.py b/GrpcInterface/Python/rips/well_events.py index 163f1c1e16..166a161b57 100644 --- a/GrpcInterface/Python/rips/well_events.py +++ b/GrpcInterface/Python/rips/well_events.py @@ -17,6 +17,7 @@ from .generated.generated_classes import ( WellEventKeyword, WellEventTimeline, WellPath, + WellPathCollection, ) @@ -419,3 +420,147 @@ def generate_schedule_text( if container and container.values: return "".join(container.values) return "" + + +def _format_query_date(query_date: Optional[str | date | datetime]) -> str: + """Convert an optional query bound to an ISO string. None gives an empty + string (no limit). A datetime keeps its time of day.""" + if query_date is None: + return "" + if isinstance(query_date, datetime): + return query_date.isoformat(timespec="seconds") + return _format_date(query_date) + + +@add_method(WellEventTimeline) +def event_dates( + self: WellEventTimeline, + start_date: Optional[str | date | datetime] = None, + end_date: Optional[str | date | datetime] = None, + wells: Optional[List[WellPath]] = None, + event_types: Optional[List[str]] = None, +) -> List[datetime]: + """Return the sorted, unique dates of the timeline events matching all filters. + + The full timeline is queried; set_timestamp() does not limit the result. + + Arguments: + start_date: Earliest event date to include (inclusive). None: no limit. + end_date: Latest event date to include (inclusive). None: no limit. A + date without time of day means midnight, so events later that day + are excluded. + wells (Optional[List[WellPath]]): Only include events for these wells. + When given, schedule keyword, raw text and insert-date events are + excluded. None or empty: no well filter. + event_types (Optional[List[str]]): Timeline event types (SEGMENT, PERF, + VALVE, WSTATE, WTYPE, WELLSPEC, WCONTROL, KEYWORD, + SCHEDULE_KEYWORD, RAW_TEXT, INSERT_DATE) or keyword names + (WCONHIST, RPTRST, ...), case-insensitive. None or empty: all + events. + + Returns: + List[datetime]: The event dates. + + Example: + .. code-block:: python + + last_date = timeline.latest_event_date() + timeline.set_timestamp(timestamp=last_date.isoformat()) + """ + container = self.query_event_dates( + start_date=_format_query_date(start_date), + end_date=_format_query_date(end_date), + well_paths=wells or [], + event_types=event_types or [], + ) + values = container.values if container and container.values else [] + return [datetime.fromisoformat(value) for value in values] + + +@add_method(WellEventTimeline) +def earliest_event_date( + self: WellEventTimeline, + start_date: Optional[str | date | datetime] = None, + end_date: Optional[str | date | datetime] = None, + wells: Optional[List[WellPath]] = None, + event_types: Optional[List[str]] = None, +) -> Optional[datetime]: + """Return the earliest date of the timeline events matching all filters, + or None when no event matches. See event_dates() for the arguments.""" + dates = event_dates(self, start_date, end_date, wells, event_types) + return dates[0] if dates else None + + +@add_method(WellEventTimeline) +def latest_event_date( + self: WellEventTimeline, + start_date: Optional[str | date | datetime] = None, + end_date: Optional[str | date | datetime] = None, + wells: Optional[List[WellPath]] = None, + event_types: Optional[List[str]] = None, +) -> Optional[datetime]: + """Return the latest date of the timeline events matching all filters, or + None when no event matches. See event_dates() for the arguments.""" + dates = event_dates(self, start_date, end_date, wells, event_types) + return dates[-1] if dates else None + + +@add_method(WellEventTimeline) +def wells_with_events( + self: WellEventTimeline, + start_date: Optional[str | date | datetime] = None, + end_date: Optional[str | date | datetime] = None, + event_types: Optional[List[str]] = None, +) -> List[WellPath]: + """Return the well paths with timeline events matching all filters, sorted + by name. See event_dates() for the arguments. + + Matching well names must be unique within the well path collection. + Raises ValueError if a name is ambiguous or cannot be resolved. + + Returns: + List[WellPath]: The well paths. + """ + container = self.query_wells_with_events( + start_date=_format_query_date(start_date), + end_date=_format_query_date(end_date), + event_types=event_types or [], + ) + names = container.values if container and container.values else [] + if not names: + return [] + + collection = self.ancestor(WellPathCollection) + if collection is None: + raise ValueError("Cannot resolve event wells without a well path collection") + matching_names = set(names) + well_paths_by_name: Dict[str, WellPath] = {} + for well_path in collection.descendants(WellPath): + if well_path.name not in matching_names: + continue + if well_path.name in well_paths_by_name: + raise ValueError(f"Ambiguous well path name: {well_path.name!r}") + well_paths_by_name[well_path.name] = well_path + missing_names = matching_names - well_paths_by_name.keys() + if missing_names: + raise ValueError(f"Cannot resolve event well names: {sorted(missing_names)!r}") + return [well_paths_by_name[name] for name in names] + + +@add_method(WellEventTimeline) +def wells_with_msw_events(self: WellEventTimeline) -> List[WellPath]: + """Return the well paths with SEGMENT or VALVE events, sorted by name. + + Wells with only PERF events are not included, even if segments were + configured directly on their well paths; add such MSW wells explicitly + to export_msw_for_wells. + + Example: + .. code-block:: python + + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=timeline.wells_with_msw_events(), + ) + """ + return wells_with_events(self, event_types=["SEGMENT", "VALVE"]) diff --git a/docs/simulatorEvents.md b/docs/simulatorEvents.md index 3dc820620b..cf5e1c5361 100644 --- a/docs/simulatorEvents.md +++ b/docs/simulatorEvents.md @@ -348,7 +348,7 @@ print(report.events_applied, report.events_skipped, report.warnings, report.erro timeline.set_timestamp(timestamp="2024-12-24") # materialize completions up to a date schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[well_path] + eclipse_case=case, export_msw_for_wells=timeline.wells_with_msw_events() ) ``` @@ -363,6 +363,56 @@ They also accept two policies, each `"warn"` (default), `"error"` or `"skip"`: The returned `ApplyReport` carries `events_applied`, `events_skipped`, `warnings` and `errors`. +### Querying events + +Both the parsed document and the timeline can answer common questions directly, so clients do not +need to walk wells, groups and schedule events themselves. The filters are the same in both layers: + +| Filter | Meaning | +|---|---| +| `start_date`, `end_date` | Inclusive date range. `None` leaves the bound open. A date without time of day is midnight. | +| `wells` | Only events for these wells. When given, schedule-level and `INSERT_DATE` events are excluded. | +| `event_types` | Event types or keyword names (`WCONHIST`, `RPTRST`, ...), case-insensitive. | + +An empty list or `None` disables a filter. The filters are combined with AND. + +On a `SimulatorEventsDocument` (pure Python, no ResInsight needed), wells and groups are given by name and +event types use the SIMEVENTS names (`SEGMENT`, `PERFORATION`, `INSERT_DATE`, ...). `groups` is an additional +owner filter. When both `wells` and `groups` are supplied, matching WELL and GROUP events are combined +(union), then filtered by date and event type: + +```python +document = rips.simulator_events.parse_simulator_events_file("simulator_events.events") +document.events(wells=["OP_1"], event_types=["WCONHIST"]) # List[SimulatorEvent], sorted by date +document.event_dates(start_date=datetime.datetime(2024, 1, 1)) +document.latest_event_date() # or earliest_event_date(); None when nothing matches +document.wells_with_events(event_types=["PERFORATION"]) # well names +document.wells_with_msw_events() # wells with SEGMENT or VALVE events +document.groups_with_events() +``` + +On a `WellEventTimeline`, wells are `WellPath` objects. Event types use the timeline names `SEGMENT`, `PERF`, +`VALVE`, `WSTATE`, `WTYPE`, `WELLSPEC`, `WCONTROL`, `KEYWORD`, `SCHEDULE_KEYWORD`, `RAW_TEXT` and `INSERT_DATE`. +Any other name is matched against keyword names. The queries always use the full timeline. `set_timestamp()` does +not limit them: + +```python +timeline.event_dates(wells=[well_path]) # List[datetime] +timeline.set_timestamp(timestamp=timeline.latest_event_date().isoformat()) +timeline.wells_with_events(start_date="2025-01-01") # List[WellPath] +schedule_text = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=timeline.wells_with_msw_events() +) +``` + +`wells_with_msw_events()` includes wells with `SEGMENT` or `VALVE` events. It does not inspect +segment configuration on well paths: add MSW wells with only `PERFORATION` (`PERF` on the timeline) +events explicitly to `export_msw_for_wells`. + +Timeline well queries resolve names within the well path collection. Matching names must be unique; +`wells_with_events()` and `wells_with_msw_events()` raise `ValueError` rather than choose between +well paths sharing a name. + ### Generating a schedule without a grid `eclipse_case` is optional in `generate_schedule_text()`. It accepts any Eclipse-type case, including Roff and