diff --git a/ApplicationLibCode/Commands/RicCloseSummaryCaseFeature.cpp b/ApplicationLibCode/Commands/RicCloseSummaryCaseFeature.cpp index 3170bd1e9d..f5ad23c3dc 100644 --- a/ApplicationLibCode/Commands/RicCloseSummaryCaseFeature.cpp +++ b/ApplicationLibCode/Commands/RicCloseSummaryCaseFeature.cpp @@ -28,6 +28,7 @@ #include "RimProject.h" #include "RimSummaryCase.h" #include "RimSummaryCaseMainCollection.h" +#include "RimSummaryCaseUpdateBatch.h" #include "RimSummaryMultiPlot.h" #include "RimSummaryMultiPlotCollection.h" #include "RimSummaryPlot.h" @@ -61,6 +62,10 @@ void RicCloseSummaryCaseFeature::setupActionLook( QAction* actionToSetup ) //-------------------------------------------------------------------------------------------------- void RicCloseSummaryCaseFeature::deleteSummaryCases( std::vector cases ) { + // The case list is used all the way down to the delete below. Keep a batch open for that whole span, so a derived + // case detached by the removal is not destroyed while this list still refers to it. + RimSummaryCaseUpdateBatch updateBatch; + RimSummaryMultiPlotCollection* summaryPlotColl = RiaSummaryTools::summaryMultiPlotCollection(); RimSummaryCaseMainCollection* summaryCaseMainCollection = RiaSummaryTools::summaryCaseMainCollection(); diff --git a/ApplicationLibCode/ProjectDataModel/Summary/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Summary/CMakeLists_files.cmake index 7eb5a25eab..5e0687122a 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Summary/CMakeLists_files.cmake @@ -55,6 +55,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimSummaryAddressSelector.cpp ${CMAKE_CURRENT_LIST_DIR}/RimEnsembleCrossPlotStatisticsCase.cpp ${CMAKE_CURRENT_LIST_DIR}/RimSummaryEnsembleTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimSummaryCaseUpdateBatch.cpp ${CMAKE_CURRENT_LIST_DIR}/RimSummaryPlotReadOut.cpp ) diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.cpp b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.cpp index df74724baf..22aaa86b0d 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.cpp +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.cpp @@ -183,7 +183,6 @@ RimDeltaSummaryCase::RimDeltaSummaryCase() CAF_PDM_InitFieldNoDefault( &m_useFixedTimeStep, "UseFixedTimeStep", "Use Fixed Time Step" ); CAF_PDM_InitField( &m_fixedTimeStepIndex, "FixedTimeStepIndex", 0, "Time Step" ); - CAF_PDM_InitField( &m_inUse, "InUse", false, "In Use" ); m_fixedTimeStepIndex.uiCapability()->setUiEditorTypeName( caf::PdmUiTreeSelectionEditor::uiEditorTypeName() ); m_fixedTimeStepIndex.uiCapability()->setUiLabelPosition( caf::PdmUiItemInfo::LabelPosition::HIDDEN ); } @@ -191,33 +190,22 @@ RimDeltaSummaryCase::RimDeltaSummaryCase() //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RimDeltaSummaryCase::setInUse( bool inUse ) +void RimDeltaSummaryCase::setSummaryCases( RimSummaryCase* sumCase1, RimSummaryCase* sumCase2 ) { - m_inUse = inUse; - - if ( !m_inUse ) - { - m_summaryCase1 = nullptr; - m_summaryCase2 = nullptr; - m_dataCache.clear(); - } -} + m_summaryCase1 = sumCase1; + m_summaryCase2 = sumCase2; -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -bool RimDeltaSummaryCase::isInUse() const -{ - return m_inUse; + clearCache(); } //-------------------------------------------------------------------------------------------------- -/// +/// Sever the references to the source cases. Used when a derived case is detached from its ensemble, +/// so it does not keep the source cases alive in any way after the ensemble has moved on. //-------------------------------------------------------------------------------------------------- -void RimDeltaSummaryCase::setSummaryCases( RimSummaryCase* sumCase1, RimSummaryCase* sumCase2 ) +void RimDeltaSummaryCase::clearSourceCases() { - m_summaryCase1 = sumCase1; - m_summaryCase2 = sumCase2; + m_summaryCase1 = nullptr; + m_summaryCase2 = nullptr; clearCache(); } diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.h b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.h index 9e27fab0b5..cbc53c02bc 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryCase.h @@ -56,9 +56,8 @@ class RimDeltaSummaryCase : public RimSummaryCase, public RifSummaryReaderInterf public: RimDeltaSummaryCase(); - void setInUse( bool inUse ); - bool isInUse() const; void setSummaryCases( RimSummaryCase* sumCase1, RimSummaryCase* sumCase2 ); + void clearSourceCases(); void setOperator( DerivedSummaryOperator oper ); void setFixedTimeSteps( int fixedTimeStepCase1, int fixedTimeStepCase2 ); @@ -111,8 +110,6 @@ class RimDeltaSummaryCase : public RimSummaryCase, public RifSummaryReaderInterf caf::PdmField> m_useFixedTimeStep; caf::PdmField m_fixedTimeStepIndex; - caf::PdmField m_inUse; - // Local cache considered mutable mutable std::map, std::vector>> m_dataCache; }; diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.cpp b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.cpp index 25004b37b0..38f311ad8a 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.cpp +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.cpp @@ -28,7 +28,9 @@ #include "RimDeltaSummaryEnsemble.h" #include "RimProject.h" #include "RimSummaryCaseMainCollection.h" +#include "RimSummaryCaseUpdateBatch.h" #include "RimSummaryEnsemble.h" +#include "RimSummaryEnsembleTools.h" #include "cafPdmUiButton.h" #include "cafPdmUiCheckBoxEditor.h" @@ -120,17 +122,6 @@ void RimDeltaSummaryEnsemble::setEnsemble2( RimSummaryEnsemble* ensemble ) RiaSummaryTools::updateSummaryEnsembleNames(); } -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -std::vector RimDeltaSummaryEnsemble::allSummaryCases() const -{ - std::vector cases; - for ( auto sumCase : allDerivedCases( true ) ) - cases.push_back( sumCase ); - return cases; -} - //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -163,56 +154,116 @@ void RimDeltaSummaryEnsemble::createDerivedEnsembleCases() { if ( !m_ensemble1 || !m_ensemble2 ) return; - setAllCasesNotInUse(); + auto orphanedCases = rebuildDerivedCases(); + + // If other derived ensembles are referring to this ensemble, update their cases as well + for ( auto referring : RimSummaryEnsembleTools::dependentDeltaEnsembles( this ) ) + { + referring->createDerivedEnsembleCases(); + } + + RimSummaryCaseUpdateBatch::orphan( orphanedCases ); +} + +//-------------------------------------------------------------------------------------------------- +/// The source case pairs this ensemble should have derived cases for, in the order of the cases in +/// the first source ensemble. Pure computation, the object graph is not modified. +//-------------------------------------------------------------------------------------------------- +std::vector> RimDeltaSummaryEnsemble::desiredSourceCasePairs() const +{ + std::vector> casePairs; + + if ( !m_ensemble1 || !m_ensemble2 ) return casePairs; const auto cases1 = m_ensemble1->allSummaryCases(); const auto cases2 = m_ensemble2->allSummaryCases(); - for ( auto& sumCase1 : cases1 ) + for ( auto sumCase1 : cases1 ) { auto crp = sumCase1->caseRealizationParameters(); if ( !crp ) continue; - RimSummaryCase* summaryCase2 = nullptr; - if ( m_matchOnParameters ) - { - summaryCase2 = findCaseByParametersHash( cases2, crp->parametersHash() ); - } - else - { - summaryCase2 = findCaseByRealizationNumber( cases2, crp->realizationNumber() ); - } + RimSummaryCase* summaryCase2 = m_matchOnParameters ? findCaseByParametersHash( cases2, crp->parametersHash() ) + : findCaseByRealizationNumber( cases2, crp->realizationNumber() ); if ( !summaryCase2 ) continue; - auto derivedCase = firstCaseNotInUse(); - derivedCase->setSummaryCases( sumCase1, summaryCase2 ); - derivedCase->setOperator( m_operator() ); + casePairs.emplace_back( sumCase1, summaryCase2 ); + } + + return casePairs; +} + +//-------------------------------------------------------------------------------------------------- +/// Bring the derived cases in sync with desiredSourceCasePairs(). Existing derived cases are matched +/// on the source case pair, so a rebuild producing the same pairs reuses the same objects. +/// +/// Surplus cases are detached and returned, never deleted. Destroying them is the responsibility of +/// the caller, which may be several frames above a caller still iterating over these very objects. +//-------------------------------------------------------------------------------------------------- +std::vector RimDeltaSummaryEnsemble::rebuildDerivedCases() +{ + std::vector orphanedCases; + + std::map, RimDeltaSummaryCase*> reusableCases; + for ( auto derivedCase : allDerivedCases() ) + { + auto sourceCase1 = derivedCase->summaryCase1(); + auto sourceCase2 = derivedCase->summaryCase2(); - int fixedTimeStepCase1 = -1; - int fixedTimeStepCase2 = -1; - if ( m_useFixedTimeStep == FixedTimeStepMode::FIXED_TIME_STEP_CASE_1 ) + // A case with unresolved sources, or a duplicate of a pair already seen, can not be reused + if ( sourceCase1 && sourceCase2 && reusableCases.try_emplace( { sourceCase1, sourceCase2 }, derivedCase ).second ) continue; + + orphanedCases.push_back( derivedCase ); + } + + int fixedTimeStepCase1 = -1; + int fixedTimeStepCase2 = -1; + if ( m_useFixedTimeStep == FixedTimeStepMode::FIXED_TIME_STEP_CASE_1 ) + { + fixedTimeStepCase1 = m_fixedTimeStepIndex; + } + else if ( m_useFixedTimeStep == FixedTimeStepMode::FIXED_TIME_STEP_CASE_2 ) + { + fixedTimeStepCase2 = m_fixedTimeStepIndex; + } + + for ( const auto& [sourceCase1, sourceCase2] : desiredSourceCasePairs() ) + { + RimDeltaSummaryCase* derivedCase = nullptr; + + auto it = reusableCases.find( { sourceCase1, sourceCase2 } ); + if ( it != reusableCases.end() ) { - fixedTimeStepCase1 = m_fixedTimeStepIndex; + derivedCase = it->second; + reusableCases.erase( it ); } - else if ( m_useFixedTimeStep == FixedTimeStepMode::FIXED_TIME_STEP_CASE_2 ) + else { - fixedTimeStepCase2 = m_fixedTimeStepIndex; + derivedCase = new RimDeltaSummaryCase(); + addCaseWithoutDependencyUpdate( derivedCase ); + derivedCase->setSummaryCases( sourceCase1, sourceCase2 ); } + derivedCase->setOperator( m_operator() ); derivedCase->setFixedTimeSteps( fixedTimeStepCase1, fixedTimeStepCase2 ); derivedCase->createSummaryReaderInterface(); - derivedCase->setCaseRealizationParameters( crp ); - derivedCase->setInUse( true ); + derivedCase->setCaseRealizationParameters( sourceCase1->caseRealizationParameters() ); derivedCase->updateDisplayNameFromCases(); } - // If other derived ensembles are referring to this ensemble, update their cases as well - for ( auto referring : findReferringEnsembles() ) + // The reusable cases not claimed by a desired pair are surplus + for ( const auto& [casePair, derivedCase] : reusableCases ) { - referring->createDerivedEnsembleCases(); + orphanedCases.push_back( derivedCase ); + } + + for ( auto orphanedCase : orphanedCases ) + { + removeCase( orphanedCase, false ); + orphanedCase->clearSourceCases(); } - deleteCasesNoInUse(); + return orphanedCases; } //-------------------------------------------------------------------------------------------------- @@ -404,7 +455,7 @@ void RimDeltaSummaryEnsemble::fieldChangedByUi( const caf::PdmFieldHandle* chang updateReferringCurveSetsZoomAll(); // If other derived ensembles are referring to this ensemble, update their cases as well - for ( auto referring : findReferringEnsembles() ) + for ( auto referring : RimSummaryEnsembleTools::dependentDeltaEnsembles( this ) ) { referring->updateReferringCurveSetsZoomAll(); } @@ -430,68 +481,17 @@ void RimDeltaSummaryEnsemble::defineEditorAttribute( const caf::PdmFieldHandle* //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RimDeltaSummaryEnsemble::setAllCasesNotInUse() +std::vector RimDeltaSummaryEnsemble::allDerivedCases() const { - for ( auto derCase : allDerivedCases( true ) ) - derCase->setInUse( false ); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimDeltaSummaryEnsemble::deleteCasesNoInUse() -{ - std::vector inactiveCases; - auto allCases = allDerivedCases( false ); - std::copy_if( allCases.begin(), - allCases.end(), - std::back_inserter( inactiveCases ), - []( RimDeltaSummaryCase* derCase ) { return !derCase->isInUse(); } ); - - for ( auto derCase : inactiveCases ) + std::vector derivedCases; + for ( auto sumCase : allSummaryCases() ) { - removeCase( derCase ); - delete derCase; - } -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -RimDeltaSummaryCase* RimDeltaSummaryEnsemble::firstCaseNotInUse() -{ - auto allCases = allDerivedCases( false ); - auto itr = std::find_if( allCases.begin(), allCases.end(), []( RimDeltaSummaryCase* derCase ) { return !derCase->isInUse(); } ); - if ( itr != allCases.end() ) - { - return *itr; - } - - // If no active case was found, add a new case to the collection - auto newCase = new RimDeltaSummaryCase(); - - // Show realization data source for the first case. If we create for all, the performance will be bad - newCase->setShowTreeNodes( m_cases.empty() ); - - m_cases.push_back( newCase ); - return newCase; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -std::vector RimDeltaSummaryEnsemble::allDerivedCases( bool activeOnly ) const -{ - std::vector activeCases; - for ( auto sumCase : RimSummaryEnsemble::allSummaryCases() ) - { - auto derivedCase = dynamic_cast( sumCase ); - if ( derivedCase && ( !activeOnly || derivedCase->isInUse() ) ) + if ( auto derivedCase = dynamic_cast( sumCase ) ) { - activeCases.push_back( derivedCase ); + derivedCases.push_back( derivedCase ); } } - return activeCases; + return derivedCases; } //-------------------------------------------------------------------------------------------------- @@ -499,7 +499,7 @@ std::vector RimDeltaSummaryEnsemble::allDerivedCases( bool //-------------------------------------------------------------------------------------------------- void RimDeltaSummaryEnsemble::updateDerivedEnsembleCases() { - for ( auto& derivedCase : allDerivedCases( true ) ) + for ( auto& derivedCase : allDerivedCases() ) { derivedCase->createSummaryReaderInterface(); @@ -512,7 +512,7 @@ void RimDeltaSummaryEnsemble::updateDerivedEnsembleCases() } // If other derived ensembles are referring to this ensemble, update their cases as well - for ( auto referring : findReferringEnsembles() ) + for ( auto referring : RimSummaryEnsembleTools::dependentDeltaEnsembles( this ) ) { referring->updateDerivedEnsembleCases(); } @@ -552,31 +552,6 @@ RimSummaryCase* RimDeltaSummaryEnsemble::findCaseByRealizationNumber( const std: return nullptr; } -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -std::vector RimDeltaSummaryEnsemble::findReferringEnsembles() const -{ - std::vector referringEnsembles; - - auto mainColl = firstAncestorOrThisOfType(); - if ( mainColl ) - { - for ( auto ensemble : mainColl->summaryEnsembles() ) - { - auto derivedEnsemble = dynamic_cast( ensemble ); - if ( derivedEnsemble ) - { - if ( derivedEnsemble->m_ensemble1() == this || derivedEnsemble->m_ensemble2() == this ) - { - referringEnsembles.push_back( derivedEnsemble ); - } - } - } - } - return referringEnsembles; -} - //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -614,7 +589,7 @@ void RimDeltaSummaryEnsemble::onSwapEnsemblesButtonClicked() updateConnectedEditors(); updateReferringCurveSetsZoomAll(); - for ( auto referring : findReferringEnsembles() ) + for ( auto referring : RimSummaryEnsembleTools::dependentDeltaEnsembles( this ) ) { referring->updateReferringCurveSetsZoomAll(); } diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.h b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.h index f540565e61..f7fdf70f4d 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimDeltaSummaryEnsemble.h @@ -53,7 +53,6 @@ class RimDeltaSummaryEnsemble : public RimSummaryEnsemble void setEnsemble1( RimSummaryEnsemble* ensemble ); void setEnsemble2( RimSummaryEnsemble* ensemble ); - std::vector allSummaryCases() const override; std::set ensembleSummaryAddresses() const override; bool hasCaseReference( const RimSummaryCase* sumCase ) const; @@ -63,6 +62,13 @@ class RimDeltaSummaryEnsemble : public RimSummaryEnsemble void onSourceEnsembleChanged(); void createDerivedEnsembleCases(); + std::vector> desiredSourceCasePairs() const; + + // Detaches and returns the derived cases no longer backed by a source case pair. The caller owns them. + [[nodiscard]] std::vector rebuildDerivedCases(); + + std::vector allDerivedCases() const; + bool discardMissingOrIncompleteRealizations() const; std::pair nameKeys() const override; @@ -76,19 +82,12 @@ class RimDeltaSummaryEnsemble : public RimSummaryEnsemble void onSwapEnsemblesButtonClicked(); - void setAllCasesNotInUse(); - void deleteCasesNoInUse(); - RimDeltaSummaryCase* firstCaseNotInUse(); - std::vector allDerivedCases( bool activeOnly ) const; - void updateDerivedEnsembleCases(); bool isValid() const; static RimSummaryCase* findCaseByParametersHash( const std::vector& cases, size_t hash ); static RimSummaryCase* findCaseByRealizationNumber( const std::vector& cases, int realizationNumber ); - std::vector findReferringEnsembles() const; - std::vector allEnsembles() const; private: diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.cpp b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.cpp index c6c99f1e4b..77c1841cca 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.cpp +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.cpp @@ -48,6 +48,7 @@ #include "RimProject.h" #include "RimRftPlotCollection.h" #include "RimSummaryCase.h" +#include "RimSummaryCaseUpdateBatch.h" #include "RimSummaryCurve.h" #include "RimSummaryEnsemble.h" #include "RimSummaryMultiPlotCollection.h" @@ -56,6 +57,7 @@ #include "cafCmdFeatureMenuBuilder.h" #include "cafPdmFieldReorderCapability.h" +#include "cafPdmPointer.h" #include "cafProgressInfo.h" #include @@ -226,8 +228,13 @@ void RimSummaryCaseMainCollection::removeCase( RimSummaryCase* summaryCase, bool //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RimSummaryCaseMainCollection::removeCases( std::vector& cases ) +void RimSummaryCaseMainCollection::removeCases( const std::vector& cases ) { + // Removing one case makes a delta ensemble rebuild its derived cases, and some of those derived cases can be part + // of this very list. The batch detaches them now and destroys them when the outermost batch scope ends, so a caller + // holding on to the list across this call never sees a freed case, as long as it opens a batch of its own. + RimSummaryCaseUpdateBatch updateBatch; + for ( auto sumCase : cases ) { removeCase( sumCase, false ); diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.h b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.h index a6e44e950a..b3c152464d 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseMainCollection.h @@ -60,7 +60,8 @@ class RimSummaryCaseMainCollection : public caf::PdmObject void addCases( const std::vector cases ); void addCase( RimSummaryCase* summaryCase ); void removeCase( RimSummaryCase* summaryCase, bool notifyChange = true ); - void removeCases( std::vector& cases ); + // Open a RimSummaryCaseUpdateBatch around this call if the case list is used afterwards, see removeCases() + void removeCases( const std::vector& cases ); void moveCase( RimSummaryCase* summaryCase, int destinationIndex ); RimSummaryEnsemble* addEnsemble( const std::vector& summaryCases, diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseUpdateBatch.cpp b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseUpdateBatch.cpp new file mode 100644 index 0000000000..a5d2399dfe --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseUpdateBatch.cpp @@ -0,0 +1,157 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimSummaryCaseUpdateBatch.h" + +#include "RimDeltaSummaryCase.h" +#include "RimDeltaSummaryEnsemble.h" +#include "RimSummaryEnsembleTools.h" + +#include "cafPdmObjectHandleTools.h" + +#include +#include + +RimSummaryCaseUpdateBatch* RimSummaryCaseUpdateBatch::sm_current = nullptr; + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimSummaryCaseUpdateBatch::RimSummaryCaseUpdateBatch() +{ + // A nested batch contributes to the outermost one and never flushes + if ( !sm_current ) sm_current = this; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimSummaryCaseUpdateBatch::~RimSummaryCaseUpdateBatch() +{ + if ( sm_current != this ) return; + + // Clear the ambient batch before flushing. Work triggered by the flush itself is executed immediately, which is + // safe here, as the outermost scope is ending and no caller is holding a case list across this point. + sm_current = nullptr; + + flush(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimSummaryCaseUpdateBatch::isActive() +{ + return sm_current != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSummaryCaseUpdateBatch::orphan( const std::vector& orphanedCases ) +{ + if ( orphanedCases.empty() ) return; + + if ( sm_current ) + { + sm_current->m_orphanedCases.insert( sm_current->m_orphanedCases.end(), orphanedCases.begin(), orphanedCases.end() ); + return; + } + + auto casesToDelete = orphanedCases; + caf::PdmObjectHandleTools::deleteObjects( casesToDelete ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSummaryCaseUpdateBatch::markDeltaEnsembleDirty( RimDeltaSummaryEnsemble* deltaEnsemble ) +{ + if ( !deltaEnsemble ) return; + + if ( sm_current ) + { + auto& dirtyEnsembles = sm_current->m_dirtyEnsembles; + + auto isSameEnsemble = [deltaEnsemble]( const caf::PdmPointer& candidate ) + { return candidate.p() == deltaEnsemble; }; + + if ( std::none_of( dirtyEnsembles.begin(), dirtyEnsembles.end(), isSameEnsemble ) ) + { + dirtyEnsembles.push_back( deltaEnsemble ); + } + return; + } + + deltaEnsemble->onSourceEnsembleChanged(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimSummaryCaseUpdateBatch::ensemblesToRegenerate() const +{ + std::vector dirtyEnsembles; + for ( const auto& dirtyEnsemble : m_dirtyEnsembles ) + { + // An ensemble marked dirty can have been deleted before the flush + if ( dirtyEnsemble.notNull() ) dirtyEnsembles.push_back( dirtyEnsemble.p() ); + } + + // Everything depending on the dirty ensembles, in dependency order. A dirty ensemble depending on another dirty + // ensemble shows up here, and must be regenerated in this order rather than as a root. + auto dependents = RimSummaryEnsembleTools::deltaEnsemblesInUpdateOrder( dirtyEnsembles ); + + std::set alreadyOrdered( dependents.begin(), dependents.end() ); + + std::vector ordered; + for ( const auto& dirtyEnsemble : m_dirtyEnsembles ) + { + if ( dirtyEnsemble.isNull() ) continue; + if ( !alreadyOrdered.insert( dirtyEnsemble.p() ).second ) continue; + + ordered.push_back( dirtyEnsemble.p() ); + } + + ordered.insert( ordered.end(), dependents.begin(), dependents.end() ); + + return ordered; +} + +//-------------------------------------------------------------------------------------------------- +/// Regenerate before destroying, so a chained delta ensemble sees the final state of its source +/// before anything is freed. +//-------------------------------------------------------------------------------------------------- +void RimSummaryCaseUpdateBatch::flush() +{ + for ( auto deltaEnsemble : ensemblesToRegenerate() ) + { + deltaEnsemble->onSourceEnsembleChanged(); + } + m_dirtyEnsembles.clear(); + + std::vector casesToDelete; + for ( const auto& orphanedCase : m_orphanedCases ) + { + // An orphaned case can already have been destroyed by the code that detached it + if ( orphanedCase.notNull() ) casesToDelete.push_back( orphanedCase.p() ); + } + m_orphanedCases.clear(); + + caf::PdmObjectHandleTools::deleteObjects( casesToDelete ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseUpdateBatch.h b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseUpdateBatch.h new file mode 100644 index 0000000000..ae59795058 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryCaseUpdateBatch.h @@ -0,0 +1,68 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafPdmPointer.h" + +#include + +class RimDeltaSummaryCase; +class RimDeltaSummaryEnsemble; + +//================================================================================================== +/// Collects the work that must happen once a set of summary case mutations has settled. Removing a +/// case only detaches it and hands it to the batch, destruction happens when the batch flushes. +/// +/// This is a plain stack object, ambient for the duration of its scope. A nested batch contributes to +/// the outermost one, which is the only one that flushes. Open a batch wherever a case list is held +/// across a mutation, so no object in that list is freed while the list is still in use. +/// +/// The contribution points fall back to immediate execution when no batch is active, so call sites +/// that do not open one keep behaving as before. +//================================================================================================== +class RimSummaryCaseUpdateBatch +{ +public: + RimSummaryCaseUpdateBatch(); + ~RimSummaryCaseUpdateBatch(); + + RimSummaryCaseUpdateBatch( const RimSummaryCaseUpdateBatch& ) = delete; + RimSummaryCaseUpdateBatch& operator=( const RimSummaryCaseUpdateBatch& ) = delete; + + // Hand over detached cases for destruction at the flush. Destroys them immediately if no batch is active. + static void orphan( const std::vector& orphanedCases ); + + // Schedule a regeneration of the derived cases of a delta ensemble. Regenerates immediately if no batch is active. + static void markDeltaEnsembleDirty( RimDeltaSummaryEnsemble* deltaEnsemble ); + + static bool isActive(); + +private: + void flush(); + + // The dirty ensembles and everything depending on them, ordered so a delta ensemble is regenerated before the delta + // ensembles using it as a source + std::vector ensemblesToRegenerate() const; + +private: + std::vector> m_orphanedCases; + std::vector> m_dirtyEnsembles; + + static RimSummaryCaseUpdateBatch* sm_current; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.cpp b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.cpp index b9c7ed443a..ce59d075ad 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.cpp +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.cpp @@ -155,13 +155,7 @@ void RimSummaryEnsemble::removeCase( RimSummaryCase* summaryCase, bool notifyCha //-------------------------------------------------------------------------------------------------- void RimSummaryEnsemble::addCase( RimSummaryCase* summaryCase, bool notifyChange ) { - summaryCase->nameChanged.connect( this, &RimSummaryEnsemble::onCaseNameChanged ); - - summaryCase->setShowTreeNodes( m_cases.empty() ); - - m_cases.push_back( summaryCase ); - m_cachedSortedEnsembleParameters.clear(); - m_analyzer.reset(); + addCaseWithoutDependencyUpdate( summaryCase ); // Update derived ensemble cases (if any) std::vector referringObjects = objectsWithReferringPtrFieldsOfType(); @@ -180,6 +174,21 @@ void RimSummaryEnsemble::addCase( RimSummaryCase* summaryCase, bool notifyChange } if ( notifyChange ) updateReferringCurveSetsZoomAll(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSummaryEnsemble::addCaseWithoutDependencyUpdate( RimSummaryCase* summaryCase ) +{ + summaryCase->nameChanged.connect( this, &RimSummaryEnsemble::onCaseNameChanged ); + + // Show realization data source for the first case. If we create for all, the performance will be bad + summaryCase->setShowTreeNodes( m_cases.empty() ); + + m_cases.push_back( summaryCase ); + m_cachedSortedEnsembleParameters.clear(); + m_analyzer.reset(); clearChildNodes(); } diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.h b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.h index ce17ef3ad0..f610ea0b48 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsemble.h @@ -128,6 +128,10 @@ class RimSummaryEnsemble : public caf::PdmObject protected: virtual void onLoadDataAndUpdate(); + // Add a case without notifying the delta ensembles depending on this ensemble. Used when the caller is itself the + // owner of the dependency update, as is the case when a delta ensemble rebuilds its derived cases. + void addCaseWithoutDependencyUpdate( RimSummaryCase* summaryCase ); + void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; void buildMetaData(); void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override; diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.cpp b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.cpp index 53b1b0bd97..003d49b417 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.cpp +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.cpp @@ -18,6 +18,8 @@ #include "RimSummaryEnsembleTools.h" +#include "RiaLogging.h" + #include "Summary/RiaSummaryTools.h" #include "RifReaderRftInterface.h" @@ -42,6 +44,8 @@ #include "cafPdmUiTreeView.h" +#include + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -528,6 +532,123 @@ RimSummaryCase* RimSummaryEnsembleTools::caseWithMostDataObjects( const std::vec return nullptr; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimSummaryEnsembleTools::dependentDeltaEnsembles( const RimSummaryEnsemble* sourceEnsemble ) +{ + std::vector dependents; + if ( !sourceEnsemble ) return dependents; + + // One entry is returned per referring field, so a delta ensemble using the same ensemble as both sources is + // reported twice. Report each delta ensemble once. + std::set seen; + for ( auto deltaEnsemble : sourceEnsemble->objectsWithReferringPtrFieldsOfType() ) + { + if ( !deltaEnsemble ) continue; + if ( seen.insert( deltaEnsemble ).second ) dependents.push_back( deltaEnsemble ); + } + + return dependents; +} + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// Depth first traversal of the dependency graph, following the edges from a source ensemble to the delta ensembles +/// referring to it. The reverse postorder of that traversal is a topological order, so a delta ensemble is always +/// visited before the delta ensembles using it as a source. +//-------------------------------------------------------------------------------------------------- +std::vector dependentDeltaEnsemblesInOrder( const std::vector& sourceEnsembles ) +{ + struct StackEntry + { + RimDeltaSummaryEnsemble* ensemble = nullptr; + std::vector dependents; + size_t nextDependent = 0; + }; + + std::vector postOrder; + std::set visited; + std::set onPath; + std::vector stack; + + auto pushEnsemble = [&]( RimDeltaSummaryEnsemble* deltaEnsemble ) + { + if ( visited.count( deltaEnsemble ) ) return; + + if ( onPath.count( deltaEnsemble ) ) + { + RiaLogging::error( QString( "Delta ensemble '%1' is part of a circular dependency. The cycle is not traversed." ) + .arg( deltaEnsemble->name() ) + .toStdString() ); + return; + } + + onPath.insert( deltaEnsemble ); + stack.push_back( { deltaEnsemble, RimSummaryEnsembleTools::dependentDeltaEnsembles( deltaEnsemble ), 0 } ); + }; + + for ( auto sourceEnsemble : sourceEnsembles ) + { + for ( auto deltaEnsemble : RimSummaryEnsembleTools::dependentDeltaEnsembles( sourceEnsemble ) ) + { + pushEnsemble( deltaEnsemble ); + + while ( !stack.empty() ) + { + auto& top = stack.back(); + if ( top.nextDependent < top.dependents.size() ) + { + auto* dependent = top.dependents[top.nextDependent++]; + pushEnsemble( dependent ); + } + else + { + auto* completed = top.ensemble; + stack.pop_back(); + + onPath.erase( completed ); + visited.insert( completed ); + postOrder.push_back( completed ); + } + } + } + } + + std::reverse( postOrder.begin(), postOrder.end() ); + + return postOrder; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector + RimSummaryEnsembleTools::deltaEnsemblesInUpdateOrder( const std::vector& sourceEnsembles ) +{ + return dependentDeltaEnsemblesInOrder( std::vector( sourceEnsembles.begin(), sourceEnsembles.end() ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimSummaryEnsembleTools::wouldCreateDependencyCycle( const RimDeltaSummaryEnsemble* deltaEnsemble, + const RimSummaryEnsemble* candidateSource ) +{ + if ( !deltaEnsemble || !candidateSource ) return false; + if ( deltaEnsemble == candidateSource ) return true; + + // Everything depending on deltaEnsemble, directly or indirectly, would close a cycle if used as a source for it + for ( auto dependent : dependentDeltaEnsemblesInOrder( { deltaEnsemble } ) ) + { + if ( dependent == candidateSource ) return true; + } + + return false; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -535,10 +656,8 @@ void RimSummaryEnsembleTools::updateDependentDeltaEnsembles( const RimSummaryEns { if ( !sourceEnsemble ) return; - auto referringObjects = sourceEnsemble->objectsWithReferringPtrFieldsOfType(); - for ( auto referringEnsemble : referringObjects ) + for ( auto deltaEnsemble : dependentDeltaEnsemblesInOrder( { sourceEnsemble } ) ) { - referringEnsemble->onSourceEnsembleChanged(); - updateDependentDeltaEnsembles( referringEnsemble ); + deltaEnsemble->onSourceEnsembleChanged(); } } \ No newline at end of file diff --git a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.h b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.h index cf1ab65d8e..2e5297f516 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/RimSummaryEnsembleTools.h @@ -27,6 +27,7 @@ class RimSummaryCase; class RimPlotCurve; class RigEnsembleParameter; class RimSummaryEnsemble; +class RimDeltaSummaryEnsemble; namespace RimSummaryEnsembleTools { @@ -47,6 +48,17 @@ void resetHighlightAllPlots(); RimSummaryCase* caseWithMostDataObjects( const std::vector& sourceCases ); +// The delta ensembles referring to sourceEnsemble, one entry per delta ensemble. An ensemble used as both source 1 and +// source 2 is reported once. +std::vector dependentDeltaEnsembles( const RimSummaryEnsemble* sourceEnsemble ); + +// All delta ensembles depending directly or indirectly on any of sourceEnsembles, ordered so that a delta ensemble +// always appears before the delta ensembles using it as a source. Cycles are reported and not traversed. +std::vector deltaEnsemblesInUpdateOrder( const std::vector& sourceEnsembles ); + +// True if using candidateSource as a source for deltaEnsemble would make the dependency graph cyclic. +bool wouldCreateDependencyCycle( const RimDeltaSummaryEnsemble* deltaEnsemble, const RimSummaryEnsemble* candidateSource ); + void updateDependentDeltaEnsembles( const RimSummaryEnsemble* sourceEnsemble ); } // namespace RimSummaryEnsembleTools diff --git a/ApplicationLibCode/UnitTests/CMakeLists.txt b/ApplicationLibCode/UnitTests/CMakeLists.txt index 78d52c6004..922bab36c9 100644 --- a/ApplicationLibCode/UnitTests/CMakeLists.txt +++ b/ApplicationLibCode/UnitTests/CMakeLists.txt @@ -85,6 +85,8 @@ set(SOURCE_UNITTEST_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWellRftPlot-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimDataFilterCollection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimSummaryCaseCollection-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimSummaryCaseMainCollection-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimDeltaSummaryEnsemble-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RifActiveCellsReader-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RifCsvDataTableFormatter-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaSummaryAddressAnalyzer-Test.cpp diff --git a/ApplicationLibCode/UnitTests/RicWellPathExportMswGeometryPath-Test.cpp b/ApplicationLibCode/UnitTests/RicWellPathExportMswGeometryPath-Test.cpp index fccce1de83..b525e60a44 100644 --- a/ApplicationLibCode/UnitTests/RicWellPathExportMswGeometryPath-Test.cpp +++ b/ApplicationLibCode/UnitTests/RicWellPathExportMswGeometryPath-Test.cpp @@ -451,6 +451,15 @@ struct MswExportInput RimWellPath* wellPath = nullptr; }; +//-------------------------------------------------------------------------------------------------- +/// The project is a global object shared by all tests, and must be closed before the test completes. +/// The tests below leave early on a failing assertion, so close from a destructor. +//-------------------------------------------------------------------------------------------------- +struct ProjectCloser +{ + ~ProjectCloser() { RiaApplication::instance()->closeProject(); } +}; + //-------------------------------------------------------------------------------------------------- /// Load the multiple_laterals project and look up the well path with the given name. /// Members are left as nullptr if the project, the case or the well path could not be found. @@ -489,6 +498,8 @@ MswExportInput loadMultipleLateralsProject( const QString& wellPathName ) //-------------------------------------------------------------------------------------------------- TEST( RicWellPathExportMswGeometryPath, MultipleLaterals_LateralsNumberedBeforeCompletionBranches ) { + ProjectCloser projectCloser; + auto input = loadMultipleLateralsProject( "Well-A Y1" ); ASSERT_TRUE( input.eclipseCase != nullptr ); ASSERT_TRUE( input.wellPath != nullptr ); @@ -520,6 +531,8 @@ TEST( RicWellPathExportMswGeometryPath, MultipleLaterals_LateralsNumberedBeforeC //-------------------------------------------------------------------------------------------------- TEST( RicWellPathExportMswGeometryPath, MultipleLaterals_CompletionBranchesListedAfterTheirLateral ) { + ProjectCloser projectCloser; + auto input = loadMultipleLateralsProject( "Well-A Y1" ); ASSERT_TRUE( input.eclipseCase != nullptr ); ASSERT_TRUE( input.wellPath != nullptr ); @@ -545,6 +558,8 @@ TEST( RicWellPathExportMswGeometryPath, MultipleLaterals_CompletionBranchesListe //-------------------------------------------------------------------------------------------------- TEST( RicWellPathExportMswGeometryPath, MultipleLaterals_CompsegsOrderedByBranchNumber ) { + ProjectCloser projectCloser; + auto input = loadMultipleLateralsProject( "Well-A Y1" ); ASSERT_TRUE( input.eclipseCase != nullptr ); ASSERT_TRUE( input.wellPath != nullptr ); diff --git a/ApplicationLibCode/UnitTests/RimDeltaSummaryEnsemble-Test.cpp b/ApplicationLibCode/UnitTests/RimDeltaSummaryEnsemble-Test.cpp new file mode 100644 index 0000000000..75210e6db3 --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimDeltaSummaryEnsemble-Test.cpp @@ -0,0 +1,309 @@ +#include "gtest/gtest.h" + +#include "Summary/RiaSummaryTools.h" + +#include "RimDeltaSummaryEnsemble.h" +#include "RimMockSummaryCase.h" +#include "RimSummaryCaseMainCollection.h" +#include "RimSummaryCaseUpdateBatch.h" +#include "RimSummaryEnsemble.h" +#include "RimSummaryEnsembleTools.h" + +#include "cafPdmPointer.h" +#include "cafPdmPtrField.h" + +#include +#include + +namespace +{ +size_t countOf( const std::vector& ensembles, const RimDeltaSummaryEnsemble* ensemble ) +{ + return static_cast( std::count( ensembles.begin(), ensembles.end(), ensemble ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// Assign a source ensemble without going through setEnsemble1()/setEnsemble2(). The setters trigger +/// an ensemble name update, and auto generated names never converge for a cyclic dependency. +//-------------------------------------------------------------------------------------------------- +void forceSourceEnsemble( RimDeltaSummaryEnsemble* deltaEnsemble, const QString& fieldKeyword, RimSummaryEnsemble* sourceEnsemble ) +{ + auto* field = dynamic_cast*>( deltaEnsemble->findField( fieldKeyword ) ); + ASSERT_TRUE( field != nullptr ); + + field->setValue( sourceEnsemble ); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// The summary case main collection is a shared global object, so every test must leave it empty to +/// keep the tests order independent. +//-------------------------------------------------------------------------------------------------- +class RimDeltaSummaryEnsembleTest : public ::testing::Test +{ +protected: + RimSummaryCaseMainCollection* mainCollection() const { return RiaSummaryTools::summaryCaseMainCollection(); } + + RimSummaryEnsemble* createEnsemble( const QString& name, const std::vector& realizationNumbers ) const + { + std::vector cases; + for ( auto realizationNumber : realizationNumbers ) + { + cases.push_back( createMockCase( realizationNumber ) ); + } + + return mainCollection()->addEnsemble( cases, name, true ); + } + + RimDeltaSummaryEnsemble* createDeltaEnsemble( RimSummaryEnsemble* ensemble1, RimSummaryEnsemble* ensemble2 ) const + { + auto* deltaEnsemble = new RimDeltaSummaryEnsemble(); + mainCollection()->addEnsemble( deltaEnsemble ); + deltaEnsemble->setEnsemble1( ensemble1 ); + deltaEnsemble->setEnsemble2( ensemble2 ); + + return deltaEnsemble; + } + + void TearDown() override + { + auto ensembles = mainCollection()->summaryEnsembles(); + + // Delete the delta ensembles first, they refer to the other ensembles + std::stable_partition( ensembles.begin(), + ensembles.end(), + []( RimSummaryEnsemble* ensemble ) { return dynamic_cast( ensemble ) != nullptr; } ); + + for ( auto* ensemble : ensembles ) + { + mainCollection()->removeEnsemble( ensemble ); + delete ensemble; + } + + for ( auto* summaryCase : mainCollection()->topLevelSummaryCases() ) + { + mainCollection()->removeCase( summaryCase, false ); + delete summaryCase; + } + } +}; + +//-------------------------------------------------------------------------------------------------- +/// objectsWithReferringPtrFields() returns one entry per referring field, so an ensemble used as both +/// sources of the same delta ensemble is reported twice by the raw PDM call. +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, DependentDeltaEnsembles_Deduplicated ) +{ + auto* ensemble = createEnsemble( "Ensemble", { 0, 1 } ); + auto* deltaEnsemble = createDeltaEnsemble( ensemble, ensemble ); + + EXPECT_EQ( size_t( 2 ), ensemble->objectsWithReferringPtrFieldsOfType().size() ); + + auto dependents = RimSummaryEnsembleTools::dependentDeltaEnsembles( ensemble ); + ASSERT_EQ( size_t( 1 ), dependents.size() ); + EXPECT_EQ( deltaEnsemble, dependents.front() ); +} + +//-------------------------------------------------------------------------------------------------- +/// A delta ensemble must be regenerated before the delta ensembles using it as a source. +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, DependencyOrder_ChainedDeltaEnsembles ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + auto* ensemble3 = createEnsemble( "Ensemble 3", { 0, 1 } ); + + auto* deltaA = createDeltaEnsemble( ensemble1, ensemble2 ); + auto* deltaB = createDeltaEnsemble( deltaA, ensemble3 ); + + auto orderFromEnsemble1 = RimSummaryEnsembleTools::deltaEnsemblesInUpdateOrder( { ensemble1 } ); + ASSERT_EQ( size_t( 2 ), orderFromEnsemble1.size() ); + EXPECT_EQ( deltaA, orderFromEnsemble1[0] ); + EXPECT_EQ( deltaB, orderFromEnsemble1[1] ); + + // Ensemble 3 is only a source of the second delta ensemble + auto orderFromEnsemble3 = RimSummaryEnsembleTools::deltaEnsemblesInUpdateOrder( { ensemble3 } ); + ASSERT_EQ( size_t( 1 ), orderFromEnsemble3.size() ); + EXPECT_EQ( deltaB, orderFromEnsemble3[0] ); +} + +//-------------------------------------------------------------------------------------------------- +/// A cycle is constructible through the UI. The traversal must terminate and report each delta +/// ensemble once. +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, DependencyOrder_CycleTerminates ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + + auto* deltaA = createDeltaEnsemble( ensemble1, ensemble2 ); + auto* deltaB = createDeltaEnsemble( deltaA, ensemble2 ); + + // Close the cycle, A now refers to B and B refers to A + forceSourceEnsemble( deltaA, "Ensemble1", deltaB ); + + auto order = RimSummaryEnsembleTools::deltaEnsemblesInUpdateOrder( { ensemble2 } ); + ASSERT_EQ( size_t( 2 ), order.size() ); + EXPECT_EQ( size_t( 1 ), countOf( order, deltaA ) ); + EXPECT_EQ( size_t( 1 ), countOf( order, deltaB ) ); + + EXPECT_TRUE( RimSummaryEnsembleTools::wouldCreateDependencyCycle( deltaB, deltaA ) ); + + // Break the cycle before tear down + forceSourceEnsemble( deltaA, "Ensemble1", ensemble1 ); +} + +//-------------------------------------------------------------------------------------------------- +/// Derived cases are matched on the source case pair, so rebuilding without changing the sources must +/// reuse the very same objects. +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, Rebuild_IsIdempotent ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + + auto* deltaEnsemble = createDeltaEnsemble( ensemble1, ensemble2 ); + deltaEnsemble->createDerivedEnsembleCases(); + + auto derivedCases = deltaEnsemble->allDerivedCases(); + ASSERT_EQ( size_t( 2 ), derivedCases.size() ); + + auto orphanedCases = deltaEnsemble->rebuildDerivedCases(); + EXPECT_TRUE( orphanedCases.empty() ); + + EXPECT_EQ( derivedCases, deltaEnsemble->allDerivedCases() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, Rebuild_CreatesMissingAndOrphansSurplus ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + + auto* deltaEnsemble = createDeltaEnsemble( ensemble1, ensemble2 ); + deltaEnsemble->createDerivedEnsembleCases(); + ASSERT_EQ( size_t( 2 ), deltaEnsemble->allDerivedCases().size() ); + + // A matching realization in both source ensembles gives one more derived case + auto* addedCase1 = createMockCase( 2 ); + auto* addedCase2 = createMockCase( 2 ); + ensemble1->addCase( addedCase1, false ); + ensemble2->addCase( addedCase2, false ); + + auto orphanedCases = deltaEnsemble->rebuildDerivedCases(); + EXPECT_TRUE( orphanedCases.empty() ); + EXPECT_EQ( size_t( 3 ), deltaEnsemble->allDerivedCases().size() ); + + // Removing it again makes the derived case surplus + ensemble1->removeCase( addedCase1, false ); + delete addedCase1; + + orphanedCases = deltaEnsemble->rebuildDerivedCases(); + ASSERT_EQ( size_t( 1 ), orphanedCases.size() ); + EXPECT_EQ( size_t( 2 ), deltaEnsemble->allDerivedCases().size() ); + + for ( auto* orphanedCase : orphanedCases ) + { + delete orphanedCase; + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, Rebuild_NoMatchingRealizations ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 5, 6 } ); + + auto* deltaEnsemble = createDeltaEnsemble( ensemble1, ensemble2 ); + + EXPECT_TRUE( deltaEnsemble->desiredSourceCasePairs().empty() ); + + auto orphanedCases = deltaEnsemble->rebuildDerivedCases(); + EXPECT_TRUE( orphanedCases.empty() ); + EXPECT_TRUE( deltaEnsemble->allDerivedCases().empty() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, Batch_DeletionDeferredToFlush ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + + auto* deltaEnsemble = createDeltaEnsemble( ensemble1, ensemble2 ); + deltaEnsemble->createDerivedEnsembleCases(); + ASSERT_EQ( size_t( 2 ), deltaEnsemble->allDerivedCases().size() ); + + caf::PdmPointer guardedCase = deltaEnsemble->allDerivedCases().front(); + + { + RimSummaryCaseUpdateBatch updateBatch; + EXPECT_TRUE( RimSummaryCaseUpdateBatch::isActive() ); + + auto* sourceCase = ensemble1->allSummaryCases().front(); + mainCollection()->removeCase( sourceCase, false ); + delete sourceCase; + + // The derived case is detached, but still alive + EXPECT_EQ( size_t( 1 ), deltaEnsemble->allDerivedCases().size() ); + EXPECT_TRUE( guardedCase.notNull() ); + } + + EXPECT_FALSE( RimSummaryCaseUpdateBatch::isActive() ); + EXPECT_TRUE( guardedCase.isNull() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, Batch_NestingFlushesOnce ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + + auto* deltaEnsemble = createDeltaEnsemble( ensemble1, ensemble2 ); + deltaEnsemble->createDerivedEnsembleCases(); + ASSERT_EQ( size_t( 2 ), deltaEnsemble->allDerivedCases().size() ); + + caf::PdmPointer guardedCase = deltaEnsemble->allDerivedCases().front(); + + { + RimSummaryCaseUpdateBatch outerBatch; + + { + RimSummaryCaseUpdateBatch innerBatch; + + auto* sourceCase = ensemble1->allSummaryCases().front(); + mainCollection()->removeCase( sourceCase, false ); + delete sourceCase; + } + + // The inner scope contributes to the outer batch and must not flush + EXPECT_TRUE( guardedCase.notNull() ); + } + + EXPECT_TRUE( guardedCase.isNull() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST_F( RimDeltaSummaryEnsembleTest, WouldCreateDependencyCycle ) +{ + auto* ensemble1 = createEnsemble( "Ensemble 1", { 0, 1 } ); + auto* ensemble2 = createEnsemble( "Ensemble 2", { 0, 1 } ); + + auto* deltaA = createDeltaEnsemble( ensemble1, ensemble2 ); + auto* deltaB = createDeltaEnsemble( deltaA, ensemble2 ); + + EXPECT_TRUE( RimSummaryEnsembleTools::wouldCreateDependencyCycle( deltaA, deltaA ) ); + EXPECT_TRUE( RimSummaryEnsembleTools::wouldCreateDependencyCycle( deltaA, deltaB ) ); + + EXPECT_FALSE( RimSummaryEnsembleTools::wouldCreateDependencyCycle( deltaB, deltaA ) ); + EXPECT_FALSE( RimSummaryEnsembleTools::wouldCreateDependencyCycle( deltaA, ensemble1 ) ); +} diff --git a/ApplicationLibCode/UnitTests/RimMockSummaryCase.h b/ApplicationLibCode/UnitTests/RimMockSummaryCase.h index b835f87476..6544395f7a 100644 --- a/ApplicationLibCode/UnitTests/RimMockSummaryCase.h +++ b/ApplicationLibCode/UnitTests/RimMockSummaryCase.h @@ -1,9 +1,11 @@ #pragma once #include "RifSummaryReaderInterface.h" +#include "RigCaseRealizationParameters.h" #include "RimSummaryCase.h" #include +#include #include #include @@ -63,3 +65,17 @@ class RimMockSummaryCase : public RimSummaryCase, public RifSummaryReaderInterfa QString m_name = "MockCase"; std::map m_data; }; + +//-------------------------------------------------------------------------------------------------- +/// A mock case carrying the realization number a delta ensemble matches its source cases on. +//-------------------------------------------------------------------------------------------------- +inline RimSummaryCase* createMockCase( int realizationNumber ) +{ + auto* summaryCase = new RimMockSummaryCase(); + + auto parameters = std::make_shared(); + parameters->setRealizationNumber( realizationNumber ); + summaryCase->setCaseRealizationParameters( parameters ); + + return summaryCase; +} diff --git a/ApplicationLibCode/UnitTests/RimSummaryCaseMainCollection-Test.cpp b/ApplicationLibCode/UnitTests/RimSummaryCaseMainCollection-Test.cpp new file mode 100644 index 0000000000..f186a17e9c --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimSummaryCaseMainCollection-Test.cpp @@ -0,0 +1,73 @@ +#include "gtest/gtest.h" + +#include "Summary/RiaSummaryTools.h" + +#include "RimDeltaSummaryEnsemble.h" +#include "RimMockSummaryCase.h" +#include "RimSummaryCaseMainCollection.h" +#include "RimSummaryCaseUpdateBatch.h" +#include "RimSummaryEnsemble.h" + +#include "cafPdmPointer.h" + +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// Removing a source case makes a delta ensemble rebuild its derived cases. Those derived cases are +/// part of the list of cases to remove, and must stay alive for as long as the caller holds that +/// list. The caller states that span by opening a RimSummaryCaseUpdateBatch. +//-------------------------------------------------------------------------------------------------- +TEST( RimSummaryCaseMainCollection, RemoveCases_NoDanglingInCallerVector ) +{ + RimSummaryCaseMainCollection* mainCollection = RiaSummaryTools::summaryCaseMainCollection(); + + auto* ensemble1 = mainCollection->addEnsemble( { createMockCase( 0 ), createMockCase( 1 ) }, "Ensemble 1", true ); + auto* ensemble2 = mainCollection->addEnsemble( { createMockCase( 0 ), createMockCase( 1 ) }, "Ensemble 2", true ); + + auto* deltaEnsemble = new RimDeltaSummaryEnsemble(); + mainCollection->addEnsemble( deltaEnsemble ); + deltaEnsemble->setEnsemble1( ensemble1 ); + deltaEnsemble->setEnsemble2( ensemble2 ); + deltaEnsemble->createDerivedEnsembleCases(); + + EXPECT_EQ( size_t( 2 ), deltaEnsemble->allSummaryCases().size() ); + + auto cases = mainCollection->allSummaryCases(); + EXPECT_EQ( size_t( 6 ), cases.size() ); + + // Guarded pointers are set to null when the object is deleted + std::vector> guardedCases( cases.begin(), cases.end() ); + + auto aliveCount = [&guardedCases]() + { + return static_cast( std::count_if( guardedCases.begin(), + guardedCases.end(), + []( const caf::PdmPointer& guardedCase ) + { return guardedCase.notNull(); } ) ); + }; + + { + RimSummaryCaseUpdateBatch updateBatch; + + mainCollection->removeCases( cases ); + + // The two derived cases are detached by the delta ensemble, but not destroyed while the batch is open + EXPECT_EQ( cases.size(), aliveCount() ); + + for ( auto* summaryCase : cases ) + { + delete summaryCase; + } + } + + // Everything the caller handed over has been destroyed exactly once + EXPECT_EQ( size_t( 0 ), aliveCount() ); + + mainCollection->removeEnsemble( deltaEnsemble ); + mainCollection->removeEnsemble( ensemble1 ); + mainCollection->removeEnsemble( ensemble2 ); + delete deltaEnsemble; + delete ensemble1; + delete ensemble2; +} diff --git a/ApplicationLibCode/UnitTests/main.cpp b/ApplicationLibCode/UnitTests/main.cpp index d244af9bfd..ae6359f974 100644 --- a/ApplicationLibCode/UnitTests/main.cpp +++ b/ApplicationLibCode/UnitTests/main.cpp @@ -22,7 +22,55 @@ #include "RiaQuantityInfoTools.h" #include "RiaRegressionTestRunner.h" +#include "RimProject.h" + #include +#include + +//-------------------------------------------------------------------------------------------------- +/// The project is a global object shared by all tests. A test leaving data behind in the project +/// makes the outcome of the tests running after it depend on the test order, and the test order is +/// not the same on all platforms. +/// +/// Fail the test that leaves data behind, and close the project so the tests after it are unaffected. +//-------------------------------------------------------------------------------------------------- +class RiaProjectIsolationListener : public testing::EmptyTestEventListener +{ +private: + void OnTestEnd( const testing::TestInfo& ) override + { + RimProject* project = RimProject::current(); + if ( !project ) return; + + const QString leftovers = leftoverDescription( project ); + if ( leftovers.isEmpty() ) return; + + RiaApplication::instance()->closeProject(); + + ADD_FAILURE() << "The test left data behind in the shared project: " << leftovers.toStdString() + << ". Call RiaApplication::instance()->closeProject() before the test completes."; + } + + static QString leftoverDescription( RimProject* project ) + { + QStringList leftovers; + + auto appendCount = [&leftovers]( const QString& description, size_t count ) + { + if ( count > 0 ) leftovers.append( QString( "%1 %2" ).arg( count ).arg( description ) ); + }; + + appendCount( "grid case(s)", project->allGridCases().size() ); + appendCount( "summary case(s)", project->allSummaryCases().size() ); + appendCount( "summary ensemble(s)", project->summaryEnsembles().size() ); + appendCount( "well path(s)", project->allWellPaths().size() ); + appendCount( "view(s)", project->allViews().size() ); + + if ( !project->fileName().isEmpty() ) leftovers.append( "a project file name" ); + + return leftovers.join( ", " ); + } +}; //-------------------------------------------------------------------------------------------------- /// @@ -40,6 +88,11 @@ int main( int argc, char** argv ) setlocale( LC_NUMERIC, "C" ); testing::InitGoogleTest( &argc, argv ); + + // OnTestEnd is dispatched in reverse order of appending, so the listener appended last is the + // first to see the end of a test. The failure it reports is then part of the printed test result. + testing::UnitTest::GetInstance()->listeners().Append( new RiaProjectIsolationListener ); + int result = RUN_ALL_TESTS(); return result; }