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.
This commit is contained in:
Kristian Bendiksen committed 2026-06-01 15:45:03 +02:00
1 parent bce29354ea
commit 19c547cc2e
9 files changed
+317 -13

No files matched your search

@@ -49,7 +49,8 @@ QString RicScheduleDataGenerator::generateSchedule( const RimWellEventTimeline&
const std::vector<RimWellPath*>& wellPaths,
const std::vector<QDateTime>& dates,
const std::set<const RimWellPath*>& 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<RimWellPath*>& wellPaths,
const QDateTime& date,
const std::set<const RimWellPath*>& mswWells,
bool dateAsComment )
bool dateAsComment,
bool alignColumns )
{
// Keyword priority order for output
static const std::vector<QString> 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";
};
@@ -51,12 +51,15 @@ public:
// 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<RimWellPath*>& wellPaths,
const std::vector<QDateTime>& dates,
const std::set<const RimWellPath*>& mswWells,
bool firstDateAsComment = true );
bool firstDateAsComment = true,
bool alignColumns = false );
// Collect all unique dates from all wells' timelines
static std::vector<QDateTime> collectAllDates( const RimWellEventTimeline& timeline, const std::vector<RimWellPath*>& wellPaths );
@@ -69,7 +72,8 @@ private:
const std::vector<RimWellPath*>& wellPaths,
const QDateTime& date,
const std::set<const RimWellPath*>& mswWells,
bool dateAsComment = false );
bool dateAsComment = false,
bool alignColumns = false );
static std::optional<Opm::DeckKeyword>
generateWelspecsForWell( const RimWellEventTimeline& timeline, RimEclipseCase& eclipseCase, RimWellPath& well, const QDateTime& date );
@@ -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<int>( i ) );
case Opm::type_tag::fdouble:
{
std::ostringstream oss;
oss.precision( 10 );
oss << item.get<double>( i );
return oss.str();
}
case Opm::type_tag::string:
return "'" + item.get<std::string>( i ) + "'";
case Opm::type_tag::raw_string:
return std::string( item.get<Opm::RawString>( i ) );
case Opm::type_tag::uda:
{
const Opm::UDAValue& uda = item.getData<Opm::UDAValue>()[i];
if ( uda.is<double>() )
{
std::ostringstream oss;
oss.precision( 10 );
oss << uda.get<double>();
return oss.str();
}
return "'" + uda.get<std::string>() + "'";
}
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<std::string> 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<std::vector<std::string>> 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<std::string> 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<size_t> 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
@@ -81,4 +81,10 @@ Opm::DeckKeyword editnncKeyword( const std::vector<RigSimulationInputTool::Trans
Opm::DeckKeyword datesKeyword( const QDateTime& date );
QString deckKeywordToString( const Opm::DeckKeyword& keyword );
// Serialize a keyword as human-readable text: a "--"-prefixed column-header comment line listing
// the item names, followed by data rows right-aligned into fixed-width columns with a two-space
// indent. Unlike deckKeywordToString(), each item renders in its own column (consecutive defaults
// are not collapsed into "N*"), so columns stay aligned.
QString deckKeywordToAlignedString( const Opm::DeckKeyword& keyword );
} // namespace RimKeywordFactory
@@ -585,6 +585,13 @@ RimcWellEventTimeline_generateSchedule::RimcWellEventTimeline_generateSchedule(
"",
"",
"Emit the first (simulation-start) date as a comment instead of a DATES keyword" );
CAF_PDM_InitScriptableField( &m_alignColumns,
"AlignColumns",
false,
"",
"",
"",
"Emit a column-header comment and right-aligned, fixed-width columns instead of the compact form" );
}
//--------------------------------------------------------------------------------------------------
@@ -638,7 +645,13 @@ std::expected<caf::PdmObjectHandle*, QString> RimcWellEventTimeline_generateSche
std::set<const RimWellPath*> 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();
@@ -230,4 +230,5 @@ private:
caf::PdmPtrField<RimEclipseCase*> m_eclipseCase;
caf::PdmPtrArrayField<RimWellPath*> m_exportMswForWells;
caf::PdmField<bool> m_firstDateAsComment;
caf::PdmField<bool> m_alignColumns;
};
@@ -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:
@@ -1262,6 +1262,94 @@ class TestScheduleGeneration:
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.
+5
View File
@@ -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)