mirror of
https://github.com/OPM/ResInsight.git
synced 2026-08-27 05:37:21 -05:00
#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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user