#14870 Schedule export: export multilateral wells once per well name

Wells sharing an export name now produce one WELSPECS/COMPORD record,
one set of MSW keywords, and no duplicate COMPDAT/COMPLUMP connections.
Events on any well in the group trigger the group's keywords.

Connections in cells shared by several well paths of a group are combined
into one record. MSW keywords are exported for the well tree of the first
group member selected for MSW export.

Fixes #14870
This commit is contained in:
Kristian Bendiksen committed 2026-10-12 18:47:03 +02:00
1 parent 164dee3f4b
commit f3d9d04281
5 files changed
+262 -48

No files matched your search

@@ -28,6 +28,7 @@
#include "ProjectDataModel/Jobs/RimKeywordFactory.h"
#include "RimEclipseCase.h"
#include "RimKeywordEvent.h"
#include "RimWellEvent.h"
#include "RimWellEventRawText.h"
#include "RimWellEventTimeline.h"
#include "RimWellEventWellSpec.h"
@@ -51,6 +52,20 @@ bool shouldEmitKeyword( const Opm::DeckKeyword& keyword )
{
return keyword.size() > 0 || keyword.isDataKeyword() || RifEventKeywordFormatter::isRecordlessKeyword( keyword );
}
QString exportNameForWell( const RimWellPath& well )
{
return well.completionSettings() ? well.completionSettings()->wellNameForExport() : well.name();
}
// Wells in a multilateral group share one export name, so an event on any member applies to the whole group.
bool eventBelongsToWell( const RimWellEvent& event, const RimWellPath& well )
{
if ( event.wellName() == well.name() ) return true;
const RimWellPath* eventWell = event.wellPath();
return eventWell && exportNameForWell( *eventWell ) == exportNameForWell( well );
}
} // namespace
//--------------------------------------------------------------------------------------------------
@@ -321,22 +336,53 @@ std::expected<QString, QString> RicScheduleDataGenerator::generateDateSection( c
// (keyed by keyword name, preserving well order) and serialised individually.
std::map<QString, std::vector<Opm::DeckKeyword>> unmergedBlocks;
for ( auto* well : wellPaths )
// Wells sharing an export name (multilateral wells) are one well in the deck, so WELSPECS/COMPORD, COMPDAT and
// the MSW keywords are generated once per export name. Groups keep the export-name order of wellPaths, and
// top-level wells come first within a group so that a group is represented by its main well.
std::vector<std::vector<RimWellPath*>> wellGroups;
{
if ( !well ) continue;
if ( auto welspecs = generateWelspecsForWell( timeline, eclipseCase, *well, date, events ) )
std::map<QString, size_t> groupIndex;
for ( auto* well : wellPaths )
{
mergeKeyword( keywordBlocks, "WELSPECS", std::move( *welspecs ) );
mergeKeyword( keywordBlocks,
"COMPORD",
RimKeywordFactory::compordKeyword( well->completionSettings()->wellNameForExport().toStdString() ) );
if ( !well ) continue;
auto [it, inserted] = groupIndex.try_emplace( exportNameForWell( *well ), wellGroups.size() );
if ( inserted ) wellGroups.emplace_back();
wellGroups[it->second].push_back( well );
}
generateCompletionsForWell( eclipseCase, *well, date, events, keywordBlocks );
for ( auto& group : wellGroups )
{
std::stable_partition( group.begin(), group.end(), []( const RimWellPath* w ) { return w->isTopLevelWellPath(); } );
}
}
generateMswForWell( eclipseCase, *well, date, events, keywordBlocks, unmergedBlocks, mswWells, emittedSegmentLayouts );
generateWellControlForWell( *well, events, keywordBlocks );
for ( const auto& group : wellGroups )
{
for ( auto* well : group )
{
if ( auto welspecs = generateWelspecsForWell( timeline, eclipseCase, *well, date, events ) )
{
mergeKeyword( keywordBlocks, "WELSPECS", std::move( *welspecs ) );
mergeKeyword( keywordBlocks, "COMPORD", RimKeywordFactory::compordKeyword( exportNameForWell( *well ).toStdString() ) );
break;
}
}
generateCompletionsForWellGroup( eclipseCase, group, date, events, keywordBlocks );
// MSW keywords describe the whole well tree, so they are generated from the top-level well of the first
// group member selected for MSW export.
auto mswIt = std::find_if( group.begin(), group.end(), [&mswWells]( const RimWellPath* w ) { return mswWells.contains( w ); } );
if ( mswIt != group.end() )
{
generateMswForWell( eclipseCase, *( *mswIt )->topLevelWellPath(), date, events, keywordBlocks, unmergedBlocks, emittedSegmentLayouts );
}
for ( auto* well : group )
{
generateWellControlForWell( *well, events, keywordBlocks );
}
}
// Schedule-level keyword events (not tied to a specific well)
@@ -494,7 +540,7 @@ std::optional<Opm::DeckKeyword> RicScheduleDataGenerator::generateWelspecsForWel
{
if ( ( event->eventType() == RimWellEvent::EventType::PERF || event->eventType() == RimWellEvent::EventType::TUBING ||
event->eventType() == RimWellEvent::EventType::WELLSPEC ) &&
event->wellName() == well.name() )
eventBelongsToWell( *event, well ) )
{
hasEvents = true;
break;
@@ -507,7 +553,7 @@ std::optional<Opm::DeckKeyword> RicScheduleDataGenerator::generateWelspecsForWel
const RimWellEventWellSpec* latestWellSpec = nullptr;
for ( auto* event : timeline.events() )
{
if ( event->eventType() != RimWellEvent::EventType::WELLSPEC || event->wellName() != well.name() ) continue;
if ( event->eventType() != RimWellEvent::EventType::WELLSPEC || !eventBelongsToWell( *event, well ) ) continue;
auto* wellSpec = dynamic_cast<RimWellEventWellSpec*>( event );
if ( !wellSpec ) continue;
@@ -537,16 +583,20 @@ std::optional<Opm::DeckKeyword> RicScheduleDataGenerator::generateWelspecsForWel
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RicScheduleDataGenerator::generateCompletionsForWell( RimEclipseCase& eclipseCase,
RimWellPath& well,
const QDateTime& date,
const std::vector<RimWellEvent*>& eventsAtDate,
std::map<QString, Opm::DeckKeyword>& keywordBlocks )
void RicScheduleDataGenerator::generateCompletionsForWellGroup( RimEclipseCase& eclipseCase,
const std::vector<RimWellPath*>& wellGroup,
const QDateTime& date,
const std::vector<RimWellEvent*>& eventsAtDate,
std::map<QString, Opm::DeckKeyword>& keywordBlocks )
{
if ( wellGroup.empty() ) return;
const RimWellPath& representative = *wellGroup.front();
bool hasPerfEvents = false;
for ( auto* event : eventsAtDate )
{
if ( event->eventType() == RimWellEvent::EventType::PERF && event->wellName() == well.name() )
if ( event->eventType() == RimWellEvent::EventType::PERF && eventBelongsToWell( *event, representative ) )
{
hasPerfEvents = true;
break;
@@ -555,10 +605,20 @@ void RicScheduleDataGenerator::generateCompletionsForWell( RimEclipseCase&
if ( !hasPerfEvents ) return;
// Completion data is computed once and feeds both COMPDAT and COMPLUMP (the latter only emits
// records whose perforation has a completion number assigned). Both keywords merge across wells.
auto compdata = RicWellPathExportCompletionDataFeatureImpl::completionDataForWellPath( &well, &eclipseCase, date );
auto wellName = well.completionSettings()->wellNameForExport().toStdString();
// A connection must be exported once per well name. Completion data is computed for every distinct well tree
// in the group (a top-level well includes its laterals) and combined per cell, so connections shared by several
// well paths get their contributions combined instead of being duplicated or dropped.
std::vector<RimWellPath*> topLevelWells;
for ( auto* well : wellGroup )
{
auto* topLevel = well->topLevelWellPath();
if ( std::find( topLevelWells.begin(), topLevelWells.end(), topLevel ) == topLevelWells.end() ) topLevelWells.push_back( topLevel );
}
// Completion data feeds both COMPDAT and COMPLUMP (the latter only emits records whose perforation has a
// completion number assigned). Both keywords merge across wells.
auto compdata = RicWellPathExportCompletionDataFeatureImpl::completionDataForWellPaths( topLevelWells, &eclipseCase, date );
auto wellName = exportNameForWell( representative ).toStdString();
mergeKeyword( keywordBlocks, "COMPDAT", RimKeywordFactory::compdatKeyword( compdata, wellName ) );
mergeKeyword( keywordBlocks, "COMPLUMP", RimKeywordFactory::complumpKeyword( compdata, wellName ) );
@@ -573,15 +633,8 @@ void RicScheduleDataGenerator::generateMswForWell( RimEclipseCase&
const std::vector<RimWellEvent*>& eventsAtDate,
std::map<QString, Opm::DeckKeyword>& keywordBlocks,
std::map<QString, std::vector<Opm::DeckKeyword>>& unmergedBlocks,
const std::set<const RimWellPath*>& mswWells,
std::map<QString, QString>& emittedSegmentLayouts )
{
// MSW keywords are exported only for the wells the caller explicitly requested.
if ( !mswWells.contains( &wellPath ) )
{
return;
}
auto* segmentCollection = wellPath.segmentCollection();
if ( !segmentCollection )
{
@@ -593,7 +646,7 @@ void RicScheduleDataGenerator::generateMswForWell( RimEclipseCase&
bool hasGeometryEvents = false; // tubing or perforation changes require WELSEGS/COMPSEGS to be redefined
for ( auto* event : eventsAtDate )
{
if ( event->wellName() != wellPath.name() ) continue;
if ( !eventBelongsToWell( *event, wellPath ) ) continue;
if ( event->eventType() == RimWellEvent::EventType::VALVE )
{
@@ -638,7 +691,7 @@ void RicScheduleDataGenerator::generateMswForWell( RimEclipseCase&
// WSEGVALV/WSEGAICD, but a valve becoming active adds valve segments and remaps connections, so the
// segments are also redefined when the layout differs from the one last written for this well.
const QString segmentLayout = RimKeywordFactory::deckKeywordToString( welsegsKw ) + RimKeywordFactory::deckKeywordToString( compsegsKw );
const QString wellName = wellPath.completionSettings() ? wellPath.completionSettings()->wellNameForExport() : wellPath.name();
const QString wellName = exportNameForWell( wellPath );
auto layoutIt = emittedSegmentLayouts.find( wellName );
const bool layoutChanged = layoutIt == emittedSegmentLayouts.end() || layoutIt->second != segmentLayout;
@@ -48,7 +48,8 @@ public:
// Generate schedule for multiple wells at specified dates.
// mswWells lists the wells for which the multi-segment-well keywords (WELSEGS, COMPSEGS,
// WSEGVALV, WSEGAICD) are exported. Wells not in the set get no MSW keywords; an empty set
// suppresses MSW export for all wells.
// suppresses MSW export for all wells. Well paths sharing an export name are one well in the deck:
// selecting any of them exports the MSW keywords once, for the well tree of the first selected one.
// 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.
@@ -94,18 +95,19 @@ private:
const QDateTime& date,
const std::vector<RimWellEvent*>& eventsAtDate );
// Generate COMPDAT (and COMPLUMP, when perforations carry a completion number) for a well at a
// specific date based on events, merging both into the accumulator.
static void generateCompletionsForWell( RimEclipseCase& eclipseCase,
RimWellPath& well,
const QDateTime& date,
const std::vector<RimWellEvent*>& eventsAtDate,
std::map<QString, Opm::DeckKeyword>& keywordBlocks );
// Generate COMPDAT (and COMPLUMP, when perforations carry a completion number) for a group of well paths sharing
// one export name at a specific date based on events, merging both into the accumulator. Connections in cells
// shared by several well paths in the group are combined into one record.
static void generateCompletionsForWellGroup( RimEclipseCase& eclipseCase,
const std::vector<RimWellPath*>& wellGroup,
const QDateTime& date,
const std::vector<RimWellEvent*>& eventsAtDate,
std::map<QString, Opm::DeckKeyword>& keywordBlocks );
// Generate WELSEGS / COMPSEGS / WSEGVALV / WSEGAICD for a well at a specific date.
// WSEGVALV / WSEGAICD are merged into keywordBlocks; WELSEGS / COMPSEGS cannot be merged across
// wells and are appended as separate per-well blocks in unmergedBlocks.
// All four keywords are emitted only when the well is present in mswWells; otherwise none are.
// The caller decides which wells get MSW keywords (see mswWells in generateSchedule).
// WELSEGS / COMPSEGS are written for tubing/perforation events, and for valve events when the
// segment layout differs from the one last written for the well (tracked in emittedSegmentLayouts).
static void generateMswForWell( RimEclipseCase& eclipseCase,
@@ -114,7 +116,6 @@ private:
const std::vector<RimWellEvent*>& eventsAtDate,
std::map<QString, Opm::DeckKeyword>& keywordBlocks,
std::map<QString, std::vector<Opm::DeckKeyword>>& unmergedBlocks,
const std::set<const RimWellPath*>& mswWells,
std::map<QString, QString>& emittedSegmentLayouts );
// Generate well control / well keyword event keywords for a well at a specific date, merging into the accumulator
@@ -65,6 +65,7 @@
#include <QDir>
#include <algorithm>
#include <map>
#include <optional>
#include <set>
@@ -1383,6 +1384,19 @@ void RicWellPathExportCompletionDataFeatureImpl::exportCarfinForTemporaryLgrs( c
std::vector<RigCompletionData> RicWellPathExportCompletionDataFeatureImpl::completionDataForWellPath( RimWellPath* wellPath,
RimEclipseCase* eCase,
const std::optional<QDateTime>& exportDate )
{
if ( wellPath == nullptr ) return {};
return completionDataForWellPaths( { wellPath }, eCase, exportDate );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<RigCompletionData>
RicWellPathExportCompletionDataFeatureImpl::completionDataForWellPaths( const std::vector<RimWellPath*>& wellPaths,
RimEclipseCase* eCase,
const std::optional<QDateTime>& exportDate )
{
if ( eCase == nullptr ) return {};
@@ -1404,14 +1418,20 @@ std::vector<RigCompletionData> RicWellPathExportCompletionDataFeatureImpl::compl
std::vector<RigCompletionData> completions;
// Completions from all given well paths (and their laterals) are combined per cell, so that cells shared
// by several well paths are exported as a single connection.
std::vector<RimWellPath*> allWellPathLaterals;
if ( wellPath->unitSystem() == eCase->eclipseCaseData()->unitsType() )
for ( auto* wellPath : wellPaths )
{
allWellPathLaterals = wellPath->allWellPathLaterals();
}
else
{
return {};
if ( !wellPath || wellPath->unitSystem() != eCase->eclipseCaseData()->unitsType() ) continue;
for ( auto* lateral : wellPath->allWellPathLaterals() )
{
if ( std::find( allWellPathLaterals.begin(), allWellPathLaterals.end(), lateral ) == allWellPathLaterals.end() )
{
allWellPathLaterals.push_back( lateral );
}
}
}
std::map<size_t, std::vector<RigCompletionData>> completionsPerEclipseCellAllCompletionTypes;
@@ -67,6 +67,11 @@ public:
static std::vector<RigCompletionData>
completionDataForWellPath( RimWellPath* wellPath, RimEclipseCase* eCase, const std::optional<QDateTime>& exportDate = std::nullopt );
// Completion data for several well paths (including their laterals), combined per cell into one connection.
static std::vector<RigCompletionData> completionDataForWellPaths( const std::vector<RimWellPath*>& wellPaths,
RimEclipseCase* eCase,
const std::optional<QDateTime>& exportDate = std::nullopt );
static std::pair<double, cvf::Vec2i>
wellPathUpperGridIntersectionIJ( const RimEclipseCase* gridCase, const RimWellPath* wellPath, const QString& gridName = "" );
@@ -316,6 +316,141 @@ class TestScheduleGeneration:
assert "2024" in schedule_text, "Schedule should contain the event date"
assert "WELSEGS" in schedule_text, "Schedule should contain MSW WELSEGS keyword"
def test_generate_schedule_shared_well_name_exported_once(
self, project_with_case_and_well
):
"""Wells sharing an export name (multilateral) give one WELSPECS/COMPORD record and unique COMPDAT cells (issue #14870)."""
project, case, timeline = project_with_case_and_well
for index, well_path in enumerate(project.well_paths()):
well_path.name = "OP5" if index == 0 else "OP5_Y2"
well_path.update()
settings = well_path.completion_settings()
settings.well_name_for_export = "OP5"
settings.update()
timeline.add_perf_event(
event_date="2024-01-01",
well_path=well_path,
start_md=2000.0,
end_md=2200.0,
diameter=0.1,
skin_factor=0.5,
state="OPEN",
)
timeline.set_timestamp(timestamp="2024-12-31")
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=[],
first_date_as_comment=False,
)
assert (
len(re.findall(r"^\s*'OP5'\s+'", schedule_text.split("COMPORD")[0], re.M))
== 1
)
assert schedule_text.count("'OP5' 'INPUT'") == 1
compdat = schedule_text.split("COMPDAT")[1]
cells = re.findall(r"'OP5'\s+(\d+\s+\d+\s+\d+)", compdat)
assert cells and len(cells) == len(set(cells))
@staticmethod
def _two_wells_sharing_export_name(project, timeline, with_tubing=False):
"""Import a second copy of well path A (renamed) and perforate both copies identically."""
well_path_coll = project.descendants(rips.WellPathCollection)[0]
well_path_coll.import_well_path(
file_name=dataroot.PATH + "/TEST10K_FLT_LGR_NNC/wellpath_a.dev"
)
well_paths = [wp for wp in project.well_paths() if wp.name == "Well Path A"]
assert len(well_paths) == 2
for index, well_path in enumerate(well_paths):
well_path.name = f"Well Path A{index + 1}"
well_path.update()
for well_path in well_paths:
if with_tubing:
timeline.add_tubing_event(
event_date="2024-01-01",
well_path=well_path,
start_md=1500.0,
end_md=2300.0,
inner_diameter=0.15,
roughness=1.0e-5,
)
timeline.add_perf_event(
event_date="2024-01-01",
well_path=well_path,
start_md=2000.0,
end_md=2200.0,
diameter=0.1,
skin_factor=0.5,
state="OPEN",
)
timeline.set_timestamp(timestamp="2024-12-31")
return well_paths
@staticmethod
def _set_export_names(well_paths, names):
for well_path, name in zip(well_paths, names):
settings = well_path.completion_settings()
settings.well_name_for_export = name
settings.update()
@staticmethod
def _compdat_transmissibilities(schedule_text, well_name):
"""Map 'I J K' -> connection transmissibility for the COMPDAT records of a well."""
compdat = schedule_text.split("COMPDAT")[1].split("\n/")[0]
pattern = rf"'{well_name}'\s+(\d+ \d+ \d+) \d+ '\w+' \S+ (\S+)"
return {cell: float(trans) for cell, trans in re.findall(pattern, compdat)}
def test_shared_well_name_combines_overlapping_connections(
self, project_with_case_and_well
):
"""Cells shared by well paths with the same export name are combined, not dropped (issue #14870)."""
project, case, timeline = project_with_case_and_well
well_paths = self._two_wells_sharing_export_name(project, timeline)
self._set_export_names(well_paths, ["OPA1", "OPA2"])
separate = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[], first_date_as_comment=False
)
single = self._compdat_transmissibilities(separate, "OPA1")
assert single and single == self._compdat_transmissibilities(separate, "OPA2")
self._set_export_names(well_paths, ["OP5", "OP5"])
shared = timeline.generate_schedule_text(
eclipse_case=case, export_msw_for_wells=[], first_date_as_comment=False
)
combined = self._compdat_transmissibilities(shared, "OP5")
compdat = shared.split("COMPDAT")[1].split("\n/")[0]
assert len(re.findall(r"^\s*'OP5'", compdat, re.M)) == len(single)
assert combined.keys() == single.keys()
for cell, trans in single.items():
assert combined[cell] == pytest.approx(2.0 * trans, rel=1e-6)
@pytest.mark.parametrize("selected_index", [0, 1])
def test_shared_well_name_msw_exported_for_any_selected_member(
self, project_with_case_and_well, selected_index
):
"""MSW for a well name is exported once when any member is selected, not only the first (issue #14870)."""
project, case, timeline = project_with_case_and_well
well_paths = self._two_wells_sharing_export_name(
project, timeline, with_tubing=True
)
self._set_export_names(well_paths, ["OP5", "OP5"])
schedule_text = timeline.generate_schedule_text(
eclipse_case=case,
export_msw_for_wells=[well_paths[selected_index]],
first_date_as_comment=False,
)
assert schedule_text.count("WELSEGS") == 1
assert schedule_text.count("COMPSEGS") == 1
def test_generate_schedule_preserves_time_of_day(self, project_with_case_and_well):
"""Event timestamps with a time-of-day must be preserved in the exported DATES keyword (issue #14111)."""
project, case, timeline = project_with_case_and_well