From c5c0e05012a424af7430ebd14152a6b7467dc1f0 Mon Sep 17 00:00:00 2001 From: Magne Sjaastad Date: Fri, 2 Oct 2026 15:08:30 +0200 Subject: [PATCH] Fix ensemble contour map GENERATED result statistics and caching bugs (#14813) Fixes a set of related bugs affecting ensemble contour maps that use a GENERATED (grid calculation) result, plus a related bug where switching a view's case lost generated results. --- .../ContourMap/RimStatisticsContourMap.cpp | 34 +++ .../ProjectDataModel/RimEclipseView.cpp | 8 + .../ProjectDataModel/RimGridCalculation.cpp | 92 ++++++- .../ProjectDataModel/RimGridCalculation.h | 4 + .../RimGridCalculationCollection.cpp | 23 ++ .../RimGridCalculationCollection.h | 3 +- .../UnitTests/RimGridCalculation-Test.cpp | 245 +++++++++++++++++- .../RimStatisticsContourMap-Test.cpp | 125 +++++++++ 8 files changed, 526 insertions(+), 8 deletions(-) diff --git a/ApplicationLibCode/ProjectDataModel/ContourMap/RimStatisticsContourMap.cpp b/ApplicationLibCode/ProjectDataModel/ContourMap/RimStatisticsContourMap.cpp index 5eb38f534c..c110fd459c 100644 --- a/ApplicationLibCode/ProjectDataModel/ContourMap/RimStatisticsContourMap.cpp +++ b/ApplicationLibCode/ProjectDataModel/ContourMap/RimStatisticsContourMap.cpp @@ -50,6 +50,8 @@ #include "RimEclipseContourMapProjection.h" #include "RimEclipseResultCase.h" #include "RimEclipseResultDefinition.h" +#include "RimGridCalculation.h" +#include "RimGridCalculationCollection.h" #include "RimProject.h" #include "RimReservoirGridEnsemble.h" #include "RimSimWellInViewCollection.h" @@ -90,6 +92,23 @@ void applyDataFilterVisibility( RigEclipseContourMapProjection& projection, RimC projection.setCellVisibility( RimCellFilterTools::computeReservoirCellVisibility( dataFilter, eCase, timeStepIndex ) ); } +//-------------------------------------------------------------------------------------------------- +/// A GENERATED result only exists in memory, so it must be recomputed for each realization while it +/// is open here, as it is otherwise lost when the case is closed again. +//-------------------------------------------------------------------------------------------------- +void ensureGeneratedResultIsComputed( const RimEclipseResultDefinition* resultDefinition, RimEclipseCase* eCase ) +{ + if ( !resultDefinition || resultDefinition->resultType() != RiaDefines::ResultCatType::GENERATED ) return; + + auto project = RimProject::current(); + if ( !project ) return; + + RimGridCalculation* calculation = project->gridCalculationCollection()->findCalculation( resultDefinition->resultVariable() ); + if ( !calculation ) return; + + calculation->calculateForCase( eCase ); +} + //-------------------------------------------------------------------------------------------------- /// Generate one result per selected time step if the mapped property is dynamic or the active data /// filter is dynamic (its visible cells then differ per time step, even for a static property). @@ -775,6 +794,13 @@ void RimStatisticsContourMap::computeStatisticsForMaps( const std::vectoractiveCellInfo( RiaDefines::PorosityModelType::MATRIX_MODEL ); auto resultData = eclipseCaseData->results( RiaDefines::PorosityModelType::MATRIX_MODEL ); + // GENERATED results are not stored on disk, so recompute them for this realization while it is open. + for ( auto& ctx : contexts ) + { + if ( !ctx.active ) continue; + ensureGeneratedResultIsComputed( ctx.map->m_resultDefinition(), eCase ); + } + // Make sure at least one dynamic result this case needs is loaded before asking for its time step // dates: allTimeStepDatesFromEclipseReader() and the loaded-result based fallback below both only // report the full time step count once such a result is known. @@ -1062,6 +1088,14 @@ void RimStatisticsContourMap::ensureResultsComputed() // Use the Compute button to force a recomputation after changing settings. if ( m_contourMapGrid ) return; + // A GENERATED result only reports as dynamic once computed, and the disk cache key depends on that + // via selectedTimeSteps(). Ensure the result is computed for the primary case before checking the + // cache, so the key is stable between save and load. + if ( RimEclipseCase* primaryCase = eclipseCase() ) + { + if ( primaryCase->ensureReservoirCaseIsOpen() ) ensureGeneratedResultIsComputed( m_resultDefinition(), primaryCase ); + } + if ( loadCachedResults() ) return; // Compute all pending sibling contour maps in the same sweep over the ensemble realizations, so diff --git a/ApplicationLibCode/ProjectDataModel/RimEclipseView.cpp b/ApplicationLibCode/ProjectDataModel/RimEclipseView.cpp index a85152f80f..709bf6b6f6 100644 --- a/ApplicationLibCode/ProjectDataModel/RimEclipseView.cpp +++ b/ApplicationLibCode/ProjectDataModel/RimEclipseView.cpp @@ -77,6 +77,7 @@ #include "RimFracture.h" #include "RimFractureTemplateCollection.h" #include "RimGeoMechResultDefinition.h" +#include "RimGridCalculationCollection.h" #include "RimGridCollection.h" #include "RimGridCrossPlotDataSet.h" #include "RimGridView.h" @@ -533,6 +534,13 @@ void RimEclipseView::fieldChangedByUi( const caf::PdmFieldHandle* changedField, previousSumoCase->cancelPendingTransfers(); } + // Grid calculation results are not stored with the case, so compute them before the result + // definitions are updated, otherwise generated results are missing and the view is cleared. + if ( auto project = RimProject::current(); project && project->gridCalculationCollection() ) + { + project->gridCalculationCollection()->ensureCalculationsAreComputed( m_eclipseCase ); + } + propagateEclipseCaseToChildObjects(); // Sync with RimReloadCaseTools::updateAll3dViews diff --git a/ApplicationLibCode/ProjectDataModel/RimGridCalculation.cpp b/ApplicationLibCode/ProjectDataModel/RimGridCalculation.cpp index e0c73256f3..fb6bcc3bfb 100644 --- a/ApplicationLibCode/ProjectDataModel/RimGridCalculation.cpp +++ b/ApplicationLibCode/ProjectDataModel/RimGridCalculation.cpp @@ -227,7 +227,9 @@ RimGridCalculationVariable* RimGridCalculation::createVariable() //-------------------------------------------------------------------------------------------------- bool RimGridCalculation::calculate() { - CloseCasesOpenedForCalculation closeCasesWhenFinished( sourceAndDestinationCases( *this, outputEclipseCases() ) ); + const std::vector calculationCases = casesToCalculate(); + + CloseCasesOpenedForCalculation closeCasesWhenFinished( sourceAndDestinationCases( *this, calculationCases ) ); const bool useCellFilterView = ( m_filterType() == FilterType::CELL_FILTER_VIEW ) && m_cellFilterView() != nullptr; const bool useDataFilter = ( m_filterType() == FilterType::DATA_FILTER ); @@ -250,7 +252,7 @@ bool RimGridCalculation::calculate() if ( inputCase && !inputCase->eclipseCaseData() ) inputCase->ensureReservoirCaseIsOpen(); } - for ( auto calculationCase : outputEclipseCases() ) + for ( auto calculationCase : calculationCases ) { if ( !calculationCase ) continue; @@ -336,8 +338,64 @@ bool RimGridCalculation::calculate() timeSteps = tmp; } + bool evaluateDependentCalculations = true; + const bool ok = calculateForCases( calculationCases, inputValueVisibilityFilter.p(), timeSteps, evaluateDependentCalculations ); + + // When only the ensemble's main case was (re)computed above, other already open realizations keep their + // previously computed (now potentially stale) result. Clear it, so it is lazily recomputed with the + // current expression/filter next time it is needed, instead of being mistaken for up to date. + if ( ok && calculateForEnsemble && !isAggregationExpression() ) + { + for ( RimEclipseCase* otherCase : outputEclipseCases() ) + { + if ( !otherCase || !otherCase->eclipseCaseData() ) continue; + if ( std::find( calculationCases.begin(), calculationCases.end(), otherCase ) != calculationCases.end() ) continue; + + otherCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->clearScalarResult( outputAddress() ); + } + } + + return ok; +} + +//-------------------------------------------------------------------------------------------------- +/// Compute the result for a single case, e.g. when a view is stepped to another case. The case does not +/// have to be an output case, as variables referring to the destination case are evaluated on the case. +//-------------------------------------------------------------------------------------------------- +bool RimGridCalculation::calculateForCase( RimEclipseCase* eclipseCase ) +{ + // Source cases are not necessarily open (e.g. after reopening a project), and validateVariables() + // requires an open case for every variable. Open them here, as calculate() does. + for ( auto inputCase : inputCases() ) + { + if ( !inputCase ) continue; + if ( !inputCase->eclipseCaseData() ) inputCase->ensureReservoirCaseIsOpen(); + if ( !inputCase->eclipseCaseData() ) return false; + } + + // Values from other cases and the cell filter view are mapped cell by cell, requiring equal grid sizes + const bool calculateForEnsemble = m_destinationEnsemble() || m_additionalCasesType == AdditionalCasesType::ENSEMBLE; + for ( auto inputCase : inputCases() ) + { + if ( calculateForEnsemble || inputCase == destinationCase() ) continue; + if ( !eclipseCase->isGridSizeEqualTo( inputCase ) ) return false; + } + + cvf::ref inputValueVisibilityFilter; + if ( m_filterType() == FilterType::CELL_FILTER_VIEW && m_cellFilterView() ) + { + if ( !eclipseCase->isGridSizeEqualTo( dynamic_cast( m_cellFilterView()->ownerCase() ) ) ) return false; + inputValueVisibilityFilter = m_cellFilterView()->currentTotalCellVisibility(); + } + + std::optional> timeSteps; + if ( !m_selectedTimeSteps().empty() ) + { + timeSteps = std::vector( m_selectedTimeSteps().begin(), m_selectedTimeSteps().end() ); + } + bool evaluateDependentCalculations = true; - return calculateForCases( outputEclipseCases(), inputValueVisibilityFilter.p(), timeSteps, evaluateDependentCalculations ); + return calculateForCases( { eclipseCase }, inputValueVisibilityFilter.p(), timeSteps, evaluateDependentCalculations ); } //-------------------------------------------------------------------------------------------------- @@ -724,6 +782,30 @@ bool RimGridCalculation::allSourceCasesAreEqualToDestinationCase() const return true; } +//-------------------------------------------------------------------------------------------------- +/// An aggregation expression (sum/avg/min/max/count) reduces a case's cell values to a single scalar +/// value per case. Unlike per-cell expressions, its result cannot be recomputed later for a single +/// realization on demand, so it must be computed for every realization up front. +//-------------------------------------------------------------------------------------------------- +bool RimGridCalculation::isAggregationExpression() const +{ + return m_expression().contains( "sum" ) || m_expression().contains( "avg" ) || m_expression().contains( "min" ) || + m_expression().contains( "max" ) || m_expression().contains( "count" ); +} + +//-------------------------------------------------------------------------------------------------- +/// For an ensemble destination with a plain per-cell expression, each realization is computed lazily +/// when its result is actually needed, so only the main case needs to be computed here. Aggregation +/// expressions produce the per-realization summary itself and must be computed for every realization. +//-------------------------------------------------------------------------------------------------- +std::vector RimGridCalculation::casesToCalculate() const +{ + const bool calculateForEnsemble = m_destinationEnsemble() || m_additionalCasesType == AdditionalCasesType::ENSEMBLE; + if ( calculateForEnsemble && !isAggregationExpression() ) return { destinationCase() }; + + return outputEclipseCases(); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -1025,9 +1107,7 @@ bool RimGridCalculation::calculateForCases( const std::vector& } const bool isMultipleCasesPresent = calculationCases.size() > 1; - const bool hasAggregationExpression = m_expression().contains( "sum" ) || m_expression().contains( "avg" ) || - m_expression().contains( "min" ) || m_expression().contains( "max" ) || - m_expression().contains( "count" ); + const bool hasAggregationExpression = isAggregationExpression(); // If multiple cases are present, release memory after data is extracted to avoid memory issues. m_releaseMemoryAfterDataIsExtracted = isMultipleCasesPresent; diff --git a/ApplicationLibCode/ProjectDataModel/RimGridCalculation.h b/ApplicationLibCode/ProjectDataModel/RimGridCalculation.h index fe0cf214d3..a6996f2219 100644 --- a/ApplicationLibCode/ProjectDataModel/RimGridCalculation.h +++ b/ApplicationLibCode/ProjectDataModel/RimGridCalculation.h @@ -73,6 +73,7 @@ public: bool preCalculate() const override; bool calculate() override; + bool calculateForCase( RimEclipseCase* eclipseCase ); void updateDependentObjects() override; void removeDependentObjects() override; @@ -149,6 +150,9 @@ private: bool allSourceCasesAreEqualToDestinationCase() const; void onEditNonVisibleResultAddressButtonPressed(); + bool isAggregationExpression() const; + std::vector casesToCalculate() const; + static std::pair createStatisticsText( const std::vector>& values ); private: diff --git a/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.cpp b/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.cpp index 40c53e8eb9..c91a100e30 100644 --- a/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.cpp +++ b/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.cpp @@ -19,7 +19,9 @@ #include "RimGridCalculationCollection.h" #include "RiaLogging.h" +#include "RigCaseCellResultsData.h" #include "RigEclipseResultAddress.h" +#include "RimEclipseCase.h" #include "RimGridCalculation.h" #include "cafPdmUiGroup.h" @@ -132,6 +134,27 @@ RimGridCalculation* RimGridCalculationCollection::findCalculation( const QString return nullptr; } +//-------------------------------------------------------------------------------------------------- +/// Grid calculation results are not stored with the case. Compute the calculations missing or +/// invalidated (cleared, but still registered) for the case, e.g. when a view is stepped to another +/// case. +//-------------------------------------------------------------------------------------------------- +void RimGridCalculationCollection::ensureCalculationsAreComputed( RimEclipseCase* eclipseCase ) const +{ + if ( !eclipseCase || !eclipseCase->ensureReservoirCaseIsOpen() ) return; + + auto results = eclipseCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL ); + if ( !results ) return; + + for ( auto calculation : sortedGridCalculations() ) + { + const RigEclipseResultAddress address = calculation->outputAddress(); + if ( results->hasResultEntry( address ) && results->isResultLoaded( address ) ) continue; + + calculation->calculateForCase( eclipseCase ); + } +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.h b/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.h index bc57397e43..86c34c6b8b 100644 --- a/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.h +++ b/ApplicationLibCode/ProjectDataModel/RimGridCalculationCollection.h @@ -37,9 +37,10 @@ public: RimGridCalculation* createCalculation() const override; std::vector sortedGridCalculations() const; std::vector dependentCalculations( RimGridCalculation* sourceCalculation ) const; + RimGridCalculation* findCalculation( const QString& calculationName ) const; + void ensureCalculationsAreComputed( RimEclipseCase* eclipseCase ) const; private: - RimGridCalculation* findCalculation( const QString& calculationName ) const; bool dependentCalculationsRecursively( RimGridCalculation* sourceCalculation, std::vector& calculations ) const; void initAfterRead() override; diff --git a/ApplicationLibCode/UnitTests/RimGridCalculation-Test.cpp b/ApplicationLibCode/UnitTests/RimGridCalculation-Test.cpp index 5745b09a89..0093ec3586 100644 --- a/ApplicationLibCode/UnitTests/RimGridCalculation-Test.cpp +++ b/ApplicationLibCode/UnitTests/RimGridCalculation-Test.cpp @@ -18,13 +18,25 @@ #include "gtest/gtest.h" -#include "RimGridCalculation.h" +#include "RiaDefines.h" +#include "RiaTestDataDirectory.h" +#include "RigCaseCellResultsData.h" +#include "RigEclipseResultAddress.h" + +#include "RimEclipseResultAddress.h" #include "RimEclipseResultCase.h" +#include "RimGridCalculation.h" +#include "RimGridCalculationCollection.h" +#include "RimGridCalculationVariable.h" +#include "RimProject.h" #include "RimReservoirGridEnsemble.h" #include "cafPdmPtrField.h" +#include +#include + #include #include @@ -138,3 +150,234 @@ TEST( RimGridCalculationTest, RemoveDependentObjectsForCaseNotOpened ) calculation.removeDependentObjects(); } + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimEclipseResultCase* openBruggeRealizationForCalculation( 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(); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// A plain per-cell expression is computed lazily for a single realization on demand, so clicking +/// "Calculate" for an ensemble destination must only compute the ensemble's main case. +//-------------------------------------------------------------------------------------------------- +TEST( RimGridCalculationTest, EnsembleDestinationWithPlainExpressionOnlyCalculatesMainCase ) +{ + RimReservoirGridEnsemble ensemble; + auto* firstCase = openBruggeRealizationForCalculation( "Real0", "BRUGGE_0000.EGRID" ); + auto* secondCase = openBruggeRealizationForCalculation( "Real10", "BRUGGE_0010.EGRID" ); + ASSERT_TRUE( firstCase != nullptr ); + ASSERT_TRUE( secondCase != nullptr ); + ensemble.addCase( firstCase ); + ensemble.addCase( secondCase ); + + RimGridCalculation calculation; + calculation.setExpression( "MyCalc := x + 1" ); + auto* variable = dynamic_cast( calculation.addVariable( "x" ) ); + ASSERT_TRUE( variable != nullptr ); + + RimEclipseResultAddress sourceAddress; + sourceAddress.setEclipseCase( firstCase ); + sourceAddress.setResultType( RiaDefines::ResultCatType::STATIC_NATIVE ); + sourceAddress.setResultName( "PORO" ); + variable->setEclipseResultAddress( sourceAddress ); + + auto* destinationEnsembleField = + dynamic_cast*>( calculation.findField( "DestinationEnsemble" ) ); + ASSERT_TRUE( destinationEnsembleField != nullptr ); + destinationEnsembleField->setValue( &ensemble ); + + ASSERT_TRUE( calculation.calculate() ); + + const RigEclipseResultAddress resAddr( RiaDefines::ResultCatType::GENERATED, "MyCalc" ); + EXPECT_TRUE( firstCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->hasResultEntry( resAddr ) ); + + // The second realization's result is not computed, since only the ensemble's main case is needed + ASSERT_TRUE( secondCase->eclipseCaseData() != nullptr ); + EXPECT_FALSE( secondCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->hasResultEntry( resAddr ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// An aggregation expression's result is the per-realization summary itself, so it cannot be produced +/// lazily and must be computed for every realization, even for an ensemble destination. +//-------------------------------------------------------------------------------------------------- +TEST( RimGridCalculationTest, EnsembleDestinationWithAggregationExpressionCalculatesAllCases ) +{ + RimReservoirGridEnsemble ensemble; + auto* firstCase = openBruggeRealizationForCalculation( "Real0", "BRUGGE_0000.EGRID" ); + auto* secondCase = openBruggeRealizationForCalculation( "Real10", "BRUGGE_0010.EGRID" ); + ASSERT_TRUE( firstCase != nullptr ); + ASSERT_TRUE( secondCase != nullptr ); + ensemble.addCase( firstCase ); + ensemble.addCase( secondCase ); + + RimGridCalculation calculation; + calculation.setExpression( "MyCalc := sum(x)" ); + auto* variable = dynamic_cast( calculation.addVariable( "x" ) ); + ASSERT_TRUE( variable != nullptr ); + + RimEclipseResultAddress sourceAddress; + sourceAddress.setEclipseCase( firstCase ); + sourceAddress.setResultType( RiaDefines::ResultCatType::STATIC_NATIVE ); + sourceAddress.setResultName( "PORO" ); + variable->setEclipseResultAddress( sourceAddress ); + + auto* destinationEnsembleField = + dynamic_cast*>( calculation.findField( "DestinationEnsemble" ) ); + ASSERT_TRUE( destinationEnsembleField != nullptr ); + destinationEnsembleField->setValue( &ensemble ); + + ASSERT_TRUE( calculation.calculate() ); + + const RigEclipseResultAddress resAddr( RiaDefines::ResultCatType::GENERATED, "MyCalc" ); + EXPECT_TRUE( firstCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->hasResultEntry( resAddr ) ); + + // An aggregation expression's result cannot be produced lazily for a single realization, so every + // realization must be computed eagerly + ASSERT_TRUE( secondCase->eclipseCaseData() != nullptr ); + EXPECT_TRUE( secondCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->hasResultEntry( resAddr ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// A view in the generic view collection can be stepped to any case in the project, so all grid +/// calculations must be computed for that case, even when it is not an output case. +//-------------------------------------------------------------------------------------------------- +TEST( RimGridCalculationTest, EnsureCalculationsAreComputedForCaseNotInOutputCases ) +{ + std::unique_ptr destinationCase( openBruggeRealizationForCalculation( "Real0", "BRUGGE_0000.EGRID" ) ); + std::unique_ptr otherCase( openBruggeRealizationForCalculation( "Real10", "BRUGGE_0010.EGRID" ) ); + ASSERT_TRUE( destinationCase != nullptr ); + ASSERT_TRUE( otherCase != nullptr ); + + RimProject* project = RimProject::current(); + ASSERT_TRUE( project != nullptr ); + + auto* calculation = dynamic_cast( project->gridCalculationCollection()->addCalculation( false ) ); + ASSERT_TRUE( calculation != nullptr ); + calculation->setExpression( "MyCalc := x + 1" ); + auto* variable = dynamic_cast( calculation->addVariable( "x" ) ); + ASSERT_TRUE( variable != nullptr ); + + RimEclipseResultAddress sourceAddress; + sourceAddress.setEclipseCase( destinationCase.get() ); + sourceAddress.setResultType( RiaDefines::ResultCatType::STATIC_NATIVE ); + sourceAddress.setResultName( "PORO" ); + variable->setEclipseResultAddress( sourceAddress ); + + auto* destinationCaseField = dynamic_cast*>( calculation->findField( "DestinationCase" ) ); + ASSERT_TRUE( destinationCaseField != nullptr ); + destinationCaseField->setValue( destinationCase.get() ); + + const auto outputCases = calculation->outputEclipseCases(); + ASSERT_TRUE( std::find( outputCases.begin(), outputCases.end(), otherCase.get() ) == outputCases.end() ); + + const RigEclipseResultAddress resAddr( RiaDefines::ResultCatType::GENERATED, "MyCalc" ); + auto* otherResults = otherCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL ); + ASSERT_FALSE( otherResults->hasResultEntry( resAddr ) ); + + project->gridCalculationCollection()->ensureCalculationsAreComputed( otherCase.get() ); + + EXPECT_TRUE( otherResults->hasResultEntry( resAddr ) ); + EXPECT_FALSE( otherResults->resultNames( RiaDefines::ResultCatType::GENERATED ).empty() ); + + project->gridCalculationCollection()->deleteCalculation( calculation ); +} + +//-------------------------------------------------------------------------------------------------- +/// calculateForCase() must open source cases itself, just like calculate() does, otherwise +/// validateVariables() dereferences a null results() pointer for a closed source case and crashes. +//-------------------------------------------------------------------------------------------------- +TEST( RimGridCalculationTest, CalculateForCaseOpensClosedSourceCase ) +{ + std::unique_ptr sourceCase( openBruggeRealizationForCalculation( "Real0", "BRUGGE_0000.EGRID" ) ); + std::unique_ptr otherCase( openBruggeRealizationForCalculation( "Real10", "BRUGGE_0010.EGRID" ) ); + ASSERT_TRUE( sourceCase != nullptr ); + ASSERT_TRUE( otherCase != nullptr ); + + RimGridCalculation calculation; + calculation.setExpression( "MyCalc := x + 1" ); + auto* variable = dynamic_cast( calculation.addVariable( "x" ) ); + ASSERT_TRUE( variable != nullptr ); + + RimEclipseResultAddress sourceAddress; + sourceAddress.setEclipseCase( sourceCase.get() ); + sourceAddress.setResultType( RiaDefines::ResultCatType::STATIC_NATIVE ); + sourceAddress.setResultName( "PORO" ); + variable->setEclipseResultAddress( sourceAddress ); + + // Close the source case, simulating a project that was just reopened: the case referenced by the + // variable is not necessarily open when calculateForCase() is called for another case. + sourceCase->closeReservoirCase(); + ASSERT_TRUE( sourceCase->eclipseCaseData() == nullptr ); + + EXPECT_TRUE( calculation.calculateForCase( otherCase.get() ) ); + + // calculateForCase() must have (re)opened the source case to validate and evaluate the expression. + EXPECT_TRUE( sourceCase->eclipseCaseData() != nullptr ); + + const RigEclipseResultAddress resAddr( RiaDefines::ResultCatType::GENERATED, "MyCalc" ); + EXPECT_TRUE( otherCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->hasResultEntry( resAddr ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// Recalculating an ensemble destination with a plain per-cell expression only updates the main case +/// (see EnsembleDestinationWithPlainExpressionOnlyCalculatesMainCase), so a stale result already +/// computed for another, currently open realization must be invalidated rather than left in place. +//-------------------------------------------------------------------------------------------------- +TEST( RimGridCalculationTest, RecalculatingEnsembleMainCaseInvalidatesStaleResultOnOtherOpenRealization ) +{ + RimReservoirGridEnsemble ensemble; + auto* firstCase = openBruggeRealizationForCalculation( "Real0", "BRUGGE_0000.EGRID" ); + auto* secondCase = openBruggeRealizationForCalculation( "Real10", "BRUGGE_0010.EGRID" ); + ASSERT_TRUE( firstCase != nullptr ); + ASSERT_TRUE( secondCase != nullptr ); + ensemble.addCase( firstCase ); + ensemble.addCase( secondCase ); + + RimGridCalculation calculation; + calculation.setExpression( "MyCalc := x + 1" ); + auto* variable = dynamic_cast( calculation.addVariable( "x" ) ); + ASSERT_TRUE( variable != nullptr ); + + RimEclipseResultAddress sourceAddress; + sourceAddress.setEclipseCase( firstCase ); + sourceAddress.setResultType( RiaDefines::ResultCatType::STATIC_NATIVE ); + sourceAddress.setResultName( "PORO" ); + variable->setEclipseResultAddress( sourceAddress ); + + auto* destinationEnsembleField = + dynamic_cast*>( calculation.findField( "DestinationEnsemble" ) ); + ASSERT_TRUE( destinationEnsembleField != nullptr ); + destinationEnsembleField->setValue( &ensemble ); + + ASSERT_TRUE( calculation.calculate() ); + + const RigEclipseResultAddress resAddr( RiaDefines::ResultCatType::GENERATED, "MyCalc" ); + + // Compute the result for the second realization too, as ensureCalculationsAreComputed() would do + // lazily when the realization is needed, e.g. by an ensemble statistics contour map. + ASSERT_TRUE( calculation.calculateForCase( secondCase ) ); + ASSERT_TRUE( secondCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->hasResultEntry( resAddr ) ); + + // Recalculate with a changed expression: only the main case (firstCase) is updated directly, but the + // stale result on the second, already open realization must be invalidated rather than left in place. + calculation.setExpression( "MyCalc := x + 2" ); + ASSERT_TRUE( calculation.calculate() ); + + EXPECT_FALSE( secondCase->results( RiaDefines::PorosityModelType::MATRIX_MODEL )->isResultLoaded( resAddr ) ); +} diff --git a/ApplicationLibCode/UnitTests/RimStatisticsContourMap-Test.cpp b/ApplicationLibCode/UnitTests/RimStatisticsContourMap-Test.cpp index 3171f6a362..0a324ab9ee 100644 --- a/ApplicationLibCode/UnitTests/RimStatisticsContourMap-Test.cpp +++ b/ApplicationLibCode/UnitTests/RimStatisticsContourMap-Test.cpp @@ -35,8 +35,12 @@ #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" @@ -345,6 +349,127 @@ TEST( RimStatisticsContourMapTest, StaticResultWithDynamicFilterProducesResultsP 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 { //--------------------------------------------------------------------------------------------------