Well Event: Emit RPTRST/RPTSCHED as proper mnemonic lists

Schedule-level keywords whose OPM ParserRecord is a single ALL-sized item
(RPTRST, RPTSCHED, ...) are not positional — they take a free-form list
of mnemonic tokens like `BASIC=2 DEN ROCKC NORST=1`. Previously each
`keyword_data` entry became its own DeckItem, and OPM emitted them
either as truncated positional values or as a quoted, space-collapsed
single string.

Detect that schema shape in `RifEventKeywordFormatter::formatKeyword`
and pack the user's entries into one RawString DeckItem of mnemonic
tokens: `KEY` for FLAG items, `KEY=VALUE` otherwise.

To distinguish flag mnemonics (`DEN`) from `KEY=1`-style entries, add a
new `FLAG` value to `RimWellEventKeywordItem::ItemType` plumbed end to
end (Python -> GRPC -> Rimc -> storage -> formatter). The Python API
now maps `bool True` to FLAG and silently drops `bool False`.

For positional keywords (WCONHIST etc.) FLAG items have no value to
emit and are rendered as default markers ("1*").

A `rawStringItem` helper is added to `RifOpmDeckTools` so the
mnemonic-list path can produce DeckItems whose OPM output is not
quoted.
This commit is contained in:
Kristian Bendiksen committed 2026-05-28 08:21:17 +02:00
1 parent 9ada4eed09
commit d2e7bed6b8
12 files changed
+243 -63

No files matched your search

@@ -33,6 +33,7 @@
#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/ParserItem.hpp"
#include "opm/input/eclipse/Parser/ParserKeyword.hpp"
#include "opm/input/eclipse/Parser/ParserKeywords/W.hpp"
#include "opm/input/eclipse/Parser/ParserRecord.hpp"
@@ -56,80 +57,132 @@ QString RifEventKeywordFormatter::formatKeyword( const QString& keywordName, con
const Opm::ParserKeyword& parserKw = parser.getKeyword( kwName );
Opm::DeckKeyword kw( parserKw );
// Eclipse keyword records are positional: items must be emitted in the schema-defined order
// regardless of how the caller supplied them (e.g. Python dict insertion order).
std::unordered_map<std::string, const RimWellEventKeywordItem*> userItemsByName;
userItemsByName.reserve( items.size() );
for ( const auto* item : items )
{
userItemsByName.emplace( item->itemName().toStdString(), item );
}
const Opm::ParserRecord& parserRecord = parserKw.getRecord( 0 );
// Determine the highest canonical index that the user actually provided; 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 )
auto stringValue = []( const RimWellEventKeywordItem* item ) -> std::string
{
switch ( item->itemType() )
{
case RimWellEventKeywordItem::ItemType::STRING:
out.push_back( RifOpmDeckTools::item( name, item->stringValue().toStdString() ) );
break;
return item->stringValue().toStdString();
case RimWellEventKeywordItem::ItemType::INTEGER:
out.push_back( RifOpmDeckTools::item( name, item->intValue() ) );
break;
return std::to_string( item->intValue() );
case RimWellEventKeywordItem::ItemType::DOUBLE:
out.push_back( RifOpmDeckTools::item( name, item->doubleValue() ) );
break;
return std::to_string( item->doubleValue() );
case RimWellEventKeywordItem::ItemType::FLAG:
return "";
}
return "";
};
std::vector<Opm::DeckItem> deckItems;
if ( lastProvidedIdx.has_value() )
// 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;
if ( isMnemonicList )
{
deckItems.reserve( *lastProvidedIdx + 1 );
for ( size_t i = 0; i <= *lastProvidedIdx; ++i )
std::vector<std::string> tokens;
tokens.reserve( items.size() );
for ( const auto* item : items )
{
const std::string& name = parserRecord.get( i ).name();
auto it = userItemsByName.find( name );
if ( it == userItemsByName.end() )
const std::string name = item->itemName().toStdString();
if ( item->itemType() == RimWellEventKeywordItem::ItemType::FLAG )
{
deckItems.push_back( RifOpmDeckTools::defaultItem( name ) );
tokens.push_back( name );
}
else
{
appendDeckItem( deckItems, name, it->second );
tokens.push_back( name + "=" + stringValue( item ) );
}
}
}
// Items not in the keyword's schema are still emitted (preserves behaviour for
// mnemonic-list keywords such as RPTRST/RPTSCHED) but in caller-supplied order
// after the canonical block.
for ( const auto* item : items )
{
const std::string name = item->itemName().toStdString();
if ( canonicalNames.contains( name ) ) continue;
appendDeckItem( deckItems, name, item );
if ( !tokens.empty() )
{
std::vector<Opm::DeckItem> deckItems;
deckItems.push_back( RifOpmDeckTools::rawStringItem( parserRecord.get( 0 ).name(), std::move( tokens ) ) );
kw.addRecord( Opm::DeckRecord{ std::move( deckItems ) } );
}
}
if ( !deckItems.empty() )
else
{
kw.addRecord( Opm::DeckRecord{ std::move( deckItems ) } );
// Positional keyword (WCONHIST, WELTARG, ...): 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 )
{
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() )
{
case RimWellEventKeywordItem::ItemType::STRING:
out.push_back( RifOpmDeckTools::item( name, item->stringValue().toStdString() ) );
break;
case RimWellEventKeywordItem::ItemType::INTEGER:
out.push_back( RifOpmDeckTools::item( name, item->intValue() ) );
break;
case RimWellEventKeywordItem::ItemType::DOUBLE:
out.push_back( RifOpmDeckTools::item( name, item->doubleValue() ) );
break;
case RimWellEventKeywordItem::ItemType::FLAG:
// FLAG only makes sense for mnemonic-list keywords; for positional schemas
// there is no meaningful value to emit, so render as a default marker.
out.push_back( RifOpmDeckTools::defaultItem( name ) );
break;
}
};
std::vector<Opm::DeckItem> deckItems;
if ( lastProvidedIdx.has_value() )
{
deckItems.reserve( *lastProvidedIdx + 1 );
for ( size_t i = 0; i <= *lastProvidedIdx; ++i )
{
const std::string& name = parserRecord.get( i ).name();
auto it = userItemsByName.find( name );
if ( it == userItemsByName.end() )
{
deckItems.push_back( RifOpmDeckTools::defaultItem( name ) );
}
else
{
appendDeckItem( deckItems, name, it->second );
}
}
}
// Items not in the keyword's schema are still emitted in caller-supplied order
// after the canonical block.
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 ) } );
}
}
// Return empty string if keyword creation failed
@@ -20,6 +20,7 @@
#include "opm/input/eclipse/Deck/DeckItem.hpp"
#include "opm/input/eclipse/Units/Dimension.hpp"
#include "opm/input/eclipse/Utility/Typetools.hpp"
namespace RifOpmDeckTools
{
@@ -157,4 +158,16 @@ Opm::DeckItem item( std::string name, std::vector<std::string> values )
return item1;
}
//--------------------------------------------------------------------------------------------------
/// Raw-string DeckItem: emitted by OPM DeckOutput without surrounding quotes.
/// Used for free-form mnemonic lists (e.g. RPTRST tokens like "BASIC=2", "DEN").
//--------------------------------------------------------------------------------------------------
Opm::DeckItem rawStringItem( std::string name, std::vector<std::string> values )
{
Opm::DeckItem item1( name, Opm::RawString{} );
for ( const auto& value : values )
item1.push_back( Opm::RawString{ value } );
return item1;
}
} // namespace RifOpmDeckTools
@@ -42,5 +42,6 @@ Opm::DeckItem optionalItem( std::string name, std::optional<double> value );
Opm::DeckItem defaultItem( std::string name );
Opm::DeckItem item( std::string name, std::set<std::string> values );
Opm::DeckItem item( std::string name, std::vector<std::string> values );
Opm::DeckItem rawStringItem( std::string name, std::vector<std::string> values );
} // namespace RifOpmDeckTools
@@ -98,6 +98,17 @@ void RimKeywordEvent::addDoubleItem( const QString& name, double value )
m_items.push_back( item );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimKeywordEvent::addFlagItem( const QString& name )
{
auto* item = new RimWellEventKeywordItem();
item->setItemName( name );
item->setFlag();
m_items.push_back( item );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -46,6 +46,7 @@ public:
void addStringItem( const QString& name, const QString& value );
void addIntItem( const QString& name, int value );
void addDoubleItem( const QString& name, double value );
void addFlagItem( const QString& name );
std::vector<class RimWellEventKeywordItem*> items() const;
@@ -98,6 +98,17 @@ void RimWellEventKeyword::addDoubleItem( const QString& name, double value )
m_items.push_back( item );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWellEventKeyword::addFlagItem( const QString& name )
{
auto* item = new RimWellEventKeywordItem();
item->setItemName( name );
item->setFlag();
m_items.push_back( item );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -45,6 +45,7 @@ public:
void addStringItem( const QString& name, const QString& value );
void addIntItem( const QString& name, int value );
void addDoubleItem( const QString& name, double value );
void addFlagItem( const QString& name );
std::vector<class RimWellEventKeywordItem*> items() const;
@@ -116,6 +116,14 @@ void RimWellEventKeywordItem::setDoubleValue( double value )
m_doubleValue = value;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWellEventKeywordItem::setFlag()
{
m_itemType = ItemType::FLAG;
}
namespace caf
{
template <>
@@ -124,6 +132,7 @@ void AppEnum<RimWellEventKeywordItem::ItemType>::setUp()
addItem( RimWellEventKeywordItem::ItemType::STRING, "STRING", "String" );
addItem( RimWellEventKeywordItem::ItemType::INTEGER, "INTEGER", "Integer" );
addItem( RimWellEventKeywordItem::ItemType::DOUBLE, "DOUBLE", "Double" );
addItem( RimWellEventKeywordItem::ItemType::FLAG, "FLAG", "Flag" );
setDefault( RimWellEventKeywordItem::ItemType::STRING );
}
} // namespace caf
@@ -38,7 +38,8 @@ public:
{
STRING,
INTEGER,
DOUBLE
DOUBLE,
FLAG
};
RimWellEventKeywordItem();
@@ -56,6 +57,7 @@ public:
void setStringValue( const QString& value );
void setIntValue( int value );
void setDoubleValue( double value );
void setFlag();
private:
caf::PdmField<QString> m_itemName;
@@ -406,9 +406,13 @@ std::expected<caf::PdmObjectHandle*, QString> RimcWellEventTimeline_addWellKeywo
}
event->addDoubleItem( name, doubleValue );
}
else if ( type == "FLAG" )
{
event->addFlagItem( name );
}
else
{
return std::unexpected( QString( "Unknown item type: %1. Must be STRING, INT, or DOUBLE" ).arg( type ) );
return std::unexpected( QString( "Unknown item type: %1. Must be STRING, INT, DOUBLE, or FLAG" ).arg( type ) );
}
}
@@ -498,9 +502,13 @@ std::expected<caf::PdmObjectHandle*, QString> RimcWellEventTimeline_addKeywordEv
}
event->addDoubleItem( name, doubleValue );
}
else if ( type == "FLAG" )
{
event->addFlagItem( name );
}
else
{
return std::unexpected( QString( "Unknown item type: %1. Must be STRING, INT, or DOUBLE" ).arg( type ) );
return std::unexpected( QString( "Unknown item type: %1. Must be STRING, INT, DOUBLE, or FLAG" ).arg( type ) );
}
}
@@ -1895,3 +1895,55 @@ class TestScheduleKeywordEvents:
)
assert event is not None, "Event with mixed types should be created"
def test_rptrst_mnemonic_output(self, project_with_case_and_well):
"""RPTRST/RPTSCHED are mnemonic-list keywords. bool True must emit a
bare KEY, int/float/str must emit KEY=VALUE, bool False must be omitted.
"""
project, case, timeline = project_with_case_and_well
well_path = project.well_paths()[0]
# Schedule generation requires at least one well event so a well path is
# selected for output; the assertions below target only the RPTRST block.
timeline.add_control_event(
event_date="2024-01-01",
well_path=well_path,
control_mode="ORAT",
control_value=1000.0,
oil_rate=1000.0,
is_producer=True,
)
timeline.add_keyword_event(
event_date="2024-01-01",
keyword_name="RPTRST",
keyword_data={
"BASIC": 2,
"DEN": True,
"ROCKC": True,
"RPORV": True,
"RFIP": True,
"FLOWS": True,
"NORST": 1,
"FLORES": True,
"OBSOLETE": False,
},
)
schedule_text = timeline.generate_schedule_text(eclipse_case=case)
print(f"\nRPTRST mnemonic output:\n{schedule_text}")
assert "RPTRST" in schedule_text
rptrst_block = schedule_text.split("RPTRST", 1)[1].split("/", 1)[0]
# Keyed mnemonics rendered as KEY=VALUE.
assert "BASIC=2" in rptrst_block
assert "NORST=1" in rptrst_block
# Flag mnemonics rendered as bare tokens. Whitespace-bounded so we don't accept
# accidental substring matches like 'DEN' inside another token.
for flag in ("DEN", "ROCKC", "RPORV", "RFIP", "FLOWS", "FLORES"):
assert f" {flag} " in rptrst_block or rptrst_block.rstrip().endswith(
f" {flag}"
), f"flag {flag!r} missing from RPTRST output: {rptrst_block!r}"
# False-valued flag must be omitted entirely.
assert "OBSOLETE" not in rptrst_block
+26 -8
View File
@@ -121,15 +121,24 @@ def add_well_keyword_event(
item_values = []
for name, value in keyword_data.items():
# Handle bool before int (bool is subclass of int in Python).
# bool semantics: True -> flag (emitted as bare KEY in mnemonic-list keywords,
# ignored elsewhere); False -> the entry is dropped entirely.
if isinstance(value, bool):
if not value:
continue
item_names.append(name)
item_types.append("FLAG")
# The value is ignored for FLAG items but must be a non-empty string;
# empty strings get dropped by the GRPC vector<string> serialization.
item_values.append("1")
continue
item_names.append(name)
if isinstance(value, str):
item_types.append("STRING")
item_values.append(value)
elif isinstance(value, bool):
# Handle bool before int (bool is subclass of int in Python)
item_types.append("INT")
item_values.append("1" if value else "0")
elif isinstance(value, int):
item_types.append("INT")
item_values.append(str(value))
@@ -228,15 +237,24 @@ def add_keyword_event(
item_values = []
for name, value in keyword_data.items():
# Handle bool before int (bool is subclass of int in Python).
# bool semantics: True -> flag (emitted as bare KEY in mnemonic-list keywords,
# ignored elsewhere); False -> the entry is dropped entirely.
if isinstance(value, bool):
if not value:
continue
item_names.append(name)
item_types.append("FLAG")
# The value is ignored for FLAG items but must be a non-empty string;
# empty strings get dropped by the GRPC vector<string> serialization.
item_values.append("1")
continue
item_names.append(name)
if isinstance(value, str):
item_types.append("STRING")
item_values.append(value)
elif isinstance(value, bool):
# Handle bool before int (bool is subclass of int in Python)
item_types.append("INT")
item_values.append("1" if value else "0")
elif isinstance(value, int):
item_types.append("INT")
item_values.append(str(value))