Auto-assign Y-axis for ensemble curve sets based on unit/vector match

Extends the existing single-curve auto axis assignment (unit and summary vector matching, then alternating left/right) to also cover RimEnsembleCurveSet. Previously ensemble curve sets were always placed on the default left axis, so e.g. FOPT and FOPR ensembles ended up on the same axis despite differing units.

- Add RimEnsembleCurveSet::unitNameY(), looked up from the first available summary case reader in the ensemble.
- Move X and Y axis assignment from RimSummaryPlot to new RimSummaryPlotTools. Y-axis assignment takes a RiaSummaryAddressModifier::CurveAddressProvider, and compares against both curves and curve sets in the plot.
- RimEnsembleCurveSetCollection::addCurveSet gains an autoAssignPlotAxis flag (default true, matching addCurveAndUpdate for single curves).

Also fixes a bug in the summary plot editor where ensemble curve sets manually assigned to different Y-axes collapsed onto the same axis when clicking OK. The PdmPtrField axis reference does not resolve across the cloned preview and target plots. Add RimEnsembleCurveSet::findOrAssignLeftOrRightAxisY() to capture and re-apply the axis explicitly.

Add RimSummaryPlot-Test.cpp with unit tests for axis auto-assignment of curves and ensemble curve sets.

Fixes #14728
This commit is contained in:
Magne Sjaastad committed 2026-09-16 06:28:50 -04:00
1 parent f32d59775b
commit 044454b9b1
13 files changed
+629 -264

No files matched your search

@@ -503,7 +503,6 @@ void appendCurvesToPlot( RimSummaryPlot* summaryPlot,
{
auto curveSet = createCurveSet( ensemble, addr );
summaryPlot->ensembleCurveSetCollection()->addCurveSet( curveSet );
curveSet->setLeftOrRightAxisY( RiuPlotAxis::defaultLeft() );
}
for ( const auto summaryCase : summaryCases )
@@ -536,7 +535,6 @@ RimEnsembleCurveSet* addNewEnsembleCurve( RimSummaryPlot* summaryPlot, const Ria
summaryPlot->ensembleCurveSetCollection()->addCurveSet( curveSet );
curveSet->setLeftOrRightAxisY( RiuPlotAxis::defaultLeft() );
curveSet->setBottomOrTopAxisX( RiuPlotAxis::defaultBottomForSummaryVectors() );
summaryPlot->curvesChanged.send();
@@ -526,9 +526,12 @@ void RicSummaryPlotEditorUi::populateCurveCreator( const RimSummaryPlot& sourceS
RimEnsembleCurveSetCollection* previewCurveSetColl = m_previewPlot->ensembleCurveSetCollection();
for ( const auto& curveSet : sourceSummaryPlot.ensembleCurveSetCollection()->curveSets() )
{
auto sourceAxis = curveSet->axisY();
RimEnsembleCurveSet* newCurveSet = curveSet->clone();
newCurveSet->disableStatisticCurves();
previewCurveSetColl->addCurveSet( newCurveSet );
previewCurveSetColl->addCurveSet( newCurveSet, false );
newCurveSet->findOrAssignLeftOrRightAxisY( sourceAxis );
RimSummaryEnsemble* ensemble = curveSet->summaryEnsemble();
curveDefs.emplace_back( ensemble, curveSet->summaryAddressY() );
@@ -576,8 +579,11 @@ void RicSummaryPlotEditorUi::updateTargetPlot()
continue;
}
auto sourceAxis = editedCurveSet->axisY();
RimEnsembleCurveSet* newCurveSet = editedCurveSet->clone();
m_targetPlot->ensembleCurveSetCollection()->addCurveSet( newCurveSet );
m_targetPlot->ensembleCurveSetCollection()->addCurveSet( newCurveSet, false );
newCurveSet->findOrAssignLeftOrRightAxisY( sourceAxis );
newCurveSet->setParentPlotNoReplot( m_targetPlot->plotWidget() );
}
@@ -57,6 +57,7 @@ set(SOURCE_GROUP_SOURCE_FILES
${CMAKE_CURRENT_LIST_DIR}/RimSummaryEnsembleTools.cpp
${CMAKE_CURRENT_LIST_DIR}/RimSummaryCaseUpdateBatch.cpp
${CMAKE_CURRENT_LIST_DIR}/RimSummaryPlotReadOut.cpp
${CMAKE_CURRENT_LIST_DIR}/RimSummaryPlotTools.cpp
)
list(APPEND CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES})
@@ -28,6 +28,8 @@
#include "Summary/RiaSummaryCurveDefinition.h"
#include "Summary/RiaSummaryPlotTools.h"
#include "RifSummaryReaderInterface.h"
#include "SummaryPlotCommands/RicSummaryPlotEditorUi.h"
#include "RimCustomObjectiveFunction.h"
@@ -612,6 +614,31 @@ void RimEnsembleCurveSet::findOrAssignBottomAxisX( RiuPlotAxis plotAxis )
}
}
//--------------------------------------------------------------------------------------------------
/// Assign the given Y-axis to this curve set, creating a new axis on the parent plot if the plot
/// does not already have an axis of the given type. Used to preserve an explicitly selected axis
/// when a curve set is copied between plots (e.g. the summary plot editor preview/target plots).
//--------------------------------------------------------------------------------------------------
void RimEnsembleCurveSet::findOrAssignLeftOrRightAxisY( RiuPlotAxis plotAxis )
{
auto plot = firstAncestorOrThisOfType<RimSummaryPlot>();
if ( !plot ) return;
auto axis = plot->axisPropertiesForPlotAxis( plotAxis );
if ( !axis )
{
axis = plot->addNewAxisProperties( plotAxis, "New Axis" );
plot->updateConnectedEditors();
}
m_yPlotAxisProperties = axis;
for ( RimSummaryCurve* curve : curves() )
{
curve->setLeftOrRightAxisY( axisY() );
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -630,6 +657,25 @@ RifEclipseSummaryAddress RimEnsembleCurveSet::summaryAddressY() const
return m_yValuesSummaryAddress->address();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::string RimEnsembleCurveSet::unitNameY() const
{
auto ensemble = summaryEnsemble();
if ( !ensemble ) return "";
for ( auto summaryCase : ensemble->allSummaryCases() )
{
if ( !summaryCase ) continue;
auto reader = summaryCase->summaryReader();
if ( reader ) return reader->unitName( summaryAddressY() );
}
return "";
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -47,6 +47,8 @@
#include <QDate>
#include <QPointer>
#include <string>
class RimSummaryCase;
class RimSummaryEnsemble;
class RimSummaryCurve;
@@ -114,6 +116,7 @@ public:
void setCurveAddress( RiaSummaryCurveAddress address );
void setSummaryAddressYAndStatisticsFlag( RifEclipseSummaryAddress address );
RifEclipseSummaryAddress summaryAddressY() const;
std::string unitNameY() const;
RiaSummaryCurveAddress curveAddress() const;
std::vector<RimSummaryCurve*> curves() const;
@@ -189,6 +192,7 @@ public:
bool isXAxisSummaryVector() const;
RiaDefines::HorizontalAxisType xAxisType() const;
void findOrAssignBottomAxisX( RiuPlotAxis plotAxis );
void findOrAssignLeftOrRightAxisY( RiuPlotAxis plotAxis );
std::vector<RimTimeAxisAnnotation*> createTimeAnnotations() const;
@@ -163,11 +163,17 @@ RimEnsembleCurveSet* RimEnsembleCurveSetCollection::findCurveSetFromPlotCurve( c
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimEnsembleCurveSetCollection::addCurveSet( RimEnsembleCurveSet* curveSet )
void RimEnsembleCurveSetCollection::addCurveSet( RimEnsembleCurveSet* curveSet, bool autoAssignPlotAxis )
{
if ( curveSet )
{
m_curveSets.push_back( curveSet );
if ( autoAssignPlotAxis )
{
auto plot = firstAncestorOrThisOfType<RimSummaryPlot>();
if ( plot ) plot->assignPlotAxis( curveSet );
}
}
}
@@ -50,7 +50,7 @@ public:
RimSummaryCurve* findRimCurveFromPlotCurve( const RiuPlotCurve* qwtCurve ) const;
RimEnsembleCurveSet* findCurveSetFromPlotCurve( const RiuPlotCurve* qwtCurve ) const;
void addCurveSet( RimEnsembleCurveSet* curveSet );
void addCurveSet( RimEnsembleCurveSet* curveSet, bool autoAssignPlotAxis = true );
void deleteCurveSet( RimEnsembleCurveSet* curveSet );
void deleteCurveSets( const std::vector<RimEnsembleCurveSet*> curveSets );
@@ -54,6 +54,7 @@
#include "RimSummaryPlotControls.h"
#include "RimSummaryPlotNameHelper.h"
#include "RimSummaryPlotTextProvider.h"
#include "RimSummaryPlotTools.h"
#include "RimSummaryTimeAxisProperties.h"
#include "Summary/RiaSummaryAddressCollectionTools.h"
#include "Summary/RiaSummaryCurveDefinition.h"
@@ -61,7 +62,6 @@
#include "Summary/RiaSummaryPlotTools.h"
#include "Summary/RiaSummaryTools.h"
#include "SummaryPlotCommands/RicSummaryPlotEditorUi.h"
#include "Tools/RimPlotAxisTools.h"
#include "RiuAbstractOverlayContentFrame.h"
#include "RiuDraggableOverlayFrame.h"
@@ -3092,265 +3092,16 @@ std::vector<RimPlotAxisProperties*> RimSummaryPlot::plotAxes( RimPlotAxisPropert
//--------------------------------------------------------------------------------------------------
void RimSummaryPlot::assignPlotAxis( RimSummaryCurve* destinationCurve )
{
assignXPlotAxis( destinationCurve );
assignYPlotAxis( destinationCurve );
RimSummaryPlotTools::assignXPlotAxis( this, allPlotAxes(), destinationCurve );
RimSummaryPlotTools::assignYPlotAxis( this, allPlotAxes(), destinationCurve );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
auto countAxes = []( const std::vector<RimPlotAxisPropertiesInterface*>& axes, RiaDefines::PlotAxis axis )
{ return std::count_if( axes.begin(), axes.end(), [axis]( const auto& ap ) { return ap->plotAxis().axis() == axis; } ); };
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimSummaryPlot::assignYPlotAxis( RimSummaryCurve* curve )
void RimSummaryPlot::assignPlotAxis( RimEnsembleCurveSet* curveSet )
{
enum class AxisAssignmentStrategy
{
ALTERNATING,
USE_MATCHING_UNIT,
USE_MATCHING_VECTOR
};
auto strategy = AxisAssignmentStrategy::USE_MATCHING_UNIT;
auto destinationUnit = RiaStdStringTools::toUpper( curve->unitNameY() );
if ( destinationUnit.empty() ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
auto anyCurveWithUnitText = [this, curve]
{
for ( auto c : summaryCurves() )
{
if ( c == curve ) continue;
if ( !c->unitNameY().empty() ) return true;
}
return false;
};
if ( !anyCurveWithUnitText() ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
if ( strategy == AxisAssignmentStrategy::USE_MATCHING_VECTOR )
{
// Special handling if curve unit is matching. Try to match on summary vector name to avoid creation of new axis
for ( auto c : summaryCurves() )
{
if ( c == curve ) continue;
auto incomingAxisText = RimPlotAxisTools::axisTextForAddress( curve->summaryAddressY() );
auto currentAxisText = RimPlotAxisTools::axisTextForAddress( c->summaryAddressY() );
if ( incomingAxisText == currentAxisText )
{
curve->setLeftOrRightAxisY( c->axisY() );
return;
}
}
}
else if ( strategy == AxisAssignmentStrategy::USE_MATCHING_UNIT )
{
for ( auto c : summaryCurves() )
{
if ( c == curve ) continue;
auto currentUnit = RiaStdStringTools::toUpper( c->unitNameY() );
if ( currentUnit == destinationUnit )
{
for ( RimPlotAxisPropertiesInterface* axisProperties : m_axisPropertiesArray )
{
if ( axisProperties->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_LEFT ||
axisProperties->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_RIGHT )
{
curve->setLeftOrRightAxisY( c->axisY() );
return;
}
}
}
}
strategy = AxisAssignmentStrategy::ALTERNATING;
}
auto isDefaultLeftAndRightUsed = [this]( RimSummaryCurve* currentCurve ) -> std::pair<bool, bool>
{
bool defaultLeftUsed = false;
bool defaultRightUsed = false;
for ( auto c : summaryCurves() )
{
if ( c == currentCurve ) continue;
if ( c->axisY() == RiuPlotAxis::defaultLeft() ) defaultLeftUsed = true;
if ( c->axisY() == RiuPlotAxis::defaultRight() ) defaultRightUsed = true;
}
return std::make_pair( defaultLeftUsed, defaultRightUsed );
};
auto [defaultLeftUsed, defaultRightUsed] = isDefaultLeftAndRightUsed( curve );
if ( !defaultLeftUsed )
{
curve->setLeftOrRightAxisY( RiuPlotAxis::defaultLeft() );
return;
}
if ( !defaultRightUsed )
{
curve->setLeftOrRightAxisY( RiuPlotAxis::defaultRight() );
return;
}
RiaDefines::PlotAxis plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_LEFT;
if ( strategy == AxisAssignmentStrategy::ALTERNATING )
{
size_t axisCountLeft = countAxes( m_axisPropertiesArray.childrenByType(), RiaDefines::PlotAxis::PLOT_AXIS_LEFT );
size_t axisCountRight = countAxes( m_axisPropertiesArray.childrenByType(), RiaDefines::PlotAxis::PLOT_AXIS_RIGHT );
if ( axisCountLeft > axisCountRight ) plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_RIGHT;
}
if ( plotWidget() && plotWidget()->isMultiAxisSupported() )
{
auto newPlotAxis = plotWidget()->createNextPlotAxis( plotAxisType );
addNewAxisProperties( newPlotAxis, "New Axis" );
curve->setLeftOrRightAxisY( newPlotAxis );
return;
}
// If we get here, we have no more axes to assign to, use left axis as fallback
curve->setLeftOrRightAxisY( RiuPlotAxis::defaultLeft() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimSummaryPlot::assignXPlotAxis( RimSummaryCurve* curve )
{
RiuPlotAxis newPlotAxis = RimSummaryPlot::plotAxisForTime();
if ( curve->axisTypeX() == RiaDefines::HorizontalAxisType::SUMMARY_VECTOR )
{
enum class AxisAssignmentStrategy
{
ALL_TOP,
ALL_BOTTOM,
ALTERNATING,
USE_MATCHING_UNIT,
USE_MATCHING_VECTOR
};
auto strategy = AxisAssignmentStrategy::USE_MATCHING_UNIT;
auto destinationUnit = RiaStdStringTools::toUpper( curve->unitNameX() );
if ( destinationUnit.empty() ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
auto anyCurveWithUnitText = [this, curve]
{
for ( auto c : summaryCurves() )
{
if ( c == curve ) continue;
if ( !c->unitNameX().empty() ) return true;
}
return false;
};
if ( !anyCurveWithUnitText() ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
if ( strategy == AxisAssignmentStrategy::USE_MATCHING_VECTOR )
{
// Special handling if curve unit is matching. Try to match on summary vector name to avoid creation of new axis
for ( auto c : summaryCurves() )
{
if ( c == curve ) continue;
auto incomingAxisText = RimPlotAxisTools::axisTextForAddress( curve->summaryAddressY() );
auto currentAxisText = RimPlotAxisTools::axisTextForAddress( c->summaryAddressY() );
if ( incomingAxisText == currentAxisText )
{
curve->setTopOrBottomAxisX( c->axisX() );
return;
}
}
}
else if ( strategy == AxisAssignmentStrategy::USE_MATCHING_UNIT )
{
bool isTopUsed = false;
bool isBottomUsed = false;
for ( auto c : summaryCurves() )
{
if ( c == curve ) continue;
if ( c->axisX() == RiuPlotAxis::defaultTop() ) isTopUsed = true;
if ( c->axisX() == RiuPlotAxis::defaultBottomForSummaryVectors() ) isBottomUsed = true;
auto currentUnit = RiaStdStringTools::toUpper( c->unitNameX() );
if ( currentUnit == destinationUnit )
{
for ( RimPlotAxisPropertiesInterface* axisProperties : m_axisPropertiesArray )
{
if ( axisProperties->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_TOP ||
axisProperties->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM )
{
curve->setTopOrBottomAxisX( c->axisX() );
return;
}
}
}
}
if ( !isTopUsed )
{
curve->setTopOrBottomAxisX( RiuPlotAxis::defaultTop() );
return;
}
if ( !isBottomUsed )
{
curve->setTopOrBottomAxisX( RiuPlotAxis::defaultBottomForSummaryVectors() );
return;
}
strategy = AxisAssignmentStrategy::ALTERNATING;
}
RiaDefines::PlotAxis plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_TOP;
if ( strategy == AxisAssignmentStrategy::ALTERNATING )
{
size_t axisCountTop = countAxes( m_axisPropertiesArray.childrenByType(), RiaDefines::PlotAxis::PLOT_AXIS_TOP );
size_t axisCountBot = countAxes( m_axisPropertiesArray.childrenByType(), RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM );
if ( axisCountTop > axisCountBot ) plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM;
}
else if ( strategy == AxisAssignmentStrategy::ALL_TOP )
{
plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_TOP;
}
else if ( strategy == AxisAssignmentStrategy::ALL_BOTTOM )
{
plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM;
}
RiuPlotAxis newPlotAxis = RiuPlotAxis::defaultBottomForSummaryVectors();
if ( plotWidget() && plotWidget()->isMultiAxisSupported() )
{
newPlotAxis = plotWidget()->createNextPlotAxis( plotAxisType );
addNewAxisProperties( newPlotAxis, "New Axis" );
}
}
curve->setTopOrBottomAxisX( newPlotAxis );
RimSummaryPlotTools::assignYPlotAxis( this, allPlotAxes(), curveSet );
}
//--------------------------------------------------------------------------------------------------
@@ -207,6 +207,9 @@ public:
RimPlotAxisProperties* addNewAxisProperties( RiaDefines::PlotAxis, const QString& name );
RimPlotAxisProperties* addNewAxisProperties( RiuPlotAxis plotAxis, const QString& name );
void findOrAssignPlotAxisX( RimSummaryCurve* curve );
void assignPlotAxis( RimEnsembleCurveSet* curveSet );
static RiuPlotAxis plotAxisForTime();
std::vector<RimPlotCurve*> visibleCurvesForLegend() override;
@@ -303,8 +306,6 @@ private:
void ensureRequiredAxisObjectsForCurves();
void assignPlotAxis( RimSummaryCurve* curve );
void assignYPlotAxis( RimSummaryCurve* curve );
void assignXPlotAxis( RimSummaryCurve* curve );
RimSummaryCurve* addNewCurve( const RifEclipseSummaryAddress& address,
RimSummaryCase* summaryCase,
@@ -324,8 +325,6 @@ private:
bool isOnlyWaterCutCurvesVisible( RiuPlotAxis plotAxis );
static RiuPlotAxis plotAxisForTime();
private:
caf::PdmField<bool> m_normalizeCurveYValues;
@@ -0,0 +1,291 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 <http://www.gnu.org/licenses/gpl.html>
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "RimSummaryPlotTools.h"
#include "RiaStdStringTools.h"
#include "Summary/RiaSummaryAddressModifier.h"
#include "RimEnsembleCurveSet.h"
#include "RimPlotAxisPropertiesInterface.h"
#include "RimSummaryCurve.h"
#include "RimSummaryPlot.h"
#include "Tools/RimPlotAxisTools.h"
#include "RiuPlotAxis.h"
#include "RiuPlotWidget.h"
#include <algorithm>
#include <variant>
namespace internal
{
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
size_t countAxes( const std::vector<RimPlotAxisPropertiesInterface*>& axes, RiaDefines::PlotAxis axis )
{
return std::count_if( axes.begin(), axes.end(), [axis]( const auto& ap ) { return ap->plotAxis().axis() == axis; } );
}
//--------------------------------------------------------------------------------------------------
/// Unit, vector and axis of a curve or ensemble curve set already present in the plot, used to
/// auto-assign a newly added curve or curve set to a matching or unused Y-axis.
//--------------------------------------------------------------------------------------------------
struct AxisCandidateInfo
{
std::string unitName;
QString vectorAxisText;
RiuPlotAxis axis;
};
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
AxisCandidateInfo axisCandidateInfo( const RiaSummaryAddressModifier::CurveAddressProvider& provider )
{
return std::visit(
[]( auto&& arg )
{ return AxisCandidateInfo{ arg->unitNameY(), RimPlotAxisTools::axisTextForAddress( arg->summaryAddressY() ), arg->axisY() }; },
provider );
}
//--------------------------------------------------------------------------------------------------
/// Find the Y-axis to use for a curve or curve set, based on matching unit or summary vector of the other curves and curve sets.
/// Creates a new axis if required.
//--------------------------------------------------------------------------------------------------
RiuPlotAxis findOrCreateYPlotAxis( RimSummaryPlot* plot,
const std::vector<RimPlotAxisPropertiesInterface*>& axisProperties,
const AxisCandidateInfo& incoming,
const std::vector<AxisCandidateInfo>& others )
{
enum class AxisAssignmentStrategy
{
ALTERNATING,
USE_MATCHING_UNIT,
USE_MATCHING_VECTOR
};
auto strategy = AxisAssignmentStrategy::USE_MATCHING_UNIT;
auto destinationUnit = RiaStdStringTools::toUpper( incoming.unitName );
bool anyOtherWithUnitText = std::any_of( others.begin(), others.end(), []( const auto& o ) { return !o.unitName.empty(); } );
if ( destinationUnit.empty() || !anyOtherWithUnitText ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
if ( strategy == AxisAssignmentStrategy::USE_MATCHING_VECTOR )
{
// Special handling if curve unit is matching. Try to match on summary vector name to avoid creation of new axis
for ( const auto& o : others )
{
if ( incoming.vectorAxisText == o.vectorAxisText ) return o.axis;
}
}
else if ( strategy == AxisAssignmentStrategy::USE_MATCHING_UNIT )
{
bool hasLeftOrRightAxis = std::any_of( axisProperties.begin(),
axisProperties.end(),
[]( const auto& ap )
{
return ap->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_LEFT ||
ap->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_RIGHT;
} );
if ( hasLeftOrRightAxis )
{
for ( const auto& o : others )
{
if ( RiaStdStringTools::toUpper( o.unitName ) == destinationUnit ) return o.axis;
}
}
strategy = AxisAssignmentStrategy::ALTERNATING;
}
bool defaultLeftUsed = std::any_of( others.begin(), others.end(), []( const auto& o ) { return o.axis == RiuPlotAxis::defaultLeft(); } );
if ( !defaultLeftUsed ) return RiuPlotAxis::defaultLeft();
bool defaultRightUsed = std::any_of( others.begin(), others.end(), []( const auto& o ) { return o.axis == RiuPlotAxis::defaultRight(); } );
if ( !defaultRightUsed ) return RiuPlotAxis::defaultRight();
RiaDefines::PlotAxis plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_LEFT;
if ( strategy == AxisAssignmentStrategy::ALTERNATING )
{
size_t axisCountLeft = countAxes( axisProperties, RiaDefines::PlotAxis::PLOT_AXIS_LEFT );
size_t axisCountRight = countAxes( axisProperties, RiaDefines::PlotAxis::PLOT_AXIS_RIGHT );
if ( axisCountLeft > axisCountRight ) plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_RIGHT;
}
if ( plot->plotWidget() && plot->plotWidget()->isMultiAxisSupported() )
{
auto newPlotAxis = plot->plotWidget()->createNextPlotAxis( plotAxisType );
plot->addNewAxisProperties( newPlotAxis, "New Axis" );
return newPlotAxis;
}
// If we get here, we have no more axes to assign to, use left axis as fallback
return RiuPlotAxis::defaultLeft();
}
} // namespace internal
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimSummaryPlotTools::assignYPlotAxis( RimSummaryPlot* plot,
const std::vector<RimPlotAxisPropertiesInterface*>& axisProperties,
const RiaSummaryAddressModifier::CurveAddressProvider& curveProvider )
{
std::vector<internal::AxisCandidateInfo> others;
for ( const auto& provider : RiaSummaryAddressModifier::createAddressProviders( plot ) )
{
if ( provider != curveProvider ) others.push_back( internal::axisCandidateInfo( provider ) );
}
const auto axis = internal::findOrCreateYPlotAxis( plot, axisProperties, internal::axisCandidateInfo( curveProvider ), others );
std::visit( [axis]( auto&& arg ) { arg->setLeftOrRightAxisY( axis ); }, curveProvider );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimSummaryPlotTools::assignXPlotAxis( RimSummaryPlot* plot,
const std::vector<RimPlotAxisPropertiesInterface*>& axisProperties,
RimSummaryCurve* curve )
{
RiuPlotAxis newPlotAxis = RimSummaryPlot::plotAxisForTime();
if ( curve->axisTypeX() == RiaDefines::HorizontalAxisType::SUMMARY_VECTOR )
{
enum class AxisAssignmentStrategy
{
ALL_TOP,
ALL_BOTTOM,
ALTERNATING,
USE_MATCHING_UNIT,
USE_MATCHING_VECTOR
};
auto strategy = AxisAssignmentStrategy::USE_MATCHING_UNIT;
auto destinationUnit = RiaStdStringTools::toUpper( curve->unitNameX() );
if ( destinationUnit.empty() ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
auto anyCurveWithUnitText = [plot, curve]
{
for ( auto c : plot->summaryCurves() )
{
if ( c == curve ) continue;
if ( !c->unitNameX().empty() ) return true;
}
return false;
};
if ( !anyCurveWithUnitText() ) strategy = AxisAssignmentStrategy::USE_MATCHING_VECTOR;
if ( strategy == AxisAssignmentStrategy::USE_MATCHING_VECTOR )
{
// Special handling if curve unit is matching. Try to match on summary vector name to avoid creation of new axis
for ( auto c : plot->summaryCurves() )
{
if ( c == curve ) continue;
auto incomingAxisText = RimPlotAxisTools::axisTextForAddress( curve->summaryAddressY() );
auto currentAxisText = RimPlotAxisTools::axisTextForAddress( c->summaryAddressY() );
if ( incomingAxisText == currentAxisText )
{
curve->setTopOrBottomAxisX( c->axisX() );
return;
}
}
}
else if ( strategy == AxisAssignmentStrategy::USE_MATCHING_UNIT )
{
bool isTopUsed = false;
bool isBottomUsed = false;
for ( auto c : plot->summaryCurves() )
{
if ( c == curve ) continue;
if ( c->axisX() == RiuPlotAxis::defaultTop() ) isTopUsed = true;
if ( c->axisX() == RiuPlotAxis::defaultBottomForSummaryVectors() ) isBottomUsed = true;
auto currentUnit = RiaStdStringTools::toUpper( c->unitNameX() );
if ( currentUnit == destinationUnit )
{
for ( RimPlotAxisPropertiesInterface* ap : axisProperties )
{
if ( ap->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_TOP ||
ap->plotAxis().axis() == RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM )
{
curve->setTopOrBottomAxisX( c->axisX() );
return;
}
}
}
}
if ( !isTopUsed )
{
curve->setTopOrBottomAxisX( RiuPlotAxis::defaultTop() );
return;
}
if ( !isBottomUsed )
{
curve->setTopOrBottomAxisX( RiuPlotAxis::defaultBottomForSummaryVectors() );
return;
}
strategy = AxisAssignmentStrategy::ALTERNATING;
}
RiaDefines::PlotAxis plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_TOP;
if ( strategy == AxisAssignmentStrategy::ALTERNATING )
{
size_t axisCountTop = internal::countAxes( axisProperties, RiaDefines::PlotAxis::PLOT_AXIS_TOP );
size_t axisCountBot = internal::countAxes( axisProperties, RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM );
if ( axisCountTop > axisCountBot ) plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM;
}
else if ( strategy == AxisAssignmentStrategy::ALL_TOP )
{
plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_TOP;
}
else if ( strategy == AxisAssignmentStrategy::ALL_BOTTOM )
{
plotAxisType = RiaDefines::PlotAxis::PLOT_AXIS_BOTTOM;
}
RiuPlotAxis newPlotAxis = RiuPlotAxis::defaultBottomForSummaryVectors();
if ( plot->plotWidget() && plot->plotWidget()->isMultiAxisSupported() )
{
newPlotAxis = plot->plotWidget()->createNextPlotAxis( plotAxisType );
plot->addNewAxisProperties( newPlotAxis, "New Axis" );
}
}
curve->setTopOrBottomAxisX( newPlotAxis );
}
@@ -0,0 +1,41 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 <http://www.gnu.org/licenses/gpl.html>
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RiaPlotDefines.h"
#include "Summary/RiaSummaryAddressModifier.h"
#include <vector>
class RimPlotAxisPropertiesInterface;
class RimSummaryCurve;
class RimSummaryPlot;
namespace RimSummaryPlotTools
{
// Assign a Y-axis based on matching unit or summary vector of the other curves and curve sets in the plot. Creates a new
// axis if required.
void assignYPlotAxis( RimSummaryPlot* plot,
const std::vector<RimPlotAxisPropertiesInterface*>& axisProperties,
const RiaSummaryAddressModifier::CurveAddressProvider& curveProvider );
// Assign an X-axis for curves using a summary vector as X-axis, based on matching unit or summary vector of the other curves in
// the plot. Creates a new axis if required.
void assignXPlotAxis( RimSummaryPlot* plot, const std::vector<RimPlotAxisPropertiesInterface*>& axisProperties, RimSummaryCurve* curve );
} // namespace RimSummaryPlotTools
@@ -97,6 +97,7 @@ set(SOURCE_UNITTEST_FILES
${CMAKE_CURRENT_LIST_DIR}/RimSummaryTimeAxisProperties-Test.cpp
${CMAKE_CURRENT_LIST_DIR}/RimDeltaSummaryEnsemble-Test.cpp
${CMAKE_CURRENT_LIST_DIR}/RimSummaryEnsemble-Test.cpp
${CMAKE_CURRENT_LIST_DIR}/RimSummaryPlot-Test.cpp
${CMAKE_CURRENT_LIST_DIR}/RimEnsembleCurveFilter-Test.cpp
${CMAKE_CURRENT_LIST_DIR}/RifActiveCellsReader-Test.cpp
${CMAKE_CURRENT_LIST_DIR}/RifCsvDataTableFormatter-Test.cpp
@@ -0,0 +1,221 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 <http://www.gnu.org/licenses/gpl.html>
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "gtest/gtest.h"
#include "RifEclipseSummaryAddress.h"
#include "RimEnsembleCurveSet.h"
#include "RimEnsembleCurveSetCollection.h"
#include "RimMockSummaryCase.h"
#include "RimSummaryCurve.h"
#include "RimSummaryEnsemble.h"
#include "RimSummaryPlot.h"
#include "RiuPlotAxis.h"
//--------------------------------------------------------------------------------------------------
/// Regression tests for #14728: a newly added curve or ensemble curve set with a value range/unit
/// differing from the curves already present in the plot should automatically be assigned to an
/// unused secondary Y-axis, instead of always landing on the same axis as the existing curves.
//--------------------------------------------------------------------------------------------------
namespace
{
RimSummaryCurve* createCurve( const std::string& unitName, const RifEclipseSummaryAddress& address )
{
auto* summaryCase = static_cast<RimMockSummaryCase*>( createMockCase( 0 ) );
summaryCase->addVector( address, { 0, 100 }, { 1.0, 2.0 }, unitName );
auto* curve = new RimSummaryCurve();
curve->setSummaryCaseY( summaryCase );
curve->setSummaryAddressY( address );
return curve;
}
// RimEnsembleCurveSet::unitNameY() looks up the unit by querying the first summary case in the
// ensemble that has a reader, so every mock case added to the ensemble must know about every
// address used by any curve set in the same ensemble (mirroring how real ensembles share the same
// set of summary vectors across all realizations).
RimEnsembleCurveSet* createEnsembleCurveSet( RimSummaryEnsemble* ensemble, const std::string& unitName, const RifEclipseSummaryAddress& address )
{
for ( auto existingCase : ensemble->allSummaryCases() )
{
static_cast<RimMockSummaryCase*>( existingCase )->addVector( address, { 0, 100 }, { 1.0, 2.0 }, unitName );
}
auto* summaryCase = static_cast<RimMockSummaryCase*>( createMockCase( 0 ) );
summaryCase->addVector( address, { 0, 100 }, { 1.0, 2.0 }, unitName );
ensemble->addCase( summaryCase, false );
auto* curveSet = new RimEnsembleCurveSet();
curveSet->setSummaryEnsemble( ensemble );
curveSet->setSummaryAddressY( address );
return curveSet;
}
} // namespace
//--------------------------------------------------------------------------------------------------
/// The very first curve added to a plot has nothing to match against, so it must land on the default
/// (left) axis.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_FirstCurveUsesDefaultLeftAxis )
{
RimSummaryPlot plot;
auto* curve = createCurve( "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.addCurveAndUpdate( curve );
EXPECT_EQ( RiuPlotAxis::defaultLeft(), curve->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// Two curves sharing the same unit are assumed to have comparable value ranges, so the second curve
/// should reuse the first curve's axis instead of creating a new one.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_MatchingUnitCurveReusesSameAxis )
{
RimSummaryPlot plot;
auto* curveA = createCurve( "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.addCurveAndUpdate( curveA );
auto* curveB = createCurve( "SM3", RifEclipseSummaryAddress::wellAddress( "WOPT", "W-1" ) );
plot.addCurveAndUpdate( curveB );
EXPECT_EQ( curveA->axisY(), curveB->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// A curve with a different unit than the curves already in the plot (e.g. a rate vector added
/// alongside a cumulative vector) must be automatically assigned to the unused secondary (right)
/// axis rather than being added to the same axis as the existing curve.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_DifferingUnitCurveUsesSecondaryAxis )
{
RimSummaryPlot plot;
auto* fopt = createCurve( "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.addCurveAndUpdate( fopt );
auto* fopr = createCurve( "SM3/DAY", RifEclipseSummaryAddress::fieldAddress( "FOPR" ) );
plot.addCurveAndUpdate( fopr );
EXPECT_EQ( RiuPlotAxis::defaultLeft(), fopt->axisY() );
EXPECT_EQ( RiuPlotAxis::defaultRight(), fopr->axisY() );
EXPECT_NE( fopt->axisY(), fopr->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// Same as AssignPlotAxis_FirstCurveUsesDefaultLeftAxis, but for an ensemble curve set. Prior to the
/// fix for #14728, ensemble curve sets had no automatic axis assignment logic at all and were always
/// forced onto the default left axis regardless of their unit.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_FirstEnsembleCurveSetUsesDefaultLeftAxis )
{
RimSummaryPlot plot;
RimSummaryEnsemble ensemble;
ensemble.setAsEnsemble( true );
auto* curveSet = createEnsembleCurveSet( &ensemble, "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.ensembleCurveSetCollection()->addCurveSet( curveSet );
EXPECT_EQ( RiuPlotAxis::defaultLeft(), curveSet->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// Two ensemble curve sets with differing units (e.g. FOPT and FOPR) must be automatically assigned
/// to different axes.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_DifferingUnitEnsembleCurveSetsUseDifferentAxes )
{
RimSummaryPlot plot;
RimSummaryEnsemble ensemble;
ensemble.setAsEnsemble( true );
auto* foptCurveSet = createEnsembleCurveSet( &ensemble, "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.ensembleCurveSetCollection()->addCurveSet( foptCurveSet );
auto* foprCurveSet = createEnsembleCurveSet( &ensemble, "SM3/DAY", RifEclipseSummaryAddress::fieldAddress( "FOPR" ) );
plot.ensembleCurveSetCollection()->addCurveSet( foprCurveSet );
EXPECT_EQ( RiuPlotAxis::defaultLeft(), foptCurveSet->axisY() );
EXPECT_EQ( RiuPlotAxis::defaultRight(), foprCurveSet->axisY() );
EXPECT_NE( foptCurveSet->axisY(), foprCurveSet->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// Two ensemble curve sets sharing the same unit are assumed to have comparable value ranges, and
/// should reuse the same axis to avoid creating unnecessary additional axes.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_MatchingUnitEnsembleCurveSetsReuseSameAxis )
{
RimSummaryPlot plot;
RimSummaryEnsemble ensemble;
ensemble.setAsEnsemble( true );
auto* curveSetA = createEnsembleCurveSet( &ensemble, "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.ensembleCurveSetCollection()->addCurveSet( curveSetA );
auto* curveSetB = createEnsembleCurveSet( &ensemble, "SM3", RifEclipseSummaryAddress::fieldAddress( "FGPT" ) );
plot.ensembleCurveSetCollection()->addCurveSet( curveSetB );
EXPECT_EQ( curveSetA->axisY(), curveSetB->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// A single curve and an ensemble curve set with differing units must be assigned to different axes,
/// verifying that curves and ensemble curve sets are compared against each other and not just within
/// their own kind.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_SingleCurveAndEnsembleCurveSetWithDifferingUnitsUseDifferentAxes )
{
RimSummaryPlot plot;
RimSummaryEnsemble ensemble;
ensemble.setAsEnsemble( true );
auto* singleCurve = createCurve( "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.addCurveAndUpdate( singleCurve );
auto* curveSet = createEnsembleCurveSet( &ensemble, "SM3/DAY", RifEclipseSummaryAddress::fieldAddress( "FOPR" ) );
plot.ensembleCurveSetCollection()->addCurveSet( curveSet );
EXPECT_EQ( RiuPlotAxis::defaultLeft(), singleCurve->axisY() );
EXPECT_EQ( RiuPlotAxis::defaultRight(), curveSet->axisY() );
}
//--------------------------------------------------------------------------------------------------
/// Same as above, but with the ensemble curve set added first and the single curve with a differing
/// unit added second.
//--------------------------------------------------------------------------------------------------
TEST( RimSummaryPlot, AssignPlotAxis_EnsembleCurveSetAndSingleCurveWithDifferingUnitsUseDifferentAxes )
{
RimSummaryPlot plot;
RimSummaryEnsemble ensemble;
ensemble.setAsEnsemble( true );
auto* curveSet = createEnsembleCurveSet( &ensemble, "SM3", RifEclipseSummaryAddress::fieldAddress( "FOPT" ) );
plot.ensembleCurveSetCollection()->addCurveSet( curveSet );
auto* singleCurve = createCurve( "SM3/DAY", RifEclipseSummaryAddress::fieldAddress( "FOPR" ) );
plot.addCurveAndUpdate( singleCurve );
EXPECT_EQ( RiuPlotAxis::defaultLeft(), curveSet->axisY() );
EXPECT_EQ( RiuPlotAxis::defaultRight(), singleCurve->axisY() );
}