diff --git a/ApplicationLibCode/CMakeLists.txt b/ApplicationLibCode/CMakeLists.txt index df7469fa81e..7a73387dbe8 100644 --- a/ApplicationLibCode/CMakeLists.txt +++ b/ApplicationLibCode/CMakeLists.txt @@ -442,6 +442,7 @@ set(UNITY_EXCLUDE_FILES # https://cmake.org/cmake/help/latest/prop_tgt/AUTOGEN_BUILD_DIR.html ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}_autogen/mocs_compilation.cpp ProjectDataModel/RiaOpmParserTools.cpp + ProjectDataModel/RiaOpmKeywordTools.cpp FileInterface/RifOsduWellPathReader.cpp FileInterface/RifOsduWellLogReader.cpp FileInterface/RifByteArrayArrowRandomAccessFile.cpp diff --git a/ApplicationLibCode/ProjectDataModel/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/CMakeLists_files.cmake index 8e4ccd74936..d3e7a00d28a 100644 --- a/ApplicationLibCode/ProjectDataModel/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/CMakeLists_files.cmake @@ -99,6 +99,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimColorLegend.cpp ${CMAKE_CURRENT_LIST_DIR}/RimColorLegendItem.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaOpmParserTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiaOpmKeywordTools.cpp ${CMAKE_CURRENT_LIST_DIR}/RimCaseDisplayNameTools.cpp ${CMAKE_CURRENT_LIST_DIR}/RimCustomObjectiveFunctionCollection.cpp ${CMAKE_CURRENT_LIST_DIR}/RimCustomObjectiveFunction.cpp diff --git a/ApplicationLibCode/ProjectDataModel/RiaOpmKeywordTools.cpp b/ApplicationLibCode/ProjectDataModel/RiaOpmKeywordTools.cpp new file mode 100644 index 00000000000..14e11c48d55 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/RiaOpmKeywordTools.cpp @@ -0,0 +1,74 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +// A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiaOpmKeywordTools.h" + +#include "opm/input/eclipse/Parser/Parser.hpp" +#include "opm/input/eclipse/Parser/ParserItem.hpp" +#include "opm/input/eclipse/Parser/ParserKeyword.hpp" +#include "opm/input/eclipse/Parser/ParserRecord.hpp" + +#include +#include + +namespace +{ +const Opm::Parser& sharedParser() +{ + static const Opm::Parser parser( true ); + return parser; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::optional RiaOpmKeywordTools::keywordInfo( const QString& keywordName ) +{ + const QString keyword = keywordName.toUpper(); + const auto& parser = sharedParser(); + const auto deckName = keyword.toStdString(); + + if ( !parser.isRecognizedKeyword( deckName ) ) return std::nullopt; + + const auto& parserKeyword = parser.getParserKeywordFromDeckName( deckName ); + + RiaOpmKeywordInfo info; + info.name = QString::fromStdString( parserKeyword.getName() ); + + const auto recordCount = static_cast( std::distance( parserKeyword.begin(), parserKeyword.end() ) ); + if ( recordCount == 1 ) + { + const auto& record = parserKeyword.getRecord( 0 ); + info.acceptsArbitraryItems = record.size() == 1 && record.get( 0 ).sizeType() == Opm::ParserItem::item_size::ALL; + } + + std::set seenNames; + for ( const auto& record : parserKeyword ) + { + for ( const auto& item : record ) + { + if ( seenNames.insert( item.name() ).second ) + { + info.itemNames.push_back( QString::fromStdString( item.name() ) ); + } + } + } + + return info; +} diff --git a/ApplicationLibCode/ProjectDataModel/RiaOpmKeywordTools.h b/ApplicationLibCode/ProjectDataModel/RiaOpmKeywordTools.h new file mode 100644 index 00000000000..ed6b66e082e --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/RiaOpmKeywordTools.h @@ -0,0 +1,36 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +// A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include + +#include +#include + +struct RiaOpmKeywordInfo +{ + QString name; + std::vector itemNames; + bool acceptsArbitraryItems = false; +}; + +namespace RiaOpmKeywordTools +{ +std::optional keywordInfo( const QString& keywordName ); +} // namespace RiaOpmKeywordTools diff --git a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp index da7add7ae54..8b11326ed5b 100644 --- a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp +++ b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.cpp @@ -225,7 +225,7 @@ RimWellEventPerf* RimWellEventTimeline::addPerforationEvent( RimWellPath* wellPa event->setWellPath( wellPath ); event->setEventDate( date ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -238,7 +238,7 @@ RimWellEventValve* RimWellEventTimeline::addValveEvent( RimWellPath* wellPath, c event->setWellPath( wellPath ); event->setEventDate( date ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -251,7 +251,7 @@ RimWellEventTubing* RimWellEventTimeline::addTubingEvent( RimWellPath* wellPath, event->setWellPath( wellPath ); event->setEventDate( date ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -264,7 +264,7 @@ RimWellEventState* RimWellEventTimeline::addStateEvent( RimWellPath* wellPath, c event->setWellPath( wellPath ); event->setEventDate( date ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -277,7 +277,7 @@ RimWellEventType* RimWellEventTimeline::addTypeEvent( RimWellPath* wellPath, con event->setWellPath( wellPath ); event->setEventDate( date ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -290,7 +290,7 @@ RimWellEventControl* RimWellEventTimeline::addControlEvent( RimWellPath* wellPat event->setWellPath( wellPath ); event->setEventDate( date ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -304,7 +304,7 @@ RimWellEventKeyword* RimWellEventTimeline::addWellKeywordEvent( RimWellPath* wel event->setEventDate( date ); event->setKeywordName( keywordName ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -318,7 +318,7 @@ RimKeywordEvent* RimWellEventTimeline::addKeywordEvent( const QDateTime& date, c event->setEventDate( date ); event->setKeywordName( keywordName ); m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); return event; } @@ -330,7 +330,7 @@ void RimWellEventTimeline::addEvent( RimWellEvent* event ) if ( event ) { m_events.push_back( event ); - updateConnectedEditors(); + updateEditorsAfterEventChange(); } } @@ -341,7 +341,7 @@ void RimWellEventTimeline::removeEvent( RimWellEvent* event ) { m_events.removeChild( event ); delete event; - updateConnectedEditors(); + updateEditorsAfterEventChange(); } //-------------------------------------------------------------------------------------------------- @@ -350,7 +350,20 @@ void RimWellEventTimeline::removeEvent( RimWellEvent* event ) void RimWellEventTimeline::clearAllEvents() { m_events.deleteChildren(); + updateEditorsAfterEventChange(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWellEventTimeline::updateEditorsAfterEventChange() +{ updateConnectedEditors(); + + if ( auto* wellPathCollection = firstAncestorOrThisOfType() ) + { + wellPathCollection->updateConnectedEditors(); + } } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h index b6ba5b88b2f..1969825f20d 100644 --- a/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h +++ b/ApplicationLibCode/ProjectDataModel/WellEvents/RimWellEventTimeline.h @@ -112,6 +112,7 @@ class RimWellEventTimeline : public caf::PdmObject bool applyValveEvent( RimWellEventValve& event, RimWellPath& wellPath ); std::vector filteredAndSortedEventsForUi() const; + void updateEditorsAfterEventChange(); caf::PdmChildArrayField m_events; QDateTime m_lastAppliedTimestamp; diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp index 92fb7f26bc5..c18217c0cf1 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.cpp @@ -19,6 +19,7 @@ #include "RimcWellEventTimeline.h" #include "CompletionExportCommands/RicScheduleDataGenerator.h" +#include "RiaOpmKeywordTools.h" #include "RimEclipseCase.h" #include "RimKeywordEvent.h" #include "RimWellEventControl.h" @@ -36,10 +37,45 @@ #include "cafPdmFieldScriptingCapability.h" #include +#include #include #include +namespace +{ +std::expected validateKeywordItems( const QString& keywordName, const std::vector& itemNames ) +{ + auto keywordInfo = RiaOpmKeywordTools::keywordInfo( keywordName ); + if ( !keywordInfo ) + { + return std::unexpected( QString( "Keyword '%1' is not recognized by opm-common." ).arg( keywordName ) ); + } + + if ( keywordInfo->acceptsArbitraryItems ) return {}; + + QStringList invalidNames; + for ( const auto& itemName : itemNames ) + { + if ( std::find( keywordInfo->itemNames.begin(), keywordInfo->itemNames.end(), itemName ) == keywordInfo->itemNames.end() ) + { + invalidNames.push_back( itemName ); + } + } + + if ( invalidNames.empty() ) return {}; + + QStringList validNames; + for ( const auto& itemName : keywordInfo->itemNames ) + { + validNames.push_back( itemName ); + } + + return std::unexpected( QString( "Keyword '%1' contains invalid item names: %2. Valid item names are: %3." ) + .arg( keywordName.toUpper(), invalidNames.join( ", " ), validNames.join( ", " ) ) ); +} +} // namespace + CAF_PDM_OBJECT_METHOD_SOURCE_INIT( RimWellEventTimeline, RimcWellEventTimeline_addPerfEvent, "AddPerfEvent" ); //-------------------------------------------------------------------------------------------------- @@ -373,6 +409,12 @@ std::expected RimcWellEventTimeline_addWellKeywo return std::unexpected( QString( "Item arrays must have same length" ) ); } + auto validationResult = validateKeywordItems( m_keywordName(), m_itemNames() ); + if ( !validationResult ) + { + return std::unexpected( validationResult.error() ); + } + // Create event auto* event = timeline->addWellKeywordEvent( m_wellPath(), date, m_keywordName() ); @@ -469,6 +511,12 @@ std::expected RimcWellEventTimeline_addKeywordEv return std::unexpected( QString( "Item arrays must have same length" ) ); } + auto validationResult = validateKeywordItems( m_keywordName(), m_itemNames() ); + if ( !validationResult ) + { + return std::unexpected( validationResult.error() ); + } + // Create event (note: no wellPath parameter) auto* event = timeline->addKeywordEvent( date, m_keywordName() ); @@ -592,6 +640,13 @@ RimcWellEventTimeline_generateSchedule::RimcWellEventTimeline_generateSchedule( "", "", "Emit a column-header comment and right-aligned, fixed-width columns instead of the compact form" ); + CAF_PDM_InitScriptableFieldNoDefault( &m_additionalDates, + "AdditionalDates", + "", + "", + "", + "Additional dates (YYYY-MM-DD or full ISO timestamp) emitted as DATES keywords, e.g. to " + "force summary reports at those dates" ); } //-------------------------------------------------------------------------------------------------- @@ -641,6 +696,24 @@ std::expected RimcWellEventTimeline_generateSche return std::unexpected( QString( "No well paths with events found" ) ); } + // Merge in user-specified additional dates: each becomes a DATES keyword even when no events + // fall on it (e.g. to force a summary report). They are deliberately not filtered by the last + // applied timestamp. + if ( !m_additionalDates().empty() ) + { + std::set mergedDates( dates.begin(), dates.end() ); + for ( const QString& dateString : m_additionalDates() ) + { + QDateTime additionalDate = QDateTime::fromString( dateString, Qt::ISODate ); + if ( !additionalDate.isValid() ) + { + return std::unexpected( QString( "Invalid date format: %1. Expected YYYY-MM-DD" ).arg( dateString ) ); + } + mergedDates.insert( additionalDate ); + } + dates.assign( mergedDates.begin(), mergedDates.end() ); + } + std::vector mswWellPaths = m_exportMswForWells.ptrReferencedObjectsByType(); std::set mswWells( mswWellPaths.begin(), mswWellPaths.end() ); diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h index 19772bae0ed..fbeb3e4b490 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h @@ -231,4 +231,5 @@ class RimcWellEventTimeline_generateSchedule : public caf::PdmObjectMethod caf::PdmPtrArrayField m_exportMswForWells; caf::PdmField m_firstDateAsComment; caf::PdmField m_alignColumns; + caf::PdmField> m_additionalDates; }; diff --git a/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py b/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py index 73df183e953..dd86999e6e1 100644 --- a/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py +++ b/GrpcInterface/Python/rips/PythonExamples/experimental/well_event_schedule_orion.py @@ -16,7 +16,9 @@ 3. 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. Generating Eclipse schedule text from the resulting timeline +5. 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 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 @@ -72,8 +74,13 @@ def build_orion_text(well_name, with_filter): # Schedule-level keywords (not tied to a well) SCHEDULE @STARTUP RPTRST BASIC=2 FREQ=1 - @STARTUP GRUPTREE CHILD=OP PARENT=FIELD + @STARTUP GRUPTREE CHILD_GROUP=OP PARENT_GROUP=FIELD @STARTUP TUNING TSINIT=1 TSMAXZ=30 TMAXWC=1 NEWTMX=12 NEWTMN=1 LITMAX=50 LITMIN=1 MXWSIT=50 MXWPIT=50 + +# Report dates: emitted as bare DATES keywords so Eclipse/Flow writes a +# summary report at these dates even though no events fall on them. +REPORT 2024-07-01 +REPORT STARTUP + 365 """ @@ -114,6 +121,7 @@ def main(): ) print(f" Events applied: {report.events_applied}") print(f" Events skipped: {report.events_skipped}") + print(f" Report dates: {report.report_dates}") for warning in report.warnings: print(f" WARNING: {warning}") for error in report.errors: @@ -139,8 +147,12 @@ 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. schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[well_path] + eclipse_case=case, + export_msw_for_wells=[well_path], + additional_dates=report.report_dates, ) if schedule_text: print(f" Generated schedule text ({len(schedule_text)} characters)") diff --git a/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py b/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py index 52d2efa5de2..2ca34c90995 100644 --- a/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py +++ b/GrpcInterface/Python/rips/PythonExamples/wells_and_fractures/well_event_schedule.py @@ -184,8 +184,8 @@ def main(): event_date="2024-01-01", keyword_name="GRUPTREE", keyword_data={ - "CHILD": "OP", - "PARENT": "FIELD", + "CHILD_GROUP": "OP", + "PARENT_GROUP": "FIELD", }, ) print(" Added GRUPTREE event on 2024-01-01 (group tree definition)") @@ -251,8 +251,12 @@ def main(): # Generate schedule text. Pass the wells that should get multi-segment-well # keywords (WELSEGS, COMPSEGS, WSEGVALV, WSEGAICD); an empty list omits them. + # additional_dates are emitted as bare DATES keywords even when no events + # fall on them - in Eclipse/Flow a DATES entry ensures a summary report. schedule_text = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[well_path] + eclipse_case=case, + export_msw_for_wells=[well_path], + additional_dates=["2024-07-01"], ) # Generate the same schedule with align_columns=True, which adds a "--"-prefixed diff --git a/GrpcInterface/Python/rips/example_input_files/well_events.orion b/GrpcInterface/Python/rips/example_input_files/well_events.orion index 972bfadcd3d..4dbf5dbfa9e 100644 --- a/GrpcInterface/Python/rips/example_input_files/well_events.orion +++ b/GrpcInterface/Python/rips/example_input_files/well_events.orion @@ -19,6 +19,11 @@ FILTER POROPERM = "PORO > 0.1 AND PERMX > 100.0" WELL A1 = "55_33-A-1" +# Report dates: each becomes a bare DATES keyword in the generated schedule +# (in Eclipse/Flow a DATES entry ensures a summary report at that date). +REPORT 2018-07-01 +REPORT A2_STARTUP + 90 + WELL A1 @A1_STARTUP PERFORATION MDSTART=1644.49 MDEND=1664.28 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=1 FILTER=POROPERM @A1_STARTUP PERFORATION MDSTART=1664.28 MDEND=1674.18 RADIUS=0.12065 SKIN=5 COMPLETION_NUMBER=2 FILTER=POROPERM diff --git a/GrpcInterface/Python/rips/orion_events.py b/GrpcInterface/Python/rips/orion_events.py index bd89cb852fd..d7b3729ca23 100644 --- a/GrpcInterface/Python/rips/orion_events.py +++ b/GrpcInterface/Python/rips/orion_events.py @@ -22,9 +22,10 @@ document = header , { statement } ; header = "ORIONEVENTS" , "2.0" ; (* first meaningful line *) - statement = unit_directive | declaration | well_block_open - | schedule_block_open | event_line ; + statement = unit_directive | declaration | report_line | well_block_open + | group_block_open | schedule_block_open | event_line ; unit_directive = "UNIT" , ( "METRIC" | "FIELD" | "LAB" ) ; + report_line = "REPORT" , date_expr ; (* REPORT 2024-06-01 *) declaration = date_decl | duration_decl | well_decl | filter_decl ; date_decl = "DATE" , ident , "=" , date_expr ; (* DATE X = 2018-03-01 + 9 *) @@ -38,6 +39,7 @@ comp_op = ">" | ">=" | "<" | "<=" ; well_block_open = "WELL" , ( quoted_string | ident ) ; (* no "=" present *) + group_block_open = "GROUP" , quoted_string ; (* group keyword events *) schedule_block_open = "SCHEDULE" ; (* well-less keyword events *) event_line = "@" , date_expr , event_type , { attribute } ; @@ -56,8 +58,8 @@ * The format is line-oriented; every non-blank line is dispatched on its first token: ``ORIONEVENTS`` (once), ``UNIT``, ``DATE``, ``DURATION``, ``WELL``, - ``SCHEDULE`` or ``@``. Anything else is an error. Keywords are uppercase and - case-sensitive (the ``DAYS`` suffix is also accepted as ``days``). + ``GROUP``, ``SCHEDULE`` or ``@``. Anything else is an error. Keywords are + uppercase and case-sensitive (the ``DAYS`` suffix is also accepted as ``days``). * Comments start with ``#`` (outside of double quotes) and run to end of line. * Variables are **typed**: ``DATE``, ``DURATION`` (whole days), ``WELL`` (well-name alias) and ``FILTER`` (cell filter expression) declarations share @@ -73,8 +75,18 @@ * ``WELL `` opens an event block for a declared ``WELL`` alias; ``WELL ""`` opens a block for the literal well name and never consults variables. A ``WELL`` line containing ``=`` is always a declaration. A bare - ``SCHEDULE`` line opens a block of schedule-level keyword events not tied to - any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal. + ``GROUP ""`` opens a block of group-level Eclipse keyword events; the + group name is injected as the ``GROUP`` item when each event is applied. + A bare ``SCHEDULE`` line opens a block of schedule-level keyword events not + tied to any well (RPTRST, GRUPTREE, TUNING, ...). Empty blocks are legal. +* ``REPORT `` (one date per line, anywhere after the header) names + a date that should appear as a bare ``DATES`` keyword in the generated + schedule even when no events fall on it — in Eclipse/Flow a ``DATES`` entry + ensures a summary report at that date. The dates are collected on + :attr:`OrionDocument.report_dates` and surfaced by the applier as sorted ISO + strings on :attr:`ApplyReport.report_dates`, ready to pass to + ``WellEventTimeline.generate_schedule_text(additional_dates=...)``. A + ``REPORT`` line is not tied to any well and does not close an open block. * Double quotes are used everywhere: well names, filter expressions and attribute values, e.g. ``FILTER="SOIL > 0.8 AND PERMX > 200"``. * Every attribute is ``KEY=VALUE``; bare positional tokens are rejected. @@ -82,9 +94,11 @@ ``PERFORATION``, ``TUBING``, ``VALVE`` and ``STATE``, or any Eclipse well keyword (``WCONHIST``, ``WELTARG``, ``WRFTPLT``, ``WCONPROD``, ...), which is passed through generically with the well name injected as WELL. Event - types inside a SCHEDULE block are Eclipse schedule keywords passed through - as-is. An event type that closely resembles a built-in is treated as a typo - per the ``on_unknown_event`` policy instead of being passed through. + types inside a GROUP block are Eclipse group keywords with the group name + injected as GROUP. Event types inside a SCHEDULE block are Eclipse schedule + keywords passed through as-is. An event type that closely resembles a built-in + is treated as a typo per the ``on_unknown_event`` policy instead of being + passed through. * On a PERFORATION event, ``FILTER=`` references a declared ``FILTER`` variable and ``FILTER=""`` is an inline anonymous filter expression. The applier materializes each used filter as a case-level combined data @@ -94,8 +108,8 @@ searched in STATIC_NATIVE, then DYNAMIC_NATIVE, then GENERATED results; a ``TYPE.`` qualifier (``STATIC``/``DYNAMIC``/``GENERATED`` or the full ``*_NATIVE`` form, case-insensitive) restricts the search to that type. -* Any other attribute key parses; keys the applier does not support yet - (``PERFID``, ``DSHIFT``) are ignored with a warning when applied. +* Any other attribute key parses; ``FILTER`` on events other than + PERFORATION is ignored with a warning when applied. * The parser recovers per line and reports **all** errors in one pass: the raised :class:`OrionParseError` carries one :class:`ParseIssue` per problem. Unknown names come with "did you mean" suggestions where possible. @@ -233,7 +247,7 @@ class AttrValue: @dataclass class OrionEvent: - """One event line: a dated action on the enclosing WELL or SCHEDULE block.""" + """One event line in an enclosing WELL, GROUP or SCHEDULE block.""" event_type: str event_date: Union[datetime.date, datetime.datetime] @@ -251,6 +265,15 @@ class WellBlock: loc: SourceLoc = SourceLoc(0, "") +@dataclass +class GroupBlock: + """A ``GROUP`` block header followed by its keyword events.""" + + group_name: str + events: List[OrionEvent] = field(default_factory=list) + loc: SourceLoc = SourceLoc(0, "") + + @dataclass class OrionDocument: """Parsed, lossless representation of an ORIONEVENTS file.""" @@ -259,7 +282,11 @@ class OrionDocument: unit_system: str = "METRIC" variables: Dict[str, OrionValue] = field(default_factory=dict) wells: List[WellBlock] = field(default_factory=list) + groups: List[GroupBlock] = field(default_factory=list) schedule_events: List[OrionEvent] = field(default_factory=list) + report_dates: List[Union[datetime.date, datetime.datetime]] = field( + default_factory=list + ) warnings: List[ParseWarning] = field(default_factory=list) @@ -267,7 +294,17 @@ class OrionDocument: # Layer A: pure parser # --------------------------------------------------------------------------- -_KEYWORDS = ("ORIONEVENTS", "UNIT", "DATE", "DURATION", "WELL", "FILTER", "SCHEDULE") +_KEYWORDS = ( + "ORIONEVENTS", + "UNIT", + "DATE", + "DURATION", + "WELL", + "FILTER", + "GROUP", + "SCHEDULE", + "REPORT", +) _IDENT = r"[A-Za-z_]\w*" _ISO_DATE = r"\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?)?" @@ -282,6 +319,7 @@ class OrionDocument: r"(?:\s+(?:DAYS|days))?$" ) _WELL_DECL_RE = re.compile(rf'^WELL\s+(?P{_IDENT})\s*=\s*"(?P[^"]*)"$') +_REPORT_RE = re.compile(rf"^REPORT\s+{_DATE_BASE}{_TERMS}$") _FILTER_DECL_RE = re.compile(rf'^FILTER\s+(?P{_IDENT})\s*=\s*"(?P[^"]*)"$') _FILTER_SPLIT_RE = re.compile(r"\s+(AND|OR)\s+") _NUMBER = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?" @@ -298,6 +336,7 @@ class OrionDocument: "GENERATED": "GENERATED", } _WELL_BLOCK_RE = re.compile(rf'^WELL\s+(?:"(?P[^"]*)"|(?P{_IDENT}))$') +_GROUP_BLOCK_RE = re.compile(r'^GROUP\s+"(?P[^"]*)"$') _EVENT_RE = re.compile(rf"^@\s*{_DATE_BASE}{_TERMS}\s+(?P.+)$") _TERM_RE = re.compile(rf"([-+])\s*(\d+|{_IDENT})") _ATTR_RE = re.compile(r'(?P[A-Za-z_]\w*)\s*=\s*(?:"(?P[^"]*)"|(?P\S+))') @@ -320,7 +359,9 @@ def parse_orion_events(text: str) -> OrionDocument: unit_holder = ["METRIC"] # mutable so _parse_line can update it variables: Dict[str, OrionValue] = {} wells: List[WellBlock] = [] + groups: List[GroupBlock] = [] schedule_events: List[OrionEvent] = [] + report_dates: List[Union[datetime.date, datetime.datetime]] = [] warnings: List[ParseWarning] = [] errors: List[ParseIssue] = [] # Event lines append to the current sink: a WellBlock's event list or the @@ -351,14 +392,16 @@ def parse_orion_events(text: str) -> OrionDocument: loc, variables, wells, + groups, schedule_events, + report_dates, warnings, current_events, unit_holder, ) except OrionParseError as exc: errors.extend(exc.errors) - if line.split(None, 1)[0] in ("WELL", "SCHEDULE") or ( + if line.split(None, 1)[0] in ("WELL", "GROUP", "SCHEDULE") or ( line.startswith("@") and current_events is None ): # Suppress cascading errors from lines belonging to a broken @@ -376,7 +419,9 @@ def parse_orion_events(text: str) -> OrionDocument: unit_system=unit_holder[0], variables=variables, wells=wells, + groups=groups, schedule_events=schedule_events, + report_dates=report_dates, warnings=warnings, ) @@ -403,7 +448,9 @@ def _parse_line( loc: SourceLoc, variables: Dict[str, OrionValue], wells: List[WellBlock], + groups: List[GroupBlock], schedule_events: List[OrionEvent], + report_dates: List[Union[datetime.date, datetime.datetime]], warnings: List[ParseWarning], current_events: Optional[List[OrionEvent]], unit_holder: List[str], @@ -428,6 +475,17 @@ def _parse_line( unit_holder[0] = match.group("unit") return current_events + if first == "GROUP": + match = _GROUP_BLOCK_RE.match(line) + if match is None: + raise OrionParseError( + f'Malformed GROUP line: {line!r} (expected GROUP "")', + loc, + ) + new_group = GroupBlock(group_name=match.group("name"), loc=loc) + groups.append(new_group) + return new_group.events + if first == "SCHEDULE": if line != "SCHEDULE": raise OrionParseError( @@ -435,6 +493,19 @@ def _parse_line( ) return schedule_events + if first == "REPORT": + match = _REPORT_RE.match(line) + if match is None: + raise OrionParseError( + f"Malformed REPORT line: {line!r} " + "(expected REPORT [+|- ...])", + loc, + ) + report_dates.append( + _eval_date_expr(match.group("base"), match.group("terms"), variables, loc) + ) + return current_events + if first == "DATE": match = _DATE_DECL_RE.match(line) if match is None: @@ -782,7 +853,13 @@ def _parse_attributes(attr_str: str, loc: SourceLoc) -> Dict[str, AttrValue]: def _infer_value(raw: str) -> AttrScalar: - """Infer int, then float, otherwise keep the raw string.""" + """Infer bool, int, then float; otherwise keep the raw string.""" + upper = raw.upper() + if upper == "TRUE": + return True + if upper == "FALSE": + return False + try: return int(raw) except ValueError: @@ -805,6 +882,7 @@ class ApplyReport: events_applied: int = 0 events_skipped: int = 0 + report_dates: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list) errors: List[str] = field(default_factory=list) @@ -813,11 +891,11 @@ class ApplyReport: _POLICIES = ("warn", "error", "skip") # Attributes accepted on a keyword event but intentionally not emitted. -_IGNORED_KEYWORD_ATTRS = {"DSHIFT", "FILTER", "PERFID"} +_IGNORED_KEYWORD_ATTRS = {"FILTER"} # Completion event attribute handling: (required, known-optional) per type. -# FILTER is applied on PERFORATION events; FILTER/PERFID are accepted on the -# other completion events but ignored with a warning. +# 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") @@ -832,7 +910,7 @@ class ApplyReport: } _STATE_REQUIRED = ("STATE",) _STATE_KNOWN = {"STATE"} -_COMPLETION_IGNORED = {"FILTER", "PERFID"} +_COMPLETION_IGNORED = {"FILTER"} _PERF_IGNORED = _COMPLETION_IGNORED # backwards-compatible alias # ORIONEVENTS -> Eclipse item-name translations per keyword. @@ -890,11 +968,16 @@ def apply_orion_document( event types are passed through as generic Eclipse keywords. Returns: - ApplyReport: counts plus collected warnings/errors. + ApplyReport: counts plus collected warnings/errors. ``REPORT`` dates + from the document are returned as sorted, deduplicated ISO strings + on ``report_dates`` — they do not create timeline events; pass them + to ``timeline.generate_schedule_text(additional_dates=...)`` to + emit them as DATES keywords. """ _validate_policy(on_unknown_well, "on_unknown_well") _validate_policy(on_unknown_event, "on_unknown_event") report = ApplyReport() + report.report_dates = sorted({d.isoformat() for d in document.report_dates}) ctx = _prepare_filter_context(document, project, case) @@ -928,6 +1011,10 @@ def apply_orion_document( dispatch = _apply_generic_well_keyword dispatch(event, well_path, timeline, report, ctx) + for group in document.groups: + for event in group.events: + _apply_schedule_event(event, timeline, report, group.group_name) + for event in document.schedule_events: _apply_schedule_event(event, timeline, report) @@ -1065,14 +1152,17 @@ def _suspected_typo(event_type: str) -> Optional[str]: def _apply_schedule_event( - event: OrionEvent, timeline: Any, report: ApplyReport + event: OrionEvent, + timeline: Any, + report: ApplyReport, + group_name: Optional[str] = None, ) -> None: - """Apply one SCHEDULE-block event as a schedule-level Eclipse keyword.""" + """Apply one GROUP- or SCHEDULE-block event as an Eclipse keyword.""" event_type = event.event_type.upper() if event_type in _COMPLETION_EVENT_TYPES: report.errors.append( f"Line {event.loc.line}: {event_type} is a completion event and " - "needs a WELL block, not SCHEDULE" + "needs a WELL block, not GROUP or SCHEDULE" ) report.events_skipped += 1 return @@ -1086,6 +1176,8 @@ def _apply_schedule_event( ) continue keyword_data[key] = attr.value + if group_name is not None: + keyword_data["GROUP"] = group_name timeline.add_keyword_event( event_date=_iso_event_date(event.event_date), @@ -1144,7 +1236,7 @@ def _apply_perforation( ctx: Optional[_FilterContext] = None, ) -> None: if not _check_completion_attrs( - event, "PERFORATION", _PERF_KNOWN, _PERF_REQUIRED, report, ignored={"PERFID"} + event, "PERFORATION", _PERF_KNOWN, _PERF_REQUIRED, report, ignored=set() ): return @@ -1391,6 +1483,7 @@ def _cli(argv: Optional[List[str]] = None) -> int: return 1 event_count = sum(len(well.events) for well in document.wells) + group_event_count = sum(len(group.events) for group in document.groups) print( f"{args.file}: OK (ORIONEVENTS {document.version}, " f"units {document.unit_system})" @@ -1398,7 +1491,9 @@ def _cli(argv: Optional[List[str]] = None) -> int: print( f" {len(document.variables)} variable(s), {len(document.wells)} " f"well block(s), {event_count} well event(s), " - f"{len(document.schedule_events)} schedule event(s)" + f"{len(document.groups)} group block(s), {group_event_count} group event(s), " + f"{len(document.schedule_events)} schedule event(s), " + f"{len(document.report_dates)} report date(s)" ) for warning in document.warnings: print(f" Warning line {warning.loc.line}: {warning.message}") diff --git a/GrpcInterface/Python/rips/tests/test_orion_events.py b/GrpcInterface/Python/rips/tests/test_orion_events.py index 0e2885340e8..f2a4bf6fdb7 100644 --- a/GrpcInterface/Python/rips/tests/test_orion_events.py +++ b/GrpcInterface/Python/rips/tests/test_orion_events.py @@ -227,11 +227,6 @@ 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_perfid_attribute_parses(self): - text = 'ORIONEVENTS 2.0\nWELL "W"\n @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n' - doc = parse_orion_events(text) - assert doc.wells[0].events[0].attributes["PERFID"].value == "Valysar" - def test_trailing_comment_ignored_but_not_inside_quotes(self): text = ( 'ORIONEVENTS 2.0\nWELL "W"\n' @@ -388,7 +383,7 @@ def test_schedule_block_parses(self): " @2024-01-01 WCONHIST STATUS=OPEN\n" "SCHEDULE\n" " @2024-01-01 RPTRST BASIC=2 FREQ=1\n" - " @2024-01-01 GRUPTREE CHILD=OP PARENT=FIELD\n" + " @2024-01-01 GRUPTREE CHILD_GROUP=OP PARENT_GROUP=FIELD\n" 'WELL "W"\n' " @2024-02-01 WELTARG CMODE=ORAT VALUE=5000\n" ) @@ -398,10 +393,93 @@ def test_schedule_block_parses(self): # WELL after SCHEDULE switches the sink back to the well block. assert [len(w.events) for w in doc.wells] == [1, 1] + def test_group_blocks_parse_and_switch_event_sink(self): + text = ( + 'ORIONEVENTS 2.0\nGROUP "OP"\n' + " @2020-07-01 GEFAC FACTOR=1.0 TRANSFER=YES\n" + " @2020-07-01 GCONPROD CMODE=LRAT LRAT=20000\n" + 'GROUP "WI"\n' + " @2020-07-01 GCONINJE TYPE=WATER CMODE=RATE RATE=16000\n" + "SCHEDULE\n" + " @2020-07-01 RPTRST BASIC=2\n" + ) + doc = parse_orion_events(text) + assert [group.group_name for group in doc.groups] == ["OP", "WI"] + assert [event.event_type for event in doc.groups[0].events] == [ + "GEFAC", + "GCONPROD", + ] + assert [event.event_type for event in doc.groups[1].events] == ["GCONINJE"] + assert [event.event_type for event in doc.schedule_events] == ["RPTRST"] + + def test_empty_group_block_ok(self): + doc = parse_orion_events('ORIONEVENTS 2.0\nGROUP "OP"\n') + assert doc.groups[0].group_name == "OP" + assert doc.groups[0].events == [] + + def test_malformed_group_line_rejected(self): + with pytest.raises(OrionParseError, match="Malformed GROUP line"): + parse_orion_events("ORIONEVENTS 2.0\nGROUP OP\n") + + def test_boolean_attributes_are_typed_unless_quoted(self): + text = ( + "ORIONEVENTS 2.0\n" + "SCHEDULE\n" + ' @2024-01-01 RPTRST DEN=True ROCKC=FALSE LABEL="True"\n' + ) + attributes = parse_orion_events(text).schedule_events[0].attributes + + assert attributes["DEN"].value is True + assert attributes["ROCKC"].value is False + assert attributes["LABEL"].value == "True" + assert isinstance(attributes["LABEL"].value, str) + def test_schedule_line_with_arguments_rejected(self): with pytest.raises(OrionParseError, match="SCHEDULE takes no arguments"): parse_orion_events("ORIONEVENTS 2.0\nSCHEDULE NOW\n") + def test_report_lines_parse(self): + text = ( + "ORIONEVENTS 2.0\n" + "DATE START = 2024-01-01\n" + "REPORT 2024-06-01\n" + "REPORT START + 31\n" + ) + doc = parse_orion_events(text) + assert doc.report_dates == [ + datetime.date(2024, 6, 1), + datetime.date(2024, 2, 1), + ] + + def test_report_keeps_duplicates_and_file_order(self): + text = "ORIONEVENTS 2.0\nREPORT 2024-06-01\nREPORT 2024-06-01\n" + doc = parse_orion_events(text) + assert doc.report_dates == [datetime.date(2024, 6, 1)] * 2 + + def test_report_inside_block_does_not_close_it(self): + text = ( + 'ORIONEVENTS 2.0\nWELL "W"\n' + " @2024-01-01 WCONHIST STATUS=OPEN\n" + "REPORT 2024-06-01\n" + " @2024-02-01 WELTARG CMODE=ORAT VALUE=5000\n" + ) + doc = parse_orion_events(text) + assert [len(w.events) for w in doc.wells] == [2] + assert doc.report_dates == [datetime.date(2024, 6, 1)] + + def test_report_with_undeclared_variable_raises(self): + with pytest.raises(OrionParseError, match="NOPE"): + parse_orion_events("ORIONEVENTS 2.0\nREPORT NOPE + 1\n") + + def test_malformed_report_line_raises(self): + with pytest.raises(OrionParseError, match="Malformed REPORT line"): + parse_orion_events("ORIONEVENTS 2.0\nREPORT\n") + + def test_report_with_datetime_literal(self): + text = "ORIONEVENTS 2.0\nREPORT 2024-06-01T14:45:30.500\n" + doc = parse_orion_events(text) + assert doc.report_dates == [datetime.datetime(2024, 6, 1, 14, 45, 30, 500000)] + def test_datetime_literal_event(self): text = ( 'ORIONEVENTS 2.0\nWELL "W"\n' @@ -710,26 +788,45 @@ def test_weltarg_value_translation(self): assert data["NEW_VALUE"] == 50 # VALUE -> NEW_VALUE assert data["CMODE"] == "BHP" - def test_dshift_is_ignored_with_warning(self): + def test_keyword_attributes_forward_without_special_casing(self): + # DSHIFT is not part of the format; it forwards unchanged like any + # other attribute instead of being stripped. text = ( 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' " @2018-01-01 WCONHIST STATUS=OPEN CMODE=ORAT DSHIFT=10\n" ) timeline, report = self._apply(text) data = timeline.keyword_calls[0]["keyword_data"] - assert "DSHIFT" not in data - # Event date is NOT shifted. + assert data["DSHIFT"] == 10 assert timeline.keyword_calls[0]["event_date"] == "2018-01-01" - assert any("DSHIFT" in w for w in report.warnings) + assert not report.warnings - def test_perfid_on_perforation_warns_and_applies(self): + def test_report_dates_on_apply_report(self): text = ( 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' - " @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n" + " @2018-01-01 WCONHIST STATUS=OPEN\n" + "REPORT 2018-07-01\n" + "REPORT 2018-03-01\n" + "REPORT 2018-07-01\n" ) timeline, report = self._apply(text) + # Sorted, deduplicated ISO strings ready for + # generate_schedule_text(additional_dates=...). No timeline events. + assert report.report_dates == ["2018-03-01", "2018-07-01"] assert report.events_applied == 1 - assert any("PERFID" in w for w in report.warnings) + + def test_perfid_on_perforation_is_unknown_attribute_error(self): + # PERFID is not part of the format; it is rejected like any other + # unknown completion attribute. + text = ( + 'ORIONEVENTS 2.0\nWELL "55_33-A-1"\n' + " @2018-01-01 PERFORATION MDSTART=1 MDEND=2 PERFID=Valysar\n" + ) + timeline, report = self._apply(text) + assert report.events_applied == 0 + assert report.events_skipped == 1 + assert any("PERFID" in e for e in report.errors) + assert not timeline.perf_calls def test_filter_on_keyword_event_warns_and_applies(self): text = ( @@ -850,6 +947,29 @@ 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_group_events_inject_group_name(self): + text = ( + 'ORIONEVENTS 2.0\nGROUP "OP"\n' + " @2020-07-01 GEFAC EFFICIENCY_FACTOR=1.0 USE_GEFAC_IN_NETWORK=YES\n" + " @2020-07-01 GCONPROD CONTROL_MODE=LRAT LIQUID_TARGET=20000 WATER_TARGET=20000 OIL_TARGET=20000\n" + 'GROUP "WI"\n' + " @2020-07-01 GCONINJE PHASE=WATER CONTROL_MODE=RATE SURFACE_TARGET=16000\n" + ) + timeline, report = self._apply(text) + assert report.events_applied == 3 + assert [call["keyword_name"] for call in timeline.schedule_keyword_calls] == [ + "GEFAC", + "GCONPROD", + "GCONINJE", + ] + assert timeline.schedule_keyword_calls[0]["keyword_data"] == { + "GROUP": "OP", + "EFFICIENCY_FACTOR": 1.0, + "USE_GEFAC_IN_NETWORK": "YES", + } + assert timeline.schedule_keyword_calls[1]["keyword_data"]["GROUP"] == "OP" + assert timeline.schedule_keyword_calls[2]["keyword_data"]["GROUP"] == "WI" + def test_completion_event_in_schedule_block_is_error(self): text = ( "ORIONEVENTS 2.0\nSCHEDULE\n @2024-01-01 PERFORATION MDSTART=1 MDEND=2\n" @@ -1036,6 +1156,88 @@ def project_with_case_and_wells(self, rips_instance, initialize_test): well_path_coll = project.descendants(rips.WellPathCollection)[0] return project, case, well_path_coll.event_timeline() + def test_compdat_invalid_item_names_report_valid_names( + self, project_with_case_and_wells + ): + """Invalid COMPDAT items produce an actionable error (issue #14535).""" + project, _case, timeline = project_with_case_and_wells + well = project.well_paths()[0] + document = parse_orion_events( + "ORIONEVENTS 2.0\n" + f'WELL "{well.name}"\n' + " @2024-01-01 COMPDAT STATUS=OPEN TRANSMISSIBILITY=1.0\n" + ) + + with pytest.raises(rips.RipsError) as exc_info: + apply_orion_document(document, timeline, project) + + error_msg = str(exc_info.value) + assert "Keyword 'COMPDAT' contains invalid item names" in error_msg + assert "STATUS, TRANSMISSIBILITY" in error_msg + assert ( + "Valid item names are: WELL, I, J, K1, K2, STATE, SAT_TABLE, " + "CONNECTION_TRANSMISSIBILITY_FACTOR, DIAMETER, Kh, SKIN, D_FACTOR, " + "DIR, PR" + ) in error_msg + + def test_rptrst_boolean_values_emit_bare_mnemonics( + self, project_with_case_and_wells + ): + project, case, timeline = project_with_case_and_wells + well = project.well_paths()[0] + document = parse_orion_events( + "ORIONEVENTS 2.0\n" + f'WELL "{well.name}"\n' + " @2024-01-01 WCONHIST STATUS=OPEN\n" + "SCHEDULE\n" + " @2024-01-01 RPTRST BASIC=2 DEN=True ROCKC=True RPORV=True " + "RFIP=True FLOWS=True FLORES=True NORST=False\n" + ) + + report = apply_orion_document(document, timeline, project) + assert report.events_applied == 2 + + schedule = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=[] + ) + rptrst_block = schedule.split("RPTRST", 1)[1].split("/", 1)[0] + tokens = rptrst_block.split() + + assert "BASIC=2" in tokens + for flag in ("DEN", "ROCKC", "RPORV", "RFIP", "FLOWS", "FLORES"): + assert flag in tokens + assert f"{flag}=True" not in rptrst_block + assert "NORST" not in tokens + + def test_group_sections_generate_group_keywords(self, project_with_case_and_wells): + project, case, timeline = project_with_case_and_wells + well = project.well_paths()[0] + document = parse_orion_events( + "ORIONEVENTS 2.0\n" + f'WELL "{well.name}"\n' + " @2020-07-01 WCONHIST STATUS=OPEN CMODE=ORAT\n" + 'GROUP "OP"\n' + " @2020-07-01 GEFAC EFFICIENCY_FACTOR=1.0 USE_GEFAC_IN_NETWORK=YES\n" + " @2020-07-01 GCONPROD CONTROL_MODE=LRAT LIQUID_TARGET=20000 " + "WATER_TARGET=20000 OIL_TARGET=20000\n" + 'GROUP "WI"\n' + " @2020-07-01 GCONINJE PHASE=WATER CONTROL_MODE=RATE " + "SURFACE_TARGET=16000\n" + ) + + report = apply_orion_document(document, timeline, project) + assert report.errors == [] + assert report.events_applied == 4 + + schedule = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=[] + ) + assert "GEFAC" in schedule + assert "GCONPROD" in schedule + assert "GCONINJE" in schedule + assert "'OP'" in schedule + assert "'WI'" in schedule + def test_apply_creates_perforations_and_schedule(self, project_with_case_and_wells): """End-to-end: parse -> apply -> set_timestamp -> generate schedule.""" project, case, timeline = project_with_case_and_wells @@ -1051,11 +1253,13 @@ def test_apply_creates_perforations_and_schedule(self, project_with_case_and_wel " @START PERFORATION MDSTART=2000 MDEND=2200 RADIUS=0.05 SKIN=0.5 COMPLETION_NUMBER=1\n" " @START + RAMP WCONHIST STATUS=OPEN CMODE=ORAT VFP=1\n" " @START + RAMP WELTARG CMODE=BHP VALUE=50\n" + "REPORT 2024-07-01\n" ) document = parse_orion_events(text) report = apply_orion_document(document, timeline, project) assert report.errors == [] assert report.events_applied == 3 + assert report.report_dates == ["2024-07-01"] # Materialize completions from the perforation event. timeline.set_timestamp(timestamp="2024-01-15") @@ -1066,13 +1270,19 @@ def test_apply_creates_perforations_and_schedule(self, project_with_case_and_wel assert abs(perf.start_measured_depth - 2000.0) < 1.0 assert abs(perf.end_measured_depth - 2200.0) < 1.0 - # The generated schedule should carry the mapped keywords. + # The generated schedule should carry the mapped keywords, and the + # REPORT date should appear as a bare DATES entry (issue #14514). schedule = timeline.generate_schedule_text( - eclipse_case=case, export_msw_for_wells=[] + eclipse_case=case, + export_msw_for_wells=[], + additional_dates=report.report_dates, ) assert "COMPDAT" in schedule assert "WCONHIST" in schedule assert "WELTARG" in schedule + assert "1 'JUL' 2024" in schedule, ( + "REPORT date should be emitted as a DATES entry" + ) def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_wells): """All event kinds from well_event_schedule.py expressed as ORIONEVENTS.""" @@ -1095,7 +1305,7 @@ def test_apply_full_event_coverage_and_schedule(self, project_with_case_and_well " @2024-06-01 WRFTPLT OUTPUT_RFT=YES OUTPUT_PLT=NO OUTPUT_SEGMENT=NO\n" "SCHEDULE\n" " @STARTUP RPTRST BASIC=2 FREQ=1\n" - " @STARTUP GRUPTREE CHILD=OP PARENT=FIELD\n" + " @STARTUP GRUPTREE CHILD_GROUP=OP PARENT_GROUP=FIELD\n" " @STARTUP TUNING TSINIT=1 TSMAXZ=30 NEWTMX=12\n" ) document = parse_orion_events(text) diff --git a/GrpcInterface/Python/rips/tests/test_well_events.py b/GrpcInterface/Python/rips/tests/test_well_events.py index 196feb0556a..a0102715347 100644 --- a/GrpcInterface/Python/rips/tests/test_well_events.py +++ b/GrpcInterface/Python/rips/tests/test_well_events.py @@ -437,6 +437,153 @@ def test_generate_schedule_multiple_dates(self, project_with_case_and_well): assert "DATES" in schedule_text, "Schedule should contain DATES keyword" assert "2024" in schedule_text, "Schedule should contain event dates" + def test_generate_schedule_with_additional_dates(self, project_with_case_and_well): + """Additional dates become bare DATES keywords, merged chronologically (issue #14514).""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.add_perf_event( + event_date="2024-03-01", + well_path=well_path_a, + start_md=2000.0, + end_md=2200.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + additional_dates=["2024-02-01", "2024-06-01"], + ) + + assert "1 'FEB' 2024" in schedule_text, ( + "Additional date 2024-02-01 should be emitted as a DATES entry" + ) + assert "1 'JUN' 2024" in schedule_text, ( + "Additional date 2024-06-01 should be emitted as a DATES entry" + ) + # Dates must appear in chronological order: JAN (event), FEB, MAR (event), JUN + positions = [ + schedule_text.index(date_str) + for date_str in [ + "1 'JAN' 2024", + "1 'FEB' 2024", + "1 'MAR' 2024", + "1 'JUN' 2024", + ] + ] + assert positions == sorted(positions), ( + f"Dates should appear chronologically, got positions {positions}" + ) + + def test_additional_dates_deduplicated_and_merged(self, project_with_case_and_well): + """An additional date equal to an event date must not produce a duplicate DATES entry.""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + additional_dates=["2024-01-01", "2024-01-01"], + ) + + assert schedule_text.count("1 'JAN' 2024") == 1, ( + "Duplicate additional dates should be merged with the event date" + ) + + def test_additional_dates_invalid_format(self, project_with_case_and_well): + """An unparsable additional date must raise an error mentioning the format.""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + with pytest.raises(rips.RipsError) as exc_info: + timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + additional_dates=["not-a-date"], + ) + assert "Invalid date format" in str(exc_info.value) + + def test_additional_date_before_first_event(self, project_with_case_and_well): + """An additional date earlier than all events becomes the first date of the schedule.""" + project, case, timeline = project_with_case_and_well + + well_paths = project.well_paths() + well_path_a = [wp for wp in well_paths if "A" in wp.name][0] + + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path_a, + start_md=1800.0, + end_md=2000.0, + diameter=0.1, + state="OPEN", + ) + timeline.set_timestamp(timestamp="2024-12-31") + + # Default first_date_as_comment=True: the earliest date (the additional + # one) becomes the comment; the event date is a real DATES entry. + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + additional_dates=["2023-06-01"], + ) + assert "-- Date: 1 JUN 2023" in schedule_text, ( + "Earliest (additional) date should be emitted as a comment by default" + ) + assert "1 'JAN' 2024" in schedule_text, ( + "Event date should be a DATES entry when it is no longer first" + ) + + # With first_date_as_comment=False every date is a DATES entry. + schedule_text = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + additional_dates=["2023-06-01"], + ) + assert "1 'JUN' 2023" in schedule_text, ( + "Additional date should be a DATES entry with first_date_as_comment=False" + ) + def test_first_date_as_comment(self, project_with_case_and_well): """Test that the first (earliest) date can be emitted as a comment. @@ -2303,8 +2450,8 @@ def test_add_keyword_event_gruptree(self, project_with_case_and_well): event_date="2024-01-01", keyword_name="GRUPTREE", keyword_data={ - "CHILD": "OP", - "PARENT": "FIELD", + "CHILD_GROUP": "OP", + "PARENT_GROUP": "FIELD", }, ) diff --git a/GrpcInterface/Python/rips/well_events.py b/GrpcInterface/Python/rips/well_events.py index 776d0afa8ad..cb9d57fcff6 100644 --- a/GrpcInterface/Python/rips/well_events.py +++ b/GrpcInterface/Python/rips/well_events.py @@ -218,8 +218,8 @@ def add_keyword_event( event_date="2024-01-01", keyword_name="GRUPTREE", keyword_data={ - "CHILD": "OP", - "PARENT": "FIELD", + "CHILD_GROUP": "OP", + "PARENT_GROUP": "FIELD", } ) @@ -290,6 +290,7 @@ def generate_schedule_text( export_msw_for_wells: List[WellPath] = [], first_date_as_comment: bool = True, align_columns: bool = False, + additional_dates: List[str] = [], ) -> str: """Generate Eclipse schedule text for all wells in the collection. @@ -314,6 +315,15 @@ def generate_schedule_text( align_columns (bool): When True, emit each keyword with a "--"-prefixed column-header comment and right-aligned, fixed-width columns instead of the compact default form. Defaults to False. + additional_dates (List[str]): Additional dates ("YYYY-MM-DD" or a full + ISO timestamp such as "2024-05-15T14:45:30") emitted as DATES + keywords even when no events fall on them. In Eclipse/Flow a DATES + entry ensures a summary report at that date. The dates are merged, + deduplicated and sorted together with the event dates, and are not + filtered by set_timestamp(). If an additional date precedes all + event dates it becomes the earliest date and is therefore emitted + as a comment when first_date_as_comment is True; pass + first_date_as_comment=False to emit every date as a DATES keyword. Returns: str: Eclipse schedule text containing DATES, COMPDAT, WELSEGS, WCONPROD, etc. @@ -358,6 +368,7 @@ def generate_schedule_text( export_msw_for_wells=export_msw_for_wells, first_date_as_comment=first_date_as_comment, align_columns=align_columns, + additional_dates=additional_dates, ) if container and container.values: return "".join(container.values) diff --git a/docs/orionEvents.md b/docs/orionEvents.md index 4005d5ef9f5..e395e37ac66 100644 --- a/docs/orionEvents.md +++ b/docs/orionEvents.md @@ -183,7 +183,7 @@ Event lines are `@ KEY=VALUE ...`. Inside a WELL block, |---|---|---| | `STATE` | yes | `well_state`: `OPEN`, `SHUT` or `STOP` | -Unknown attributes on a completion event are an error and skip the event. `PERFID` is accepted but ignored with a warning (reserved for future use); on completion events other than `PERFORATION`, `FILTER` is likewise ignored with a warning. +Unknown attributes on a completion event are an error and skip the event. On completion events other than `PERFORATION`, `FILTER` is ignored with a warning. ### Well keyword events @@ -194,7 +194,7 @@ Any other event type in a WELL block is passed through as an Eclipse well keywor | `WCONHIST` | `VFP` → `VFP_TABLE` | | `WELTARG` | `VALUE` → `NEW_VALUE` | -All other keywords (`WRFTPLT`, `WCONPROD`, `WELOPEN`, ...) forward attributes unchanged, so attribute keys must match the Eclipse item names ResInsight uses for that keyword. `DSHIFT`, `FILTER` and `PERFID` are ignored with a warning. +All other keywords (`WRFTPLT`, `WCONPROD`, `WELOPEN`, ...) forward attributes unchanged, so attribute keys must match the Eclipse item names ResInsight uses for that keyword. `FILTER` is ignored with a warning. An event type that closely resembles a misspelled built-in (e.g. `PERFORATIN`) is **not** passed through as a keyword; it is handled by the `on_unknown_event` policy with a "did you mean" hint.