mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-24 22:27:55 -05:00
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:
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;
|
||||
};
|
||||
+17
-5
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user