///////////////////////////////////////////////////////////////////////////////// // // 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 "RiaDefines.h" #include "RiaLogging.h" #include "RiaTestDataDirectory.h" #include "ContourMap/RigContourMapCalculator.h" #include "ContourMap/RigContourMapGrid.h" #include "RigCaseCellResultsData.h" #include "RigEclipseCaseData.h" #include "RigEclipseResultAddress.h" #include "RigMainGrid.h" #include "ContourMap/RimStatisticsContourMap.h" #include "ContourMap/RimStatisticsContourMapView.h" #include "RiaResultNames.h" #include "RimCellRangeFilter.h" #include "RimDataFilterCollection.h" #include "RimEclipsePropertyFilter.h" #include "RimEclipseResultAddress.h" #include "RimEclipseResultCase.h" #include "RimEclipseResultDefinition.h" #include "RimGridCalculation.h" #include "RimGridCalculationCollection.h" #include "RimGridCalculationVariable.h" #include "RimProject.h" #include "RimReservoirGridEnsemble.h" #include "cafPdmChildField.h" #include "cafPdmDefaultObjectFactory.h" #include "cafPdmField.h" #include "cafPdmPtrField.h" #include "cafPdmXmlObjectHandle.h" #include #include #include #include namespace { using GridModeType = RimReservoirGridEnsembleBase::GridModeType; using GridImportMode = RimStatisticsContourMap::GridImportMode; RimEclipseResultCase* openBruggeRealization( const QString& realizationFolder, const QString& fileName ) { QDir baseFolder( TEST_MODEL_DIR ); if ( !baseFolder.cd( QString( "Case_with_10_timesteps/%1" ).arg( realizationFolder ) ) ) return nullptr; QString filePath = baseFolder.absoluteFilePath( fileName ); if ( !QFile::exists( filePath ) ) return nullptr; auto eclipseCase = std::make_unique(); eclipseCase->setCaseInfo( realizationFolder, filePath ); if ( !eclipseCase->openEclipseGridFile() ) return nullptr; return eclipseCase.release(); } std::unique_ptr createBruggeEnsemble( GridModeType gridMode ) { auto ensemble = std::make_unique(); for ( const auto& [folder, fileName] : std::vector>{ { "Real0", "BRUGGE_0000.EGRID" }, { "Real10", "BRUGGE_0010.EGRID" }, { "Real30", "BRUGGE_0030.EGRID" }, { "Real40", "BRUGGE_0040.EGRID" } } ) { auto* eclipseCase = openBruggeRealization( folder, fileName ); if ( !eclipseCase ) return nullptr; ensemble->addCase( eclipseCase ); } auto* autoDetectField = dynamic_cast*>( ensemble->findField( "AutoDetectGridType" ) ); auto* gridModeField = dynamic_cast>*>( ensemble->findField( "GridMode" ) ); if ( !autoDetectField || !gridModeField ) return nullptr; autoDetectField->setValue( false ); gridModeField->setValue( gridMode ); return ensemble; } // "synthetic_ensemble_with_dynamic_filter" has unified .UNRST restart files (unlike Case_with_10_timesteps, // which uses the legacy per-step .X#### format). Computing statistics forces the OPM_COMMON grid reader, // which only supports unified restart files, so a dynamic (e.g. SWAT) filter needs this fixture instead. // Real1-3 have their PORO and SWAT values scaled by a distinct per-realization factor relative to Real0, // so the ensemble also exercises real inter-realization variability, not just identical copies. std::unique_ptr createSyntheticDynamicFilterEnsemble() { auto ensemble = std::make_unique(); QDir baseFolder( TEST_MODEL_DIR ); if ( !baseFolder.cd( "synthetic_ensemble_with_dynamic_filter" ) ) return nullptr; for ( const auto& folder : { "Real0", "Real1", "Real2", "Real3" } ) { QDir realizationFolder( baseFolder ); if ( !realizationFolder.cd( folder ) ) return nullptr; QString filePath = realizationFolder.absoluteFilePath( "SYNTH.EGRID" ); if ( !QFile::exists( filePath ) ) return nullptr; auto eclipseCase = std::make_unique(); eclipseCase->setCaseInfo( folder, filePath ); if ( !eclipseCase->openEclipseGridFile() ) return nullptr; ensemble->addCase( eclipseCase.release() ); } auto* autoDetectField = dynamic_cast*>( ensemble->findField( "AutoDetectGridType" ) ); auto* gridModeField = dynamic_cast>*>( ensemble->findField( "GridMode" ) ); if ( !autoDetectField || !gridModeField ) return nullptr; autoDetectField->setValue( false ); gridModeField->setValue( GridModeType::SHARED_GRID ); return ensemble; } RimStatisticsContourMap* addPoroContourMap( RimReservoirGridEnsemble* ensemble, GridImportMode importMode ) { auto* map = new RimStatisticsContourMap; ensemble->addStatisticsContourMap( map ); map->setEclipseCase( ensemble->cases().front() ); map->setGridImportMode( importMode ); auto* aggregationField = dynamic_cast>*>( map->findField( "ResultAggregation" ) ); auto* resultDefinitionField = dynamic_cast*>( map->findField( "ResultDefinition" ) ); if ( !aggregationField || !resultDefinitionField ) return nullptr; aggregationField->setValue( RigContourMapCalculator::MEAN ); ( *resultDefinitionField )()->setResultType( RiaDefines::ResultCatType::STATIC_NATIVE ); ( *resultDefinitionField )()->setResultVariable( "PORO" ); return map; } void setDataFilter( RimStatisticsContourMap* map, RimCellFilter* filter ) { auto* dataFilterField = dynamic_cast*>( map->findField( "DataFilter" ) ); ASSERT_TRUE( dataFilterField != nullptr ); dataFilterField->setValue( filter ); } // Filter including the top K layer of the grid only RimCellRangeFilter* addTopLayerFilter( RimReservoirGridEnsemble* ensemble ) { const RigMainGrid* mainGrid = ensemble->cases().front()->eclipseCaseData()->mainGrid(); auto* rangeFilter = ensemble->dataFilterCollection()->addNewRangeFilter(); rangeFilter->startIndexI = 1; rangeFilter->startIndexJ = 1; rangeFilter->startIndexK = 1; rangeFilter->cellCountI = static_cast( mainGrid->cellCountI() ); rangeFilter->cellCountJ = static_cast( mainGrid->cellCountJ() ); rangeFilter->cellCountK = 1; return rangeFilter; } size_t countValidValues( const std::vector& values ) { return std::count_if( values.begin(), values.end(), []( double v ) { return std::isfinite( v ); } ); } size_t countDifferentValues( const std::vector& lhs, const std::vector& rhs ) { size_t differentCount = 0; for ( size_t i = 0; i < lhs.size(); i++ ) { const bool lhsValid = std::isfinite( lhs[i] ); const bool rhsValid = std::isfinite( rhs[i] ); if ( lhsValid != rhsValid || ( lhsValid && std::abs( lhs[i] - rhs[i] ) > 1.0e-9 ) ) differentCount++; } return differentCount; } //-------------------------------------------------------------------------------------------------- /// Compute the mean PORO contour map with and without a top layer data filter, and verify that the /// filter changes the result //-------------------------------------------------------------------------------------------------- std::vector verifyDataFilterIsApplied( GridModeType gridMode, GridImportMode importMode ) { auto ensemble = createBruggeEnsemble( gridMode ); if ( !ensemble ) ADD_FAILURE() << "Failed to create Brugge ensemble"; if ( !ensemble ) return {}; auto* map = addPoroContourMap( ensemble.get(), importMode ); if ( !map ) ADD_FAILURE() << "Failed to create contour map"; if ( !map ) return {}; RimStatisticsContourMap::computeStatisticsForMaps( { map } ); const auto unfiltered = map->result( 0, RimStatisticsContourMap::StatisticsType::MEAN ); EXPECT_GT( countValidValues( unfiltered ), 0u ); setDataFilter( map, addTopLayerFilter( ensemble.get() ) ); RimStatisticsContourMap::computeStatisticsForMaps( { map } ); const auto filtered = map->result( 0, RimStatisticsContourMap::StatisticsType::MEAN ); EXPECT_EQ( unfiltered.size(), filtered.size() ); if ( unfiltered.size() != filtered.size() ) return {}; EXPECT_GT( countValidValues( filtered ), 0u ); EXPECT_GT( countDifferentValues( unfiltered, filtered ), 0u ); // An EXCLUDE filter of the same layer must give yet another result auto* excludeFilter = addTopLayerFilter( ensemble.get() ); excludeFilter->setFilterMode( RimCellFilter::EXCLUDE ); setDataFilter( map, excludeFilter ); RimStatisticsContourMap::computeStatisticsForMaps( { map } ); const auto excluded = map->result( 0, RimStatisticsContourMap::StatisticsType::MEAN ); EXPECT_GT( countDifferentValues( filtered, excluded ), 0u ); return filtered; } } // namespace //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, DataFilterSharedGrid ) { verifyDataFilterIsApplied( GridModeType::SHARED_GRID, GridImportMode::SHARED_GRID ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, DataFilterIndividualGridImport ) { verifyDataFilterIsApplied( GridModeType::SHARED_GRID, GridImportMode::INDIVIDUAL_GRIDS ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, DataFilterIndividualGridEnsemble ) { verifyDataFilterIsApplied( GridModeType::INDIVIDUAL_GRIDS, GridImportMode::SHARED_GRID ); } //-------------------------------------------------------------------------------------------------- /// The Brugge realizations have identical grids, so the shared and individual grid code paths must /// give the same filtered result //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, DataFilterSharedAndIndividualGridsAgree ) { const auto shared = verifyDataFilterIsApplied( GridModeType::SHARED_GRID, GridImportMode::SHARED_GRID ); const auto individual = verifyDataFilterIsApplied( GridModeType::INDIVIDUAL_GRIDS, GridImportMode::INDIVIDUAL_GRIDS ); ASSERT_FALSE( shared.empty() ); ASSERT_EQ( shared.size(), individual.size() ); EXPECT_EQ( 0u, countDifferentValues( shared, individual ) ); } //-------------------------------------------------------------------------------------------------- /// An active data filter must be shown as overlay text in the contour map view (top left corner), /// similar to the cell/property filter label shown in a regular 3d view. //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, ActiveDataFilterIsShownInViewOverlayText ) { auto ensemble = createBruggeEnsemble( GridModeType::SHARED_GRID ); ASSERT_TRUE( ensemble != nullptr ); auto* map = addPoroContourMap( ensemble.get(), GridImportMode::SHARED_GRID ); ASSERT_TRUE( map != nullptr ); auto view = std::make_unique(); view->setStatisticsContourMap( map ); EXPECT_TRUE( view->activeFiltersDisplayText().isEmpty() ); auto* filter = addTopLayerFilter( ensemble.get() ); filter->setName( "Top Layer" ); setDataFilter( map, filter ); EXPECT_EQ( QString( "Top Layer" ), view->activeFiltersDisplayText() ); filter->setActive( false ); EXPECT_TRUE( view->activeFiltersDisplayText().isEmpty() ); } //-------------------------------------------------------------------------------------------------- /// A static mapped property (PORO) with an active dynamic filter (SWAT) must still produce a result /// per selected time step, since the filter's visible cells change over time even if PORO does not. //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, StaticResultWithDynamicFilterProducesResultsPerTimeStep ) { auto ensemble = createSyntheticDynamicFilterEnsemble(); ASSERT_TRUE( ensemble != nullptr ); auto* map = addPoroContourMap( ensemble.get(), GridImportMode::SHARED_GRID ); ASSERT_TRUE( map != nullptr ); // The synthetic fixture's unified restart file has 13 time steps (indices 0..12) const int lastTimeStep = 12; auto* swatFilter = ensemble->dataFilterCollection()->addNewPropertyFilter(); swatFilter->resultDefinition()->setResultType( RiaDefines::ResultCatType::DYNAMIC_NATIVE ); swatFilter->resultDefinition()->setResultVariable( RiaResultNames::swat() ); auto* lowerField = dynamic_cast*>( swatFilter->findField( "LowerBound" ) ); auto* upperField = dynamic_cast*>( swatFilter->findField( "UpperBound" ) ); ASSERT_TRUE( lowerField && upperField ); lowerField->setValue( 0.25 ); upperField->setValue( 0.255 ); setDataFilter( map, swatFilter ); auto* selectedTimeStepsField = dynamic_cast>*>( map->findField( "SelectedTimeSteps" ) ); ASSERT_TRUE( selectedTimeStepsField != nullptr ); selectedTimeStepsField->setValue( { 0, lastTimeStep } ); // A dynamic filter must make selectedTimeSteps() return the full user selection, even for static PORO EXPECT_EQ( ( std::vector{ 0, lastTimeStep } ), map->selectedTimeSteps() ); RimStatisticsContourMap::computeStatisticsForMaps( { map } ); const auto resultAtFirstStep = map->result( 0, RimStatisticsContourMap::StatisticsType::MEAN ); const auto resultAtLastStep = map->result( 1, RimStatisticsContourMap::StatisticsType::MEAN ); EXPECT_GT( countValidValues( resultAtFirstStep ), 0u ); EXPECT_GT( countValidValues( resultAtLastStep ), 0u ); ASSERT_EQ( resultAtFirstStep.size(), resultAtLastStep.size() ); // The filter's visible cells differ per time step (SWAT changes), so the aggregated PORO results differ too EXPECT_GT( countDifferentValues( resultAtFirstStep, resultAtLastStep ), 0u ); } //-------------------------------------------------------------------------------------------------- /// A GENERATED result only reports as dynamic once it has actually been computed for the case, since /// hasDynamicResult() inspects the case's in-memory result entry. Before that, selectedTimeSteps() /// collapses to a single time step, so the disk cache key differs between sessions, and the cache is /// always rejected. //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, GeneratedDynamicResultIsNotReportedAsDynamicBeforeItIsComputed ) { auto ensemble = createSyntheticDynamicFilterEnsemble(); ASSERT_TRUE( ensemble != nullptr ); RimEclipseCase* primaryCase = ensemble->cases().front(); RimGridCalculation calculation; calculation.setExpression( "GenSwat := x" ); auto* variable = dynamic_cast( calculation.addVariable( "x" ) ); ASSERT_TRUE( variable != nullptr ); RimEclipseResultAddress sourceAddress; sourceAddress.setEclipseCase( primaryCase ); sourceAddress.setResultType( RiaDefines::ResultCatType::DYNAMIC_NATIVE ); sourceAddress.setResultName( RiaResultNames::swat() ); variable->setEclipseResultAddress( sourceAddress ); auto* destinationEnsembleField = dynamic_cast*>( calculation.findField( "DestinationEnsemble" ) ); ASSERT_TRUE( destinationEnsembleField != nullptr ); destinationEnsembleField->setValue( ensemble.get() ); auto* map = addPoroContourMap( ensemble.get(), GridImportMode::SHARED_GRID ); ASSERT_TRUE( map != nullptr ); auto* resultDefinitionField = dynamic_cast*>( map->findField( "ResultDefinition" ) ); ASSERT_TRUE( resultDefinitionField != nullptr ); ( *resultDefinitionField )()->setResultType( RiaDefines::ResultCatType::GENERATED ); ( *resultDefinitionField )()->setResultVariable( "GenSwat" ); // The synthetic fixture's unified restart file has 13 time steps (indices 0..12) const int lastTimeStep = 12; auto* selectedTimeStepsField = dynamic_cast>*>( map->findField( "SelectedTimeSteps" ) ); ASSERT_TRUE( selectedTimeStepsField != nullptr ); selectedTimeStepsField->setValue( { 0, lastTimeStep } ); // Before the calculation has run, the GENERATED result is not yet reported as dynamic, so // selectedTimeSteps() collapses to a single time step. EXPECT_FALSE( ( *resultDefinitionField )()->hasDynamicResult() ); EXPECT_EQ( ( std::vector{ 0 } ), map->selectedTimeSteps() ); ASSERT_TRUE( calculation.calculateForCases( { primaryCase }, nullptr, std::nullopt, true ) ); // Once computed, the GENERATED result is reported as dynamic, and selectedTimeSteps() reports the // full user selection. EXPECT_TRUE( ( *resultDefinitionField )()->hasDynamicResult() ); EXPECT_EQ( ( std::vector{ 0, lastTimeStep } ), map->selectedTimeSteps() ); } //-------------------------------------------------------------------------------------------------- /// ensureResultsComputed() must make the GENERATED result known for the primary case before checking /// the disk cache, otherwise the cache key is unstable across sessions and the cache is always /// rejected. //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, EnsureResultsComputedMakesGeneratedResultDynamicBeforeCacheCheck ) { auto ensemble = createSyntheticDynamicFilterEnsemble(); ASSERT_TRUE( ensemble != nullptr ); RimProject* project = RimProject::current(); ASSERT_TRUE( project != nullptr ); auto* calculation = dynamic_cast( project->gridCalculationCollection()->addCalculation( false ) ); ASSERT_TRUE( calculation != nullptr ); calculation->setExpression( "GenSwat := x" ); auto* variable = dynamic_cast( calculation->addVariable( "x" ) ); ASSERT_TRUE( variable != nullptr ); RimEclipseCase* primaryCase = ensemble->cases().front(); RimEclipseResultAddress sourceAddress; sourceAddress.setEclipseCase( primaryCase ); sourceAddress.setResultType( RiaDefines::ResultCatType::DYNAMIC_NATIVE ); sourceAddress.setResultName( RiaResultNames::swat() ); variable->setEclipseResultAddress( sourceAddress ); auto* destinationEnsembleField = dynamic_cast*>( calculation->findField( "DestinationEnsemble" ) ); ASSERT_TRUE( destinationEnsembleField != nullptr ); destinationEnsembleField->setValue( ensemble.get() ); auto* map = addPoroContourMap( ensemble.get(), GridImportMode::SHARED_GRID ); ASSERT_TRUE( map != nullptr ); auto* resultDefinitionField = dynamic_cast*>( map->findField( "ResultDefinition" ) ); ASSERT_TRUE( resultDefinitionField != nullptr ); ( *resultDefinitionField )()->setResultType( RiaDefines::ResultCatType::GENERATED ); ( *resultDefinitionField )()->setResultVariable( "GenSwat" ); // The synthetic fixture's unified restart file has 13 time steps (indices 0..12) const int lastTimeStep = 12; auto* selectedTimeStepsField = dynamic_cast>*>( map->findField( "SelectedTimeSteps" ) ); ASSERT_TRUE( selectedTimeStepsField != nullptr ); selectedTimeStepsField->setValue( { 0, lastTimeStep } ); // Sanity check: before anything has run, the GENERATED result is not yet known on the primary case EXPECT_FALSE( ( *resultDefinitionField )()->hasDynamicResult() ); map->ensureResultsComputed(); // The GENERATED result is now known, so selectedTimeSteps() reports the full selection. EXPECT_TRUE( ( *resultDefinitionField )()->hasDynamicResult() ); EXPECT_EQ( ( std::vector{ 0, lastTimeStep } ), map->selectedTimeSteps() ); const auto resultAtFirstStep = map->result( 0, RimStatisticsContourMap::StatisticsType::MEAN ); const auto resultAtLastStep = map->result( 1, RimStatisticsContourMap::StatisticsType::MEAN ); EXPECT_GT( countValidValues( resultAtFirstStep ), 0u ); EXPECT_GT( countValidValues( resultAtLastStep ), 0u ); project->gridCalculationCollection()->deleteCalculation( calculation ); } namespace { //-------------------------------------------------------------------------------------------------- /// Minimal logger capturing warning messages, used to verify the obsolete formation filter warning. //-------------------------------------------------------------------------------------------------- class RecordingLogger : public RiaLogger { public: int level() const override { return static_cast( RILogLevel::RI_LL_WARNING ); } void setLevel( int ) override {} void error( const char* ) override {} void warning( const char* message ) override { warnings.push_back( message ); } void info( const char* ) override {} void debug( const char* ) override {} std::vector warnings; }; } // namespace //-------------------------------------------------------------------------------------------------- /// Loading a project with the obsolete formation filter fields set (#14710) should warn, not silently drop them. //-------------------------------------------------------------------------------------------------- TEST( RimStatisticsContourMapTest, ObsoleteFormationFilterWarnsOnLoad ) { auto sourceMap = std::make_unique(); sourceMap->setName( "MyContourMap" ); auto* enableField = dynamic_cast*>( sourceMap->findField( "EnableFormationFilter" ) ); auto* formationsField = dynamic_cast>*>( sourceMap->findField( "Formations" ) ); ASSERT_TRUE( enableField != nullptr ); ASSERT_TRUE( formationsField != nullptr ); enableField->setValue( true ); formationsField->setValue( { "Zone1", "Zone2" } ); // The obsolete fields are marked non-writable so current projects never persist them again; force // writing here to simulate an XML string produced by an older ResInsight version. enableField->xmlCapability()->setIOWritable( true ); formationsField->xmlCapability()->setIOWritable( true ); QString xml = sourceMap->xmlCapability()->writeObjectToXmlString(); auto* recordingLogger = new RecordingLogger(); RiaLogging::appendLoggerInstance( std::unique_ptr( recordingLogger ) ); // Simulate the project having been saved by an older ResInsight version, so the version guard in // initAfterRead() lets the obsolete-field warning through. auto* versionField = dynamic_cast*>( RimProject::current()->findField( "ProjectFileVersionString" ) ); ASSERT_TRUE( versionField != nullptr ); QString originalVersion = versionField->value(); versionField->setValue( "2026.06.0" ); auto readMap = std::make_unique(); readMap->xmlCapability()->readObjectFromXmlString( xml, caf::PdmDefaultObjectFactory::instance() ); readMap->xmlCapability()->resolveReferencesRecursively(); readMap->xmlCapability()->initAfterReadRecursively(); versionField->setValue( originalVersion ); bool foundWarning = false; for ( const auto& message : recordingLogger->warnings ) { if ( message.find( "Zone1, Zone2" ) != std::string::npos ) foundWarning = true; } EXPECT_TRUE( foundWarning ); }