From 0eba9e8ef7dd11c61981a879fca871dc973558f2 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Wed, 16 Sep 2026 12:03:19 +0200 Subject: [PATCH] #14752 Well Target Mapping: use view-independent data filters --- .../ProjectDataModel/RimWellTargetMapping.cpp | 54 ++-- .../ProjectDataModel/RimWellTargetMapping.h | 9 +- .../Well/RigWellTargetMapping.cpp | 28 +- .../Well/RigWellTargetMapping.h | 3 +- ApplicationLibCode/UnitTests/CMakeLists.txt | 1 + .../UnitTests/RimWellTargetMapping-Test.cpp | 260 ++++++++++++++++++ 6 files changed, 312 insertions(+), 43 deletions(-) create mode 100644 ApplicationLibCode/UnitTests/RimWellTargetMapping-Test.cpp diff --git a/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.cpp b/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.cpp index 9d981bc1af..7e5971546b 100644 --- a/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.cpp +++ b/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.cpp @@ -20,11 +20,9 @@ #include "RiaGuiApplication.h" #include "RiaLogging.h" -#include "RiaOptionItemFactory.h" #include "RiaPorosityModel.h" #include "RiaResultNames.h" -#include "RigActiveCellInfo.h" #include "RiuMainWindow.h" #include "RigCaseCellResultsData.h" @@ -34,6 +32,8 @@ #include "RigStatisticsMath.h" #include "Well/RigWellTargetMapping.h" +#include "RimCellFilter.h" +#include "RimDataFilterCollection.h" #include "RimEclipseCase.h" #include "RimEclipseCaseCollection.h" #include "RimEclipseCaseEnsemble.h" @@ -160,7 +160,7 @@ RimWellTargetMapping::RimWellTargetMapping() "How much to increase the bounding box of the first case to cover for any grid size differences across the " "ensemble." ); - CAF_PDM_InitFieldNoDefault( &m_filterView, "FilterView", "Filter By View" ); + CAF_PDM_InitFieldNoDefault( &m_dataFilter, "DataFilter", "Data Filter" ); CAF_PDM_InitFieldNoDefault( &m_ensembleStatisticsCase, "EnsembleStatisticsCase", "Ensemble Statistics Case" ); @@ -226,15 +226,15 @@ QList RimWellTargetMapping::calculateValueOptions( const caf::AppEnum::setEnumSubset( &m_volumesType, findAvailableVolumesTypes( fc ) ); } } - else if ( fieldNeedingOptions == &m_filterView ) + else if ( fieldNeedingOptions == &m_dataFilter ) { options.push_back( caf::PdmOptionItemInfo( "None", nullptr ) ); - if ( auto fc = firstCase() ) + if ( auto collection = dataFilterCollection() ) { - for ( const auto& view : fc->views() ) + for ( auto filter : collection->filters() ) { - RiaOptionItemFactory::appendOptionItemFromViewNameAndCaseName( view, &options ); + options.push_back( caf::PdmOptionItemInfo( filter->name(), filter ) ); } } } @@ -498,7 +498,7 @@ void RimWellTargetMapping::defineUiOrdering( QString uiConfigName, caf::PdmUiOrd auto gridEnsemble = firstAncestorOrThisOfType(); auto hasEnsembleParent = firstAncestorOrThisOfType() != nullptr || gridEnsemble != nullptr; - if ( !hasEnsembleParent ) uiOrdering.add( &m_filterView ); + uiOrdering.add( &m_dataFilter ); caf::PdmUiGroup* minimumCellValuesGroup = uiOrdering.addNewGroup( "Minimum Cell Values" ); if ( showOilOptions ) minimumCellValuesGroup->add( &m_saturationOil ); @@ -560,43 +560,25 @@ RigWellTargetMapping::ClusteringLimits RimWellTargetMapping::getClusteringLimits .maxNumTargets = m_maxNumTargets, .maxIterations = m_maxIterations, .filterAddress = m_resultDefinition->eclipseResultAddress(), - .filter = getVisibilityFilter() }; + .dataFilter = m_dataFilter() }; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -std::vector RimWellTargetMapping::getVisibilityFilter() const +RimDataFilterCollection* RimWellTargetMapping::dataFilterCollection() const { - std::vector filter = {}; - - // Visibility filter is only valid in the single case setting - auto hasEnsembleParent = firstAncestorOrThisOfType() != nullptr || - firstAncestorOrThisOfType() != nullptr; - if ( !hasEnsembleParent ) + if ( auto ensemble = firstAncestorOrThisOfType() ) { - auto fc = firstCase(); - if ( m_filterView() && fc && fc->eclipseCaseData() ) - { - cvf::ref visibility = m_filterView->currentTotalCellVisibility(); - - auto activeReservoirCellIndices = - fc->eclipseCaseData()->activeCellInfo( RiaDefines::PorosityModelType::MATRIX_MODEL )->activeReservoirCellIndices(); - int numActiveCells = static_cast( activeReservoirCellIndices.size() ); - - filter.resize( numActiveCells, std::numeric_limits::infinity() ); - - // Create binary filter for active cells: 1.0 can be used, 0.0 is filtered out. -#pragma omp parallel for - for ( int i = 0; i < numActiveCells; i++ ) - { - const auto reservoirCellIndex = activeReservoirCellIndices[i]; - filter[i] = visibility->val( reservoirCellIndex.value() ) ? 1.0 : 0.0; - } - } + return ensemble->dataFilterCollection(); } - return filter; + if ( auto eclipseCase = firstAncestorOrThisOfType() ) + { + return eclipseCase->dataFilterCollection(); + } + + return nullptr; } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.h b/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.h index 77d67f534b..95745658b0 100644 --- a/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.h +++ b/ApplicationLibCode/ProjectDataModel/RimWellTargetMapping.h @@ -30,7 +30,8 @@ class RimEclipseResultDefinition; class RimEclipseCase; -class RimEclipseView; +class RimCellFilter; +class RimDataFilterCollection; //================================================================================================== /// @@ -67,12 +68,12 @@ private: void generateEnsembleStatistics(); cvf::Vec3st getResultGridCellCount() const; - RimEclipseCase* firstCase() const; + RimEclipseCase* firstCase() const; + RimDataFilterCollection* dataFilterCollection() const; static std::vector findAvailableVolumesTypes( RimEclipseCase* eclipseCase ); RigWellTargetMapping::ClusteringLimits getClusteringLimits() const; - std::vector getVisibilityFilter() const; void resetMinimumCellValuesToDefault(); void onGenerateButtonClicked(); @@ -106,7 +107,7 @@ private: caf::PdmField m_cellCountJ; caf::PdmField m_cellCountK; - caf::PdmPtrField m_filterView; + caf::PdmPtrField m_dataFilter; caf::PdmChildField m_ensembleStatisticsCase; diff --git a/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.cpp b/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.cpp index cc0c2ea61e..147114d378 100644 --- a/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.cpp +++ b/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.cpp @@ -40,6 +40,7 @@ #include "RigNNCData.h" #include "RigStatisticsMath.h" +#include "RimCellFilterTools.h" #include "RimEclipseCase.h" #include "RimEclipseView.h" #include "RimProject.h" @@ -146,9 +147,32 @@ void RigWellTargetMapping::generateCandidates( RimEclipseCase* eclips } std::vector filterVector; - if ( !limits.filter.empty() ) + if ( limits.dataFilter ) { - filterVector = limits.filter; + // Evaluate for each realization after opening its grid, using the mapping's time step. + auto visibility = RimCellFilterTools::computeReservoirCellVisibility( limits.dataFilter, eclipseCase, timeStepIdx ); + if ( visibility.notNull() ) + { + auto activeReservoirCellIndices = + caseData->activeCellInfo( RiaDefines::PorosityModelType::MATRIX_MODEL )->activeReservoirCellIndices(); + const int numActiveCells = static_cast( activeReservoirCellIndices.size() ); + filterVector.resize( numActiveCells ); + + // Convert reservoir visibility to the active-cell indexing used by clustering. +#pragma omp parallel for + for ( int i = 0; i < numActiveCells; i++ ) + { + filterVector[i] = visibility->val( activeReservoirCellIndices[i].value() ) ? 1.0 : 0.0; + } + } + else + { + // Fall through to the unfiltered path below so all realizations in an ensemble get a result, + // consistent with how an invalid filterAddress is handled. + RiaLogging::warning( + std::format( "Unable to evaluate well target mapping data filter for case '{}'. Continuing without filter.", + eclipseCase->caseUserDescription().toStdString() ) ); + } } else if ( limits.filterAddress.isValid() ) { diff --git a/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.h b/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.h index 115df2597f..c0eeb673a9 100644 --- a/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.h +++ b/ApplicationLibCode/ReservoirDataModel/Well/RigWellTargetMapping.h @@ -26,6 +26,7 @@ class RigCaseCellResultsData; class RimEclipseCase; +class RimCellFilter; class RimRegularGridCase; class RigFloodingSettings; @@ -67,7 +68,7 @@ public: int maxNumTargets; int maxIterations; RigEclipseResultAddress filterAddress; - std::vector filter; + RimCellFilter* dataFilter = nullptr; }; static void generateCandidates( RimEclipseCase* eclipseCase, diff --git a/ApplicationLibCode/UnitTests/CMakeLists.txt b/ApplicationLibCode/UnitTests/CMakeLists.txt index 3e773ba912..1b28545be0 100644 --- a/ApplicationLibCode/UnitTests/CMakeLists.txt +++ b/ApplicationLibCode/UnitTests/CMakeLists.txt @@ -90,6 +90,7 @@ set(SOURCE_UNITTEST_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWellPathValve-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWellPathGeometryDef-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWellRftPlot-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWellTargetMapping-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimDataFilterCollection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimFilterInViewCollection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimSummaryCaseCollection-Test.cpp diff --git a/ApplicationLibCode/UnitTests/RimWellTargetMapping-Test.cpp b/ApplicationLibCode/UnitTests/RimWellTargetMapping-Test.cpp new file mode 100644 index 0000000000..a1e51ef8fd --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimWellTargetMapping-Test.cpp @@ -0,0 +1,260 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "gtest/gtest.h" + +#include "RiaPorosityModel.h" +#include "RiaResultNames.h" +#include "RiaTestDataDirectory.h" + +#include "RigActiveCellInfo.h" +#include "RigCaseCellResultsData.h" +#include "RigEclipseCaseData.h" +#include "RigFormationNames.h" +#include "RigMainGrid.h" + +#include "RimCellFilterTools.h" +#include "RimCellRangeFilter.h" +#include "RimDataFilterCollection.h" +#include "RimEclipsePropertyFilter.h" +#include "RimEclipseResultCase.h" +#include "RimEclipseResultDefinition.h" +#include "RimRegularGridCase.h" +#include "RimReservoirGridEnsemble.h" +#include "RimWellTargetMapping.h" + +#include + +#include +#include +#include + +namespace +{ +class TestableWellTargetMapping : public RimWellTargetMapping +{ +public: + using RimWellTargetMapping::calculateValueOptions; + using RimWellTargetMapping::initAfterRead; +}; + +std::unique_ptr openCase( const QString& realization, const QString& fileName ) +{ + auto eclipseCase = std::make_unique(); + auto filePath = QDir( TEST_MODEL_DIR ).absoluteFilePath( "Case_with_10_timesteps/" + realization + "/" + fileName ); + eclipseCase->setCaseInfo( realization, filePath ); + if ( !eclipseCase->openEclipseGridFile() ) return nullptr; + return eclipseCase; +} + +template +void setField( caf::PdmObject& object, const QString& keyword, const T& value ) +{ + auto field = dynamic_cast*>( object.findField( keyword ) ); + ASSERT_NE( nullptr, field ); + field->setValue( value ); +} + +void expectFilteredClusters( RimEclipseCase* eclipseCase, RimCellFilter* filter, size_t timeStep ) +{ + auto visibility = RimCellFilterTools::computeReservoirCellVisibility( filter, eclipseCase, timeStep ); + ASSERT_TRUE( visibility.notNull() ); + auto results = eclipseCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL ); + RigEclipseResultAddress address( RiaDefines::ResultCatType::GENERATED, RigWellTargetMapping::wellTargetResultName() ); + const auto& clusters = results->cellScalarResults( address, timeStep ); + auto activeIndices = results->activeCellInfo()->activeReservoirCellIndices(); + ASSERT_EQ( activeIndices.size(), clusters.size() ); + + size_t clusteredCells = 0; + size_t excludedCells = 0; + for ( size_t i = 0; i < clusters.size(); ++i ) + { + const bool isClustered = std::isfinite( clusters[i] ) && clusters[i] > 0.0; + if ( isClustered ) ++clusteredCells; + if ( !visibility->val( activeIndices[i].value() ) ) + { + ++excludedCells; + EXPECT_FALSE( isClustered ); + } + } + EXPECT_GT( clusteredCells, 0u ); + EXPECT_GT( excludedCells, 0u ); +} +} // namespace + +TEST( RimWellTargetMappingTest, FilterOptionsUseOwningCase ) +{ + RimEclipseResultCase eclipseCase; + auto mapping = new TestableWellTargetMapping; + eclipseCase.addWellTargetMapping( mapping ); + auto filter = eclipseCase.dataFilterCollection()->addNewRangeFilter(); + filter->setName( "Case filter" ); + + auto options = mapping->calculateValueOptions( mapping->findField( "DataFilter" ) ); + ASSERT_EQ( 2, options.size() ); + EXPECT_EQ( "None", options[0].optionUiText() ); + EXPECT_EQ( caf::PdmOptionItemInfo( "Case filter", filter ).value(), options[1].value() ); +} + +TEST( RimWellTargetMappingTest, FilterOptionsUseOwningEnsemble ) +{ + RimReservoirGridEnsemble ensemble; + auto eclipseCase = new RimEclipseResultCase; + ensemble.addCase( eclipseCase ); + eclipseCase->dataFilterCollection()->addNewRangeFilter(); + auto mapping = new TestableWellTargetMapping; + ensemble.addWellTargetMapping( mapping ); + auto filter = ensemble.dataFilterCollection()->addNewRangeFilter(); + filter->setName( "Ensemble filter" ); + + auto options = mapping->calculateValueOptions( mapping->findField( "DataFilter" ) ); + ASSERT_EQ( 2, options.size() ); + EXPECT_EQ( "None", options[0].optionUiText() ); + EXPECT_EQ( caf::PdmOptionItemInfo( "Ensemble filter", filter ).value(), options[1].value() ); +} + +TEST( RimWellTargetMappingTest, ClearingFilterRestoresCandidates ) +{ + auto eclipseCase = openCase( "Real0", "BRUGGE_0000.EGRID" ); + ASSERT_NE( nullptr, eclipseCase ); + auto filter = eclipseCase->dataFilterCollection()->addNewPropertyFilter(); + filter->resultDefinition()->setResultType( RiaDefines::ResultCatType::DYNAMIC_NATIVE ); + filter->resultDefinition()->setResultVariable( RiaResultNames::swat() ); + setField( *filter, "LowerBound", 0.0 ); + setField( *filter, "UpperBound", 1.0 ); + filter->setFilterMode( RimCellFilter::EXCLUDE ); + + auto mapping = new TestableWellTargetMapping; + eclipseCase->addWellTargetMapping( mapping ); + setField( *mapping, "TimeStep", 5 ); + setField( *mapping, "Iterations", 10 ); + setField( *mapping, "MaxNumTargets", 1 ); + auto filterField = dynamic_cast*>( mapping->findField( "DataFilter" ) ); + ASSERT_NE( nullptr, filterField ); + *filterField = filter; + mapping->initAfterRead(); + + auto results = eclipseCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL ); + RigEclipseResultAddress address( RiaDefines::ResultCatType::GENERATED, RigWellTargetMapping::wellTargetResultName() ); + const auto& clusters = results->cellScalarResults( address, 5 ); + ASSERT_FALSE( clusters.empty() ); + auto isClustered = []( double value ) { return std::isfinite( value ) && value > 0.0; }; + EXPECT_EQ( 0, std::count_if( clusters.begin(), clusters.end(), isClustered ) ); + + *filterField = nullptr; + mapping->initAfterRead(); + const auto& unfilteredClusters = results->cellScalarResults( address, 5 ); + EXPECT_GT( std::count_if( unfilteredClusters.begin(), unfilteredClusters.end(), isClustered ), 0 ); +} + +class RimWellTargetMappingFilterTest : public testing::TestWithParam> +{ +}; + +TEST_P( RimWellTargetMappingFilterTest, FiltersClustersWithoutViews ) +{ + const auto [useEnsemble, useFormations, exclude] = GetParam(); + const size_t timeStep = 5; + auto firstCase = openCase( "Real0", "BRUGGE_0000.EGRID" ); + ASSERT_NE( nullptr, firstCase ); + RimReservoirGridEnsemble ensemble; + auto caseA = firstCase.get(); + std::vector cases{ caseA }; + if ( useEnsemble ) + { + ensemble.addCase( firstCase.release() ); + auto secondCase = openCase( "Real40", "BRUGGE_0040.EGRID" ); + ASSERT_NE( nullptr, secondCase ); + ensemble.addCase( secondCase.release() ); + cases = ensemble.cases(); + } + + auto collection = useEnsemble ? ensemble.dataFilterCollection() : caseA->dataFilterCollection(); + auto filter = collection->addNewPropertyFilter(); + filter->setFilterMode( exclude ? RimCellFilter::EXCLUDE : RimCellFilter::INCLUDE ); + if ( useFormations ) + { + int splitFraction = 1; + for ( auto eclipseCase : cases ) + { + const int layerCount = static_cast( eclipseCase->eclipseCaseData()->mainGrid()->cellCountK() ); + const int splitLayer = layerCount * splitFraction / 3; + RigFormationNames formations; + formations.appendFormationRange( "Upper", 0, splitLayer ); + formations.appendFormationRange( "Lower", splitLayer + 1, layerCount - 1 ); + eclipseCase->eclipseCaseData()->setActiveFormationNames( &formations ); + ++splitFraction; + } + filter->resultDefinition()->setResultType( RiaDefines::ResultCatType::FORMATION_NAMES ); + filter->resultDefinition()->setResultVariable( RiaResultNames::activeFormationNamesResultName() ); + filter->setToDefaultValues(); + setField( *filter, "SelectedValues", std::vector{ 0 } ); + } + else + { + filter->resultDefinition()->setResultType( RiaDefines::ResultCatType::DYNAMIC_NATIVE ); + filter->resultDefinition()->setResultVariable( RiaResultNames::swat() ); + setField( *filter, "LowerBound", 0.3 ); + setField( *filter, "UpperBound", 0.6 ); + } + + if ( useEnsemble ) + { + RigWellTargetMapping::ClusteringLimits limits{}; + limits.maxNumTargets = 1; + limits.maxIterations = 10; + limits.dataFilter = filter; + RigFloodingSettings floodingSettings( RigFloodingSettings::FloodingType::WATER_FLOODING, + 0.0, + RigFloodingSettings::FloodingType::GAS_FLOODING, + 0.0 ); + std::unique_ptr statistics( + RigWellTargetMapping::generateEnsembleCandidates( ensemble.cases(), + timeStep, + cvf::Vec3st( 10, 10, 3 ), + RigWellTargetMapping::VolumeType::OIL, + RigWellTargetMapping::VolumesType::RESERVOIR_VOLUMES_COMPUTED, + RigWellTargetMapping::VolumeResultType::TOTAL, + floodingSettings, + limits, + 0.0, + 0.0 ) ); + ASSERT_NE( nullptr, statistics ); + } + else + { + auto mapping = new TestableWellTargetMapping; + caseA->addWellTargetMapping( mapping ); + auto filterField = dynamic_cast*>( mapping->findField( "DataFilter" ) ); + ASSERT_NE( nullptr, filterField ); + *filterField = filter; + setField( *mapping, "TimeStep", static_cast( timeStep ) ); + setField( *mapping, "Iterations", 10 ); + setField( *mapping, "MaxNumTargets", 1 ); + mapping->initAfterRead(); + } + + for ( auto eclipseCase : cases ) + { + expectFilteredClusters( eclipseCase, filter, timeStep ); + } +} + +INSTANTIATE_TEST_SUITE_P( SingleCaseAndEnsemble, + RimWellTargetMappingFilterTest, + testing::Combine( testing::Bool(), testing::Bool(), testing::Bool() ) );