From 4b2c74867a39c3790679dc43fa991572b822ae05 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 29 May 2026 17:04:11 +0200 Subject: [PATCH] Well Event: Add opt-in column-aligned schedule output Add an align_columns option to schedule generation. When enabled, each keyword is serialized with a '--'-prefixed column-header comment and data rows right-aligned into fixed-width columns, instead of the compact default form. The default output is unchanged. A new RimKeywordFactory::deckKeywordToAlignedString introspects the OPM deck model and renders each item in its own column (consecutive defaults are not collapsed into 'N*'). The flag is threaded through RicScheduleDataGenerator and exposed on the GenerateSchedule scriptable method as align_columns in the Python generate_schedule_text wrapper. --- .../RicScheduleDataGenerator.cpp | 16 +- .../RicScheduleDataGenerator.h | 8 +- .../Jobs/RimKeywordFactory.cpp | 169 ++++++++++++++++++ .../ProjectDataModel/Jobs/RimKeywordFactory.h | 6 + .../RimcWellEventTimeline.cpp | 15 +- .../RimcWellEventTimeline.h | 1 + .../well_event_schedule.py | 22 ++- .../Python/rips/tests/test_well_events.py | 88 +++++++++ GrpcInterface/Python/rips/well_events.py | 5 + 9 files changed, 317 insertions(+), 13 deletions(-) diff --git a/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.cpp b/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.cpp index f124a199ebd..b4368c94081 100644 --- a/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.cpp +++ b/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.cpp @@ -49,7 +49,8 @@ QString RicScheduleDataGenerator::generateSchedule( const RimWellEventTimeline& const std::vector& wellPaths, const std::vector& dates, const std::set& mswWells, - bool firstDateAsComment ) + bool firstDateAsComment, + bool alignColumns ) { QString result; @@ -76,7 +77,8 @@ QString RicScheduleDataGenerator::generateSchedule( const RimWellEventTimeline& bool isFirstDate = true; for ( const auto& date : dates ) { - QString dateSection = generateDateSection( timeline, eclipseCase, sortedWellPaths, date, mswWells, isFirstDate && firstDateAsComment ); + QString dateSection = + generateDateSection( timeline, eclipseCase, sortedWellPaths, date, mswWells, isFirstDate && firstDateAsComment, alignColumns ); if ( !dateSection.isEmpty() ) { result += dateSection; @@ -138,7 +140,8 @@ QString RicScheduleDataGenerator::generateDateSection( const RimWellEventTimelin const std::vector& wellPaths, const QDateTime& date, const std::set& mswWells, - bool dateAsComment ) + bool dateAsComment, + bool alignColumns ) { // Keyword priority order for output static const std::vector keywordOrder = { "WELSPECS", @@ -161,6 +164,9 @@ QString RicScheduleDataGenerator::generateDateSection( const RimWellEventTimelin QString result; + auto serializeKeyword = [&]( const Opm::DeckKeyword& kw ) + { return alignColumns ? RimKeywordFactory::deckKeywordToAlignedString( kw ) : RimKeywordFactory::deckKeywordToString( kw ); }; + // Generate DATES keyword, or a date comment when requested (e.g. for the first date, which // equals the simulation start date and is rejected as a DATES entry by some simulators). if ( dateAsComment ) @@ -171,7 +177,7 @@ QString RicScheduleDataGenerator::generateDateSection( const RimWellEventTimelin } else { - result += RimKeywordFactory::deckKeywordToString( RimKeywordFactory::datesKeyword( date ) ) + "\n"; + result += serializeKeyword( RimKeywordFactory::datesKeyword( date ) ) + "\n"; } // Records for each keyword name are accumulated across wells, then serialised once below. @@ -213,7 +219,7 @@ QString RicScheduleDataGenerator::generateDateSection( const RimWellEventTimelin auto appendKeywordText = [&]( const Opm::DeckKeyword& kw ) { if ( kw.size() == 0 && !kw.isDataKeyword() ) return; - result += RimKeywordFactory::deckKeywordToString( kw ); + result += serializeKeyword( kw ); result += "\n"; }; diff --git a/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.h b/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.h index 643af707ae2..71b2f719c1f 100644 --- a/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.h +++ b/ApplicationLibCode/Commands/CompletionExportCommands/RicScheduleDataGenerator.h @@ -51,12 +51,15 @@ class RicScheduleDataGenerator // When firstDateAsComment is true, the first (earliest) date is written as a comment line // instead of a DATES keyword, since some commercial simulators reject a DATES entry that // equals the simulation start date. Later dates are always emitted as DATES keywords. + // When alignColumns is true, keywords are serialised with a "--"-prefixed column-header comment + // and right-aligned, fixed-width columns instead of the compact default form. static QString generateSchedule( const RimWellEventTimeline& timeline, RimEclipseCase& eclipseCase, const std::vector& wellPaths, const std::vector& dates, const std::set& mswWells, - bool firstDateAsComment = true ); + bool firstDateAsComment = true, + bool alignColumns = false ); // Collect all unique dates from all wells' timelines static std::vector collectAllDates( const RimWellEventTimeline& timeline, const std::vector& wellPaths ); @@ -69,7 +72,8 @@ class RicScheduleDataGenerator const std::vector& wellPaths, const QDateTime& date, const std::set& mswWells, - bool dateAsComment = false ); + bool dateAsComment = false, + bool alignColumns = false ); static std::optional generateWelspecsForWell( const RimWellEventTimeline& timeline, RimEclipseCase& eclipseCase, RimWellPath& well, const QDateTime& date ); diff --git a/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.cpp b/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.cpp index 31ea55dde58..30ad1df04e8 100644 --- a/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.cpp +++ b/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.cpp @@ -42,6 +42,8 @@ #include "opm/input/eclipse/Deck/DeckKeyword.hpp" #include "opm/input/eclipse/Deck/DeckOutput.hpp" #include "opm/input/eclipse/Deck/DeckRecord.hpp" +#include "opm/input/eclipse/Parser/Parser.hpp" +#include "opm/input/eclipse/Parser/ParserKeyword.hpp" #include "opm/input/eclipse/Parser/ParserKeywords/B.hpp" #include "opm/input/eclipse/Parser/ParserKeywords/C.hpp" #include "opm/input/eclipse/Parser/ParserKeywords/D.hpp" @@ -768,4 +770,171 @@ QString deckKeywordToString( const Opm::DeckKeyword& keyword ) return QString::fromStdString( oss.str() ); } +namespace +{ + //-------------------------------------------------------------------------------------------------- + /// Render a single DeckItem to text the same way OPM's DeckOutput does (quoted strings, raw strings + /// unquoted, integers, doubles at precision 10), except defaults always render as "1*" per value + /// rather than being accumulated into "N*". Multi-value items are space-joined into one column. + //-------------------------------------------------------------------------------------------------- + std::string renderDeckItemValue( const Opm::DeckItem& item ) + { + const size_t numValues = item.data_size(); + if ( numValues == 0 ) return "1*"; + + auto renderOne = [&]( size_t i ) -> std::string + { + if ( item.defaultApplied( i ) ) return "1*"; + + switch ( item.getType() ) + { + case Opm::type_tag::integer: + return std::to_string( item.get( i ) ); + case Opm::type_tag::fdouble: + { + std::ostringstream oss; + oss.precision( 10 ); + oss << item.get( i ); + return oss.str(); + } + case Opm::type_tag::string: + return "'" + item.get( i ) + "'"; + case Opm::type_tag::raw_string: + return std::string( item.get( i ) ); + case Opm::type_tag::uda: + { + const Opm::UDAValue& uda = item.getData()[i]; + if ( uda.is() ) + { + std::ostringstream oss; + oss.precision( 10 ); + oss << uda.get(); + return oss.str(); + } + return "'" + uda.get() + "'"; + } + default: + return "1*"; + } + }; + + std::string result; + for ( size_t i = 0; i < numValues; ++i ) + { + if ( i > 0 ) result += " "; + result += renderOne( i ); + } + return result; + } +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString deckKeywordToAlignedString( const Opm::DeckKeyword& keyword ) +{ + if ( keyword.name().empty() ) return {}; + + std::ostringstream oss; + oss << keyword.name() << "\n"; + + auto rightAlign = []( const std::string& s, size_t width ) { return std::string( width - s.size(), ' ' ) + s; }; + + // Emit the keyword as one or more groups of consecutive records that share the same ordered list + // of item names. Each group gets its own aligned column header. Tabular keywords (WCONHIST, + // COMPDAT, ...) form a single group; heterogeneous keywords (WELSEGS = header + segment records, + // TUNING = three distinct records) form one group per record shape. + const size_t numRecords = keyword.size(); + size_t recordIndex = 0; + while ( recordIndex < numRecords ) + { + // Group signature = item names of the first record in the group. + std::vector header; + for ( const auto& item : keyword.getRecord( recordIndex ) ) + { + header.push_back( item.name() ); + } + + // Collect every consecutive record matching this signature, rendering its values. + std::vector> rows; + size_t groupEnd = recordIndex; + for ( ; groupEnd < numRecords; ++groupEnd ) + { + const Opm::DeckRecord& record = keyword.getRecord( groupEnd ); + if ( record.size() != header.size() ) break; + + bool sameNames = true; + size_t col = 0; + for ( const auto& item : record ) + { + if ( item.name() != header[col] ) + { + sameNames = false; + break; + } + ++col; + } + if ( !sameNames ) break; + + std::vector row; + row.reserve( record.size() ); + for ( const auto& item : record ) + { + row.push_back( renderDeckItemValue( item ) ); + } + rows.push_back( std::move( row ) ); + } + + const size_t numCols = header.size(); + std::vector width( numCols, 0 ); + for ( size_t c = 0; c < numCols; ++c ) + width[c] = header[c].size(); + for ( const auto& row : rows ) + for ( size_t c = 0; c < numCols; ++c ) + width[c] = std::max( width[c], row[c].size() ); + + // Header comment line: "--" occupies the same two columns as the data-row indent so the + // header names line up with the values below them. + if ( numCols > 0 ) + { + oss << "--"; + for ( size_t c = 0; c < numCols; ++c ) + { + if ( c > 0 ) oss << " "; + oss << rightAlign( header[c], width[c] ); + } + oss << "\n"; + } + + for ( const auto& row : rows ) + { + oss << " "; + for ( size_t c = 0; c < numCols; ++c ) + { + if ( c > 0 ) oss << " "; + oss << rightAlign( row[c], width[c] ); + } + oss << " /\n"; + } + + recordIndex = groupEnd; + } + + // Emit the keyword-terminating slash for variable-size keywords, matching OPM + // (DeckKeyword::m_slashTerminated == !ParserKeyword::hasFixedSize()). + bool slashTerminated = true; + try + { + Opm::Parser parser; + slashTerminated = !parser.getKeyword( keyword.name() ).hasFixedSize(); + } + catch ( ... ) + { + slashTerminated = true; + } + if ( slashTerminated ) oss << "/\n"; + + return QString::fromStdString( oss.str() ); +} + } // namespace RimKeywordFactory diff --git a/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.h b/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.h index c9446f7cd54..94e5441cd38 100644 --- a/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.h +++ b/ApplicationLibCode/ProjectDataModel/Jobs/RimKeywordFactory.h @@ -81,4 +81,10 @@ Opm::DeckKeyword editnncKeyword( const std::vector RimcWellEventTimeline_generateSche std::set mswWells( mswWellPaths.begin(), mswWellPaths.end() ); QString scheduleText = - RicScheduleDataGenerator::generateSchedule( *timeline, *eclipseCase, wellPathsWithEvents, dates, mswWells, m_firstDateAsComment() ); + RicScheduleDataGenerator::generateSchedule( *timeline, + *eclipseCase, + wellPathsWithEvents, + dates, + mswWells, + m_firstDateAsComment(), + m_alignColumns() ); // Return the schedule text in a data container auto* dataObject = new RimcDataContainerString(); diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h index e682747341b..19772bae0ed 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcWellEventTimeline.h @@ -230,4 +230,5 @@ class RimcWellEventTimeline_generateSchedule : public caf::PdmObjectMethod caf::PdmPtrField m_eclipseCase; caf::PdmPtrArrayField m_exportMswForWells; caf::PdmField m_firstDateAsComment; + caf::PdmField m_alignColumns; }; 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 0281e41880f..a2c9796a2a7 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 @@ -237,6 +237,13 @@ def main(): eclipse_case=case, export_msw_for_wells=[well_path] ) + # Generate the same schedule with align_columns=True, which adds a "--"-prefixed + # column-header comment per keyword and right-aligns the data into fixed-width + # columns. Only the formatting differs from the unaligned text above. + schedule_text_aligned = timeline.generate_schedule_text( + eclipse_case=case, export_msw_for_wells=[well_path], align_columns=True + ) + if schedule_text: print(f"\n Generated schedule text ({len(schedule_text)} characters)") print(" " + "=" * 60) @@ -308,15 +315,20 @@ def main(): print(f" - GRUPTREE entries: {schedule_text.count('GRUPTREE')}") print(f" - TUNING entries: {schedule_text.count('TUNING')}") - # Save to file - output_file = "generated_schedule.sch" - with open(output_file, "w") as f: + # Save both formats to file + unaligned_file = "generate_schedule_unaligned.sch" + with open(unaligned_file, "w") as f: f.write(schedule_text) - print(f"\n Schedule text saved to: {output_file}") + print(f"\n Unaligned schedule text saved to: {unaligned_file}") + + aligned_file = "generate_schedule_aligned.sch" + with open(aligned_file, "w") as f: + f.write(schedule_text_aligned) + print(f" Aligned schedule text saved to: {aligned_file}") # Show example of generated keywords print("\n8. Example of generated Eclipse keywords:") - print(" (See generated_schedule.sch for complete output)") + print(f" (See {unaligned_file} / {aligned_file} for complete output)") if "WELSEGS" in schedule_text: print("\n Sample WELSEGS segment:") for line in lines: diff --git a/GrpcInterface/Python/rips/tests/test_well_events.py b/GrpcInterface/Python/rips/tests/test_well_events.py index fcb63a6d9e6..5d7211ed9ef 100644 --- a/GrpcInterface/Python/rips/tests/test_well_events.py +++ b/GrpcInterface/Python/rips/tests/test_well_events.py @@ -1262,6 +1262,94 @@ def test_schedule_contains_compdat_keyword(self, project_with_case_and_well): assert "COMPDAT" in schedule_text + def test_align_columns_adds_headers_and_alignment(self, project_with_case_and_well): + """align_columns=True must add a '--'-prefixed column-header comment per keyword and + indent right-aligned data rows, while the default (align_columns=False) keeps the + compact form. Only the formatting should differ between the two.""" + project, case, timeline = project_with_case_and_well + well_path = project.well_paths()[0] + + # A perforation (COMPDAT) plus a WCONHIST keyword event gives several tabular keywords + # plus the always-present DATES keyword to exercise the aligned formatter. + timeline.add_perf_event( + event_date="2024-01-01", + well_path=well_path, + start_md=2000.0, + end_md=2200.0, + diameter=0.1, + state="OPEN", + ) + timeline.add_well_keyword_event( + event_date="2024-01-01", + well_path=well_path, + keyword_name="WCONHIST", + keyword_data={ + "WELL": well_path.name, + "STATUS": "OPEN", + "CMODE": "RESV", + "ORAT": 3999.99, + "VFP_TABLE": 1, + }, + ) + + timeline.set_timestamp(timestamp="2024-01-01") + + # Force the first date to a DATES keyword (instead of the default leading comment) so the + # aligned DATES column header is exercised. + aligned = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + align_columns=True, + ) + default = timeline.generate_schedule_text( + eclipse_case=case, + export_msw_for_wells=project.well_paths(), + first_date_as_comment=False, + align_columns=False, + ) + + print(f"\nAligned schedule text:\n{aligned}") + print(f"\nDefault schedule text:\n{default}") + + # The DATES keyword is always present; aligned output prefixes its item names as a comment. + assert "--DAY" in aligned, "Aligned output should carry a DATES column header" + assert "--DAY" not in default, "Default output should not carry column headers" + + # WCONHIST item names appear only as a header comment in the aligned form (their values, + # e.g. 'OPEN'/'RESV', are what show up in both forms). + assert "CMODE" in aligned and "STATUS" in aligned, ( + "Aligned output should list WCONHIST item names in a header comment" + ) + assert "CMODE" not in default and "STATUS" not in default, ( + "Default output should not list item names" + ) + + # Each aligned column-header line starts with '--' and its data rows are indented two + # spaces and terminate with ' /'. + wconhist_block = aligned.split("WCONHIST\n", 1)[1] + header_line = wconhist_block.splitlines()[0] + data_line = wconhist_block.splitlines()[1] + # Header is a comment whose names are right-aligned into their columns, so it starts with + # '--' and lists WELL/STATUS/CMODE (the first column may be padded ahead of 'WELL'). + assert header_line.startswith("--") and "WELL" in header_line, ( + f"WCONHIST header should be a '--' comment listing item names: {header_line!r}" + ) + assert data_line.startswith(" "), ( + f"WCONHIST data row should be indented two spaces: {data_line!r}" + ) + assert data_line.rstrip().endswith("/"), ( + f"WCONHIST data row should end with '/': {data_line!r}" + ) + # Per-column defaults stay as individual '1*' markers (never accumulated into 'N*'). + assert "1*" in data_line and " 2*" not in data_line, ( + f"WCONHIST data row should keep per-column '1*' markers: {data_line!r}" + ) + + # Same keywords are produced either way. + for keyword in ("DATES", "COMPDAT", "WCONHIST"): + assert keyword in aligned and keyword in default + def test_perf_completion_number_triggers_complump(self, project_with_case_and_well): """#13273 follow-up: a completion_number on add_perf_event must surface as a COMPLUMP keyword (with that number) in the generated schedule. diff --git a/GrpcInterface/Python/rips/well_events.py b/GrpcInterface/Python/rips/well_events.py index 9355a3acef8..776d0afa8ad 100644 --- a/GrpcInterface/Python/rips/well_events.py +++ b/GrpcInterface/Python/rips/well_events.py @@ -289,6 +289,7 @@ def generate_schedule_text( eclipse_case: EclipseCase, export_msw_for_wells: List[WellPath] = [], first_date_as_comment: bool = True, + align_columns: bool = False, ) -> str: """Generate Eclipse schedule text for all wells in the collection. @@ -310,6 +311,9 @@ def generate_schedule_text( 2024") instead of a DATES keyword. This avoids a DATES entry equal to the simulation start date, which some commercial simulators reject. Later dates are always emitted as DATES keywords. + 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. Returns: str: Eclipse schedule text containing DATES, COMPDAT, WELSEGS, WCONPROD, etc. @@ -353,6 +357,7 @@ def generate_schedule_text( eclipse_case=eclipse_case, export_msw_for_wells=export_msw_for_wells, first_date_as_comment=first_date_as_comment, + align_columns=align_columns, ) if container and container.values: return "".join(container.values)