#14063 Well Event: Emit multi-record keywords (e.g. TUNING) correctly

buildKeyword placed every user-supplied item into a single DeckRecord,
so multi-record keywords such as TUNING (3 records, each terminated by
its own '/') were emitted with only one record/slash and rejected by the
simulator.

Distribute items across the keyword's records using the OPM ParserKeyword
schema: each item is matched to the record that defines it. Every record
is emitted (empty records are written as a bare '/') so the schema's
record structure is preserved, and items unknown to the schema are now
reported via a warning. Single-record keywords keep their previous
behaviour, including appending non-schema items after the canonical
block.
This commit is contained in:
Kristian Bendiksen
2026-05-29 15:43:30 +02:00
parent 9515e6c277
commit 393e52cfdc
3 changed files with 150 additions and 39 deletions
@@ -38,6 +38,7 @@
#include "opm/input/eclipse/Parser/ParserKeywords/W.hpp"
#include "opm/input/eclipse/Parser/ParserRecord.hpp"
#include <iterator>
#include <optional>
#include <sstream>
#include <unordered_map>
@@ -70,6 +71,7 @@ std::optional<Opm::DeckKeyword> RifEventKeywordFormatter::buildKeyword( const QS
const Opm::ParserKeyword& parserKw = parser.getKeyword( kwName );
Opm::DeckKeyword kw( parserKw );
const size_t numRecords = static_cast<size_t>( std::distance( parserKw.begin(), parserKw.end() ) );
const Opm::ParserRecord& parserRecord = parserKw.getRecord( 0 );
auto stringValue = []( const RimWellEventKeywordItem* item ) -> std::string
@@ -91,7 +93,8 @@ std::optional<Opm::DeckKeyword> RifEventKeywordFormatter::buildKeyword( const QS
// RPTRST / RPTSCHED-style keywords have a single ALL-sized item that holds a free-form
// list of mnemonics ("BASIC=2 DEN ROCKC ..."). Emit each user-supplied key either as
// "KEY=VALUE" (typed value) or bare "KEY" (FLAG), all packed into one DeckItem.
const bool isMnemonicList = parserRecord.size() == 1 && parserRecord.get( 0 ).sizeType() == Opm::ParserItem::item_size::ALL;
const bool isMnemonicList = numRecords == 1 && parserRecord.size() == 1 &&
parserRecord.get( 0 ).sizeType() == Opm::ParserItem::item_size::ALL;
if ( isMnemonicList )
{
@@ -119,8 +122,8 @@ std::optional<Opm::DeckKeyword> RifEventKeywordFormatter::buildKeyword( const QS
}
else
{
// Positional keyword (WCONHIST, WELTARG, ...): emit items in the schema-defined order
// regardless of caller-supplied order (e.g. Python dict insertion order).
// Positional keyword (WCONHIST, WELTARG, TUNING, ...): emit items in the schema-defined
// order regardless of caller-supplied order (e.g. Python dict insertion order).
std::unordered_map<std::string, const RimWellEventKeywordItem*> userItemsByName;
userItemsByName.reserve( items.size() );
for ( const auto* item : items )
@@ -128,21 +131,6 @@ std::optional<Opm::DeckKeyword> RifEventKeywordFormatter::buildKeyword( const QS
userItemsByName.emplace( item->itemName().toStdString(), item );
}
// Highest canonical index actually provided by the user. Trailing unspecified items are
// dropped, intermediate gaps become default markers ("1*").
std::optional<size_t> lastProvidedIdx;
std::unordered_set<std::string> canonicalNames;
canonicalNames.reserve( parserRecord.size() );
for ( size_t i = 0; i < parserRecord.size(); ++i )
{
const std::string& name = parserRecord.get( i ).name();
canonicalNames.insert( name );
if ( userItemsByName.contains( name ) )
{
lastProvidedIdx = i;
}
}
auto appendDeckItem = [&]( std::vector<Opm::DeckItem>& out, const std::string& name, const RimWellEventKeywordItem* item )
{
switch ( item->itemType() )
@@ -164,37 +152,81 @@ std::optional<Opm::DeckKeyword> RifEventKeywordFormatter::buildKeyword( const QS
}
};
std::vector<Opm::DeckItem> deckItems;
if ( lastProvidedIdx.has_value() )
// Build one record's items: emit schema items up to the last one the user provided,
// turning intermediate gaps into default markers ("1*") and dropping trailing
// unspecified items.
auto buildRecordItems = [&]( const Opm::ParserRecord& record )
{
deckItems.reserve( *lastProvidedIdx + 1 );
for ( size_t i = 0; i <= *lastProvidedIdx; ++i )
std::optional<size_t> lastProvidedIdx;
for ( size_t i = 0; i < record.size(); ++i )
{
const std::string& name = parserRecord.get( i ).name();
auto it = userItemsByName.find( name );
if ( it == userItemsByName.end() )
if ( userItemsByName.contains( record.get( i ).name() ) ) lastProvidedIdx = i;
}
std::vector<Opm::DeckItem> deckItems;
if ( lastProvidedIdx.has_value() )
{
deckItems.reserve( *lastProvidedIdx + 1 );
for ( size_t i = 0; i <= *lastProvidedIdx; ++i )
{
deckItems.push_back( RifOpmDeckTools::defaultItem( name ) );
}
else
{
appendDeckItem( deckItems, name, it->second );
const std::string& name = record.get( i ).name();
auto it = userItemsByName.find( name );
if ( it == userItemsByName.end() )
deckItems.push_back( RifOpmDeckTools::defaultItem( name ) );
else
appendDeckItem( deckItems, name, it->second );
}
}
return deckItems;
};
// Item names known to the schema across all of the keyword's records.
std::unordered_set<std::string> canonicalNames;
for ( const auto& record : parserKw )
{
for ( size_t i = 0; i < record.size(); ++i )
{
canonicalNames.insert( record.get( i ).name() );
}
}
// Items not in the keyword's schema are still emitted in caller-supplied order
// after the canonical block.
for ( const auto* item : items )
if ( numRecords > 1 )
{
const std::string name = item->itemName().toStdString();
if ( canonicalNames.contains( name ) ) continue;
appendDeckItem( deckItems, name, item );
}
// Multi-record keyword (e.g. TUNING): emit every record, each terminated by its own
// '/'. Records the user did not populate are still written (as a bare '/') so the
// record structure required by the schema is preserved.
for ( const auto& record : parserKw )
{
kw.addRecord( Opm::DeckRecord{ buildRecordItems( record ) } );
}
if ( !deckItems.empty() )
for ( const auto* item : items )
{
const std::string name = item->itemName().toStdString();
if ( !canonicalNames.contains( name ) )
{
RiaLogging::warning(
std::format( "Keyword '{}': item '{}' is not part of the keyword schema and was ignored.", kwName, name ) );
}
}
}
else
{
kw.addRecord( Opm::DeckRecord{ std::move( deckItems ) } );
// Single-record keyword: emit the canonical block, then any non-schema items in
// caller-supplied order.
std::vector<Opm::DeckItem> deckItems = buildRecordItems( parserRecord );
for ( const auto* item : items )
{
const std::string name = item->itemName().toStdString();
if ( canonicalNames.contains( name ) ) continue;
appendDeckItem( deckItems, name, item );
}
if ( !deckItems.empty() )
{
kw.addRecord( Opm::DeckRecord{ std::move( deckItems ) } );
}
}
}
@@ -172,6 +172,27 @@ def main():
)
print(" Added GRUPTREE event on 2024-01-01 (group tree definition)")
# Example 6: TUNING - Time stepping / convergence control.
# TUNING is a multi-record keyword: items are distributed into the record that
# defines them (record 1: TSINIT/TSMAXZ/TMAXWC, record 3: NEWTMX..MXWPIT), so the
# generated keyword has three records, each terminated by its own '/'.
_tuning_event = timeline.add_keyword_event(
event_date="2024-01-01",
keyword_name="TUNING",
keyword_data={
"TSINIT": 1,
"TSMAXZ": 30,
"TMAXWC": 1,
"NEWTMX": 12,
"NEWTMN": 1,
"LITMAX": 50,
"LITMIN": 1,
"MXWSIT": 50,
"MXWPIT": 50,
},
)
print(" Added TUNING event on 2024-01-01 (time stepping / convergence control)")
# Apply events up to March 15, 2024
# This should create:
# - Tubing interval (Jan 1)
@@ -238,6 +259,7 @@ def main():
"WRFTPLT",
"RPTRST",
"GRUPTREE",
"TUNING",
]
found_keywords = [kw for kw in expected_keywords if kw in schedule_text]
@@ -284,6 +306,7 @@ def main():
print(f" - WRFTPLT entries: {schedule_text.count('WRFTPLT')}")
print(f" - RPTRST entries: {schedule_text.count('RPTRST')}")
print(f" - GRUPTREE entries: {schedule_text.count('GRUPTREE')}")
print(f" - TUNING entries: {schedule_text.count('TUNING')}")
# Save to file
output_file = "generated_schedule.sch"
@@ -2092,6 +2092,62 @@ class TestScheduleKeywordEvents:
assert event is not None, "RPTSCHED event should be created"
def test_tuning_keyword_multi_record_output(self, project_with_case_and_well):
"""TUNING is a multi-record keyword (3 records, each terminated by '/'). Items
spanning different records must be distributed into their own records, producing
three slashes rather than one.
"""
project, case, timeline = project_with_case_and_well
well_path = project.well_paths()[0]
# A well event so the date section is emitted.
timeline.add_control_event(
event_date="2018-01-01",
well_path=well_path,
control_mode="ORAT",
control_value=1000.0,
oil_rate=1000.0,
is_producer=True,
)
# Items from record 1 (TSINIT, TSMAXZ, TMAXWC) and record 3 (NEWTMX..MXWPIT).
timeline.add_keyword_event(
event_date="2018-01-01",
keyword_name="TUNING",
keyword_data={
"TSINIT": 1,
"TSMAXZ": 30,
"TMAXWC": 1,
"NEWTMX": 12,
"NEWTMN": 1,
"LITMAX": 50,
"LITMIN": 1,
"MXWSIT": 50,
"MXWPIT": 50,
},
)
schedule_text = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=project.well_paths()
)
print(f"\nSchedule text with TUNING keyword:\n{schedule_text}")
assert "TUNING" in schedule_text, "Schedule should contain TUNING keyword"
# Isolate the TUNING block (header line up to the following blank line).
tuning_block = schedule_text.split("TUNING\n", 1)[1].split("\n\n", 1)[0]
record_terminators = [
line for line in tuning_block.splitlines() if line.strip().endswith("/")
]
assert len(record_terminators) == 3, (
f"TUNING must emit three records (three '/'), got {len(record_terminators)}:\n{tuning_block}"
)
# Record 1 keeps TSMAXZ; record 3 keeps NEWTMX. Both values must survive.
assert "30" in tuning_block, "TSMAXZ value missing from TUNING record 1"
assert "12" in tuning_block, "NEWTMX value missing from TUNING record 3"
def test_keyword_event_schedule_output(self, project_with_case_and_well):
"""Test that schedule keyword events appear in schedule text generation."""
project, case, timeline = project_with_case_and_well