From 5afb5c8a17ed6226862a98bd91d6faa54b7377e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Herje?= <82032112+jorgenherje@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:22:40 +0200 Subject: [PATCH] Sumo summary data using ri-cloud-api (#14503) - Fetch SUMO summary data using ri-cloud-api - Update ri-cloud-api submodule to get improved speed on sumo search --- .../Application/RiaApplication.cpp | 19 +- .../Tools/Cloud/RiaCloudApiService.cpp | 42 +- .../Tools/Cloud/RiaCloudApiService.h | 22 +- .../Tools/Cloud/RiaCloudConnector.h | 4 +- .../Tools/Cloud/RiaSumoConnector.cpp | 640 +++++++----------- .../Tools/Cloud/RiaSumoConnector.h | 32 +- .../RicSumoDataFeature.cpp | 6 +- .../Sumo/RicCreateSumoEnsembleFeature.cpp | 4 +- .../Cloud/RimCloudDataSourceCollection.cpp | 60 +- .../Cloud/RimCloudDataSourceCollection.h | 18 +- .../Summary/Sumo/CMakeLists_files.cmake | 2 +- .../Summary/Sumo/RimSummaryEnsembleSumo.cpp | 274 ++++++-- .../Summary/Sumo/RimSummaryEnsembleSumo.h | 10 +- .../Summary/Sumo/RimSummarySumoDataSource.cpp | 228 ------- .../Summary/Sumo/RimSumoDataSource.cpp | 327 +++++++++ ...rySumoDataSource.h => RimSumoDataSource.h} | 35 +- .../ProjectDataModelCommands/RimcProject.cpp | 1 + docs/cloud-service-api.md | 4 + scripts/ri-cloud-api | 2 +- 19 files changed, 980 insertions(+), 750 deletions(-) delete mode 100644 ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.cpp rename ApplicationLibCode/ProjectDataModel/Summary/Sumo/{RimSummarySumoDataSource.h => RimSumoDataSource.h} (57%) diff --git a/ApplicationLibCode/Application/RiaApplication.cpp b/ApplicationLibCode/Application/RiaApplication.cpp index 86f90d34da..c73806647a 100644 --- a/ApplicationLibCode/Application/RiaApplication.cpp +++ b/ApplicationLibCode/Application/RiaApplication.cpp @@ -156,6 +156,11 @@ RiaApplication* RiaApplication::s_riaApplication = nullptr; +// Address and port of the local 'ri_cloud_api' service. RiaCloudApiService launches the service here, +// and RiaSumoConnector reads all Sumo data through it, so the two must agree on the address. +constexpr auto CLOUD_API_SERVER_ADDRESS = "http://127.0.0.1"; +constexpr int CLOUD_API_SERVER_PORT = 8000; + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -1921,17 +1926,23 @@ RiaSumoConnector* RiaApplication::makeSumoConnector() if ( !m_sumoConnector ) { auto sumoPrefs = preferences()->sumoPreferences(); - const QString server = sumoPrefs->server(); const QString authority = sumoPrefs->authority(); const QString scopes = sumoPrefs->scopes(); const QString clientId = sumoPrefs->clientId(); const unsigned int port = 53527; - m_sumoConnector = new RiaSumoConnector( RiuMainWindow::instance(), server, authority, scopes, clientId, port ); + // The Sumo data is read through the local ri_cloud_api service, so the connector asks that service + // for its address before every request. The authentication parameters still come from the Sumo + // configuration. + auto serverUrlProvider = [this]() { return cloudApiService()->serverUrl(); }; + + m_sumoConnector = new RiaSumoConnector( RiuMainWindow::instance(), serverUrlProvider, authority, scopes, clientId, port ); m_sumoConnector->setTokenDataFilePath( RiaSumoDefines::tokenPath() ); m_sumoConnector->importTokenFromFile(); - // Start the local ri_cloud_api service once Sumo authentication has been performed. + // Start the local ri_cloud_api service once Sumo authentication has been performed. Every request + // asks for a token first, so this also covers project loading, where the Sumo data is read without + // any user interaction. QObject::connect( m_sumoConnector, &RiaSumoConnector::tokenReady, cloudApiService(), @@ -1948,7 +1959,7 @@ RiaCloudApiService* RiaApplication::cloudApiService() { if ( !m_cloudApiService ) { - m_cloudApiService = std::make_unique(); + m_cloudApiService = std::make_unique( CLOUD_API_SERVER_ADDRESS, CLOUD_API_SERVER_PORT ); // Ensure the service process is killed while the event loop is still alive at shutdown. QObject::connect( QCoreApplication::instance(), &QCoreApplication::aboutToQuit, m_cloudApiService.get(), &RiaCloudApiService::stop ); diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp index 07db2fbf1d..d979060f77 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp @@ -52,18 +52,19 @@ constexpr int maxConsecutiveFailures = 2; // Per-request timeout for the health check, kept well below the poll interval. constexpr int healthCheckTimeoutMs = 5 * 1000; -// Range of ports scanned when looking for a free port to bind the service to. -constexpr int firstPortNumber = 8000; +// Number of ports scanned, starting at the wanted port, when looking for a free port to bind to. constexpr int portRangeLength = 100; } // namespace //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -RiaCloudApiService::RiaCloudApiService( QObject* parent ) +RiaCloudApiService::RiaCloudApiService( const QString& serverAddress, int wantedPort, QObject* parent ) : QObject( parent ) , m_process( nullptr ) , m_networkAccessManager( new QNetworkAccessManager( this ) ) + , m_serverAddress( serverAddress ) + , m_wantedPort( wantedPort ) , m_port( -1 ) , m_consecutiveFailures( 0 ) , m_isResponding( false ) @@ -118,6 +119,16 @@ int RiaCloudApiService::port() const return m_port; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaCloudApiService::serverUrl() const +{ + if ( m_port < 0 ) return {}; + + return QString( "%1:%2" ).arg( m_serverAddress ).arg( m_port ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -142,7 +153,14 @@ void RiaCloudApiService::start() return; } - const int port = findAvailablePortNumber(); + const QString host = QUrl( m_serverAddress ).host(); + if ( host.isEmpty() ) + { + RiaLogging::error( std::format( "Cloud API service: invalid server address '{}', cannot start service.", m_serverAddress ) ); + return; + } + + const int port = findAvailablePortNumber( m_wantedPort ); if ( port < 0 ) { RiaLogging::error( "Cloud API service: no available port found, cannot start service." ); @@ -154,9 +172,7 @@ void RiaCloudApiService::start() arguments << "-m" << "uvicorn" << "ri_cloud_api.main:app" - << "--host" - << "127.0.0.1" - << "--port" << QString::number( m_port ); + << "--host" << host << "--port" << QString::number( m_port ); m_process = new QProcess( this ); m_process->setWorkingDirectory( workingDirectory ); @@ -205,7 +221,7 @@ void RiaCloudApiService::start() m_process->start( pythonExecutable, arguments ); - RiaLogging::info( std::format( "Cloud API service: starting on http://127.0.0.1:{}.", m_port ) ); + RiaLogging::info( std::format( "Cloud API service: starting on {}.", serverUrl() ) ); emit statusChanged(); @@ -258,7 +274,7 @@ void RiaCloudApiService::onReadyReadStandardOutput() const QString output = QString::fromLocal8Bit( m_process->readAllStandardOutput() ); for ( const QString& line : output.split( '\n', Qt::SkipEmptyParts ) ) { - RiaLogging::info( std::format( "Cloud API service: {}", line.trimmed() ) ); + RiaLogging::debug( std::format( "Cloud API service: {}", line.trimmed() ) ); } } @@ -273,7 +289,7 @@ void RiaCloudApiService::onHealthCheck() return; } - QNetworkRequest request( QUrl( QString( "http://127.0.0.1:%1/alive" ).arg( m_port ) ) ); + QNetworkRequest request( QUrl( serverUrl() + "/alive" ) ); request.setTransferTimeout( healthCheckTimeoutMs ); QNetworkReply* reply = m_networkAccessManager->get( request ); @@ -311,12 +327,12 @@ void RiaCloudApiService::onHealthCheck() //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -int RiaCloudApiService::findAvailablePortNumber() +int RiaCloudApiService::findAvailablePortNumber( int firstPort ) { - const int endPort = std::min( firstPortNumber + portRangeLength, (int)std::numeric_limits::max() ); + const int endPort = std::min( firstPort + portRangeLength, (int)std::numeric_limits::max() ); QTcpServer serverTest; - for ( quint16 port = static_cast( firstPortNumber ); port <= static_cast( endPort ); ++port ) + for ( quint16 port = static_cast( firstPort ); port <= static_cast( endPort ); ++port ) { if ( serverTest.listen( QHostAddress::LocalHost, port ) ) { diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h index ec0a3a155d..67ab1d560d 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h @@ -31,16 +31,21 @@ class QNetworkAccessManager; /// Manages the life cycle of the local "ri_cloud_api" uvicorn service. /// /// The service is a long-lived localhost process (unlike the single-shot script worker process in -/// RiaApplication). It is started when Sumo cloud authentication is performed, bound to an -/// automatically selected free port, polled for liveness on its /alive endpoint, restarted on a -/// new port if it stops responding, and killed when ResInsight closes. +/// RiaApplication). It is started when Sumo cloud authentication is performed and from the Cloud Data +/// user interface, bound to the first available port at or above the wanted port, polled for liveness +/// on its /alive endpoint, restarted if it stops responding, and killed when ResInsight closes. +/// +/// A restart scans for a port again, so the port in use can change during a session. Always read the +/// current address from serverUrl() instead of caching it. /// //================================================================================================== class RiaCloudApiService : public QObject { Q_OBJECT public: - explicit RiaCloudApiService( QObject* parent = nullptr ); + // The server address is the base URL without the port, e.g. "http://127.0.0.1". The wanted port is + // where the search for an available port starts. + RiaCloudApiService( const QString& serverAddress, int wantedPort, QObject* parent = nullptr ); ~RiaCloudApiService() override; void start(); @@ -56,6 +61,10 @@ public: int port() const; + // Base URL of the service, including the port actually in use, e.g. "http://127.0.0.1:8001". Empty + // when the service is not running. + QString serverUrl() const; + signals: // Emitted when isRunning() or isResponding() changes, so that user interface elements showing // the server status can refresh. The status changes without any user interaction, driven by the @@ -67,7 +76,7 @@ private slots: void onReadyReadStandardOutput(); private: - static int findAvailablePortNumber(); + static int findAvailablePortNumber( int firstPort ); static QString serviceWorkingDirectory(); static QProcessEnvironment buildProcessEnvironment( const QString& workingDirectory ); @@ -77,6 +86,9 @@ private: QTimer m_startupTimer; QTimer m_healthTimer; + const QString m_serverAddress; + const int m_wantedPort; + int m_port; int m_consecutiveFailures; bool m_isResponding; diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudConnector.h b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudConnector.h index d4ddc83b69..a36d19708d 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudConnector.h +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudConnector.h @@ -46,7 +46,9 @@ public: void setTokenDataFilePath( const QString& filePath ); - QString server() const; + // Virtual, as a derived connector may talk to a server whose address is not known when the connector + // is constructed, and can change while it is alive. + virtual QString server() const; QString requestTokenBlocking(); void requestTokenWithCancelButton(); diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.cpp b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.cpp index eccc493b8d..31117524cf 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.cpp +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.cpp @@ -36,16 +36,29 @@ //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -RiaSumoConnector::RiaSumoConnector( QObject* parent, - const QString& server, - const QString& authority, - const QString& scopes, - const QString& clientId, - unsigned int port ) - : RiaCloudConnector( parent, server, authority, scopes, clientId, port ) +RiaSumoConnector::RiaSumoConnector( QObject* parent, + std::function serverUrlProvider, + const QString& authority, + const QString& scopes, + const QString& clientId, + unsigned int port ) + : RiaCloudConnector( parent, {}, authority, scopes, clientId, port ) + , m_serverUrlProvider( std::move( serverUrlProvider ) ) { } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaSumoConnector::server() const +{ + // Ask for the address on every call. The server is bound to the first available port, and gets a new + // port if it is restarted, so a cached address goes stale. + if ( !m_serverUrlProvider ) return {}; + + return m_serverUrlProvider(); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -84,40 +97,20 @@ void RiaSumoConnector::requestCasesForField( const QString& fieldName ) requestTokenBlocking(); QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( constructSearchUrl( m_server ) ) ); + QString url = QString( "%1/cases?asset_name=%2" ).arg( server() ).arg( fieldName ); + m_networkRequest.setUrl( QUrl( url ) ); addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); - QString payloadTemplate = R"( -{ - "query": { - "bool": { - "filter": [ - {"term":{"class.keyword":"case"}}, - {"term":{"access.asset.name.keyword":"%1"}} - ] - } - }, - "sort": [ - {"tracklog.datetime":{"order":"desc"}} - ], - "track_total_hits":true, - "size":100, - "from":0 -} -)"; - - QString payload = payloadTemplate.arg( fieldName ); - auto reply = m_networkAccessManager->post( m_networkRequest, payload.toUtf8() ); + auto reply = m_networkAccessManager->get( m_networkRequest ); connect( reply, &QNetworkReply::finished, [this, reply]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseCases( reply ); - } + // parseCases handles the error case and always emits casesFinished, so the blocking caller + // returns immediately instead of waiting for the request to time out. + parseCases( reply ); } ); } @@ -139,7 +132,7 @@ void RiaSumoConnector::requestAssets() requestTokenBlocking(); QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( m_server + "/api/v1/userpermissions" ) ); + m_networkRequest.setUrl( QUrl( QString( "%1/assets" ).arg( server() ) ) ); addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); @@ -149,10 +142,9 @@ void RiaSumoConnector::requestAssets() &QNetworkReply::finished, [this, reply]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseAssets( reply ); - } + // parseAssets handles the error case and always emits assetsFinished, so the blocking caller + // returns immediately instead of waiting for the request to time out. + parseAssets( reply ); } ); } @@ -173,86 +165,21 @@ void RiaSumoConnector::requestEnsembleByCasesId( const SumoCaseId& caseId ) { requestTokenBlocking(); - // See the following file for query definition - // https://github.com/equinor/fmu-sumo/blob/main/src/fmu/sumo/explorer/objects/case.py - - QString payloadTemplate = R"( - - { - "query": { - "term": { - "fmu.case.uuid.keyword": "%1" - } - }, - "aggs": { - "ensemble_uuids": { - "terms": { - "field": "fmu.ensemble.uuid.keyword", - "size": 100 - } - }, - "ensemble_names": { - "terms": { - "field": "fmu.ensemble.name.keyword", - "size": 100 - } - }, - "data_types": { - "terms": { - "field": "class.keyword", - "size": 100 - } - }, - "ensembles": { - "terms": { - "field": "fmu.ensemble.uuid.keyword", - "size": 100 - }, - "aggs": { - "ensemble_name": { - "terms": { - "field": "fmu.ensemble.name.keyword", - "size": 100 - } - }, - "numreal": { - "cardinality": { - "field": "fmu.realization.id" - } - }, - "maxreal": { - "max": { - "field": "fmu.realization.id" - } - }, - "minreal": { - "min": { - "field": "fmu.realization.id" - } - } - } - } - }, - "size": 0 - } -)"; - QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( m_server + "/api/v1/search" ) ); + QString url = QString( "%1/cases/%2/ensembles" ).arg( server() ).arg( caseId.get() ); + m_networkRequest.setUrl( QUrl( url ) ); addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); - auto payload = payloadTemplate.arg( caseId.get() ); - auto reply = m_networkAccessManager->post( m_networkRequest, payload.toUtf8() ); + auto reply = m_networkAccessManager->get( m_networkRequest ); connect( reply, &QNetworkReply::finished, [this, reply, caseId]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseEnsembleNames( reply, caseId ); - } + // parseEnsembleNames handles the error case and always emits ensembleNamesFinished, so the + // blocking caller returns immediately instead of waiting for the request to time out. + parseEnsembleNames( reply, caseId ); } ); } @@ -261,9 +188,6 @@ void RiaSumoConnector::requestEnsembleByCasesId( const SumoCaseId& caseId ) //-------------------------------------------------------------------------------------------------- void RiaSumoConnector::parseEnsembleNames( QNetworkReply* reply, const SumoCaseId& caseId ) { - // See the following file for parsing of the reply - // https://github.com/equinor/fmu-sumo/blob/main/src/fmu/sumo/explorer/objects/case.py - QByteArray result = reply->readAll(); reply->deleteLater(); @@ -271,17 +195,13 @@ void RiaSumoConnector::parseEnsembleNames( QNetworkReply* reply, const SumoCaseI { m_ensembleNames.clear(); - QJsonDocument doc = QJsonDocument::fromJson( result ); - QJsonObject jsonObj = doc.object(); - QJsonObject aggregationsObject = jsonObj["aggregations"].toObject(); - QJsonObject aggregationColumnsObject = aggregationsObject["ensemble_names"].toObject(); + QJsonDocument doc = QJsonDocument::fromJson( result ); + QJsonArray jsonArray = doc.array(); - QJsonArray bucketsArray = aggregationColumnsObject["buckets"].toArray(); - for ( const QJsonValue& bucket : bucketsArray ) + for ( const QJsonValue& value : jsonArray ) { - QJsonObject bucketObj = bucket.toObject(); - auto ensembleName = bucketObj["key"].toString(); - + QJsonObject ensembleObj = value.toObject(); + QString ensembleName = ensembleObj["name"].toString(); m_ensembleNames.push_back( { caseId, ensembleName } ); } @@ -289,7 +209,7 @@ void RiaSumoConnector::parseEnsembleNames( QNetworkReply* reply, const SumoCaseI } else { - RiaLogging::error( std::format( "Request ensemble names failed: : '%s'", reply->errorString() ) ); + RiaLogging::error( std::format( "Request ensemble names failed: '{}'", reply->errorString() ) ); } emit ensembleNamesFinished(); @@ -312,53 +232,21 @@ void RiaSumoConnector::requestVectorNamesForEnsemble( const SumoCaseId& caseId, { requestTokenBlocking(); - QString payloadTemplate = R"( -{ - "track_total_hits": true, - "query": { "bool": { - "must": [ - {"term": {"class": "table"}}, - {"term": {"_sumo.parent_object.keyword": "%1"}}, - {"term": {"fmu.ensemble.name.keyword": "%2"}}, - {"term": {"fmu.context.stage.keyword": "iteration"}}, - {"term": {"fmu.aggregation.operation.keyword": "collection"}}, - {"term": {"data.tagname.keyword": "summary"}}, - {"term": {"data.content.keyword": "timeseries"}} - ]} - }, - "aggs": { - "smry_tables": { - "terms": { "field": "data.name.keyword" }, - "aggs": { - "smry_columns": { - "terms": { - "field": "data.spec.columns.keyword", - "size": 65535 - } - } - } - } - }, - "_source": false, - "size": 0 -})"; - QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( m_server + "/api/v1/search" ) ); + QString url = QString( "%1/cases/%2/ensembles/%3/vector_list" ).arg( server() ).arg( caseId.get() ).arg( ensembleName ); + m_networkRequest.setUrl( QUrl( url ) ); addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); - auto payload = payloadTemplate.arg( caseId.get() ).arg( ensembleName ); - auto reply = m_networkAccessManager->post( m_networkRequest, payload.toUtf8() ); + auto reply = m_networkAccessManager->get( m_networkRequest ); connect( reply, &QNetworkReply::finished, [this, reply, ensembleName, caseId]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseVectorNames( reply, caseId, ensembleName ); - } + // parseVectorNames handles the error case and always emits vectorNamesFinished, so the + // blocking caller returns immediately instead of waiting for the request to time out. + parseVectorNames( reply, caseId, ensembleName ); } ); } @@ -377,52 +265,25 @@ void RiaSumoConnector::requestVectorNamesForEnsembleBlocking( const SumoCaseId& //-------------------------------------------------------------------------------------------------- void RiaSumoConnector::requestRealizationIdsForEnsemble( const SumoCaseId& caseId, const QString& ensembleName ) { - QString payloadTemplate = R"( -{ - "track_total_hits": true, - "query": { - "bool": { - "must": [ - {"term": {"class": "table"}}, - {"term": {"_sumo.parent_object.keyword": "%1"}}, - {"term": {"fmu.ensemble.name.keyword": "%2"}}, - {"term": {"fmu.context.stage.keyword": "iteration"}}, - {"term": {"fmu.aggregation.operation.keyword": "collection"}}, - {"term": {"data.tagname.keyword": "summary"}}, - {"term": {"data.content.keyword": "timeseries"}} - ] - } - }, - "aggs": { - "realization-ids": { - "terms": { - "field": "fmu.aggregation.realization_ids", - "size": 1000 - } - } - }, - "_source": false, - "size": 0 -} -)"; m_realizationIds.clear(); + requestTokenBlocking(); + QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( m_server + "/api/v1/search" ) ); + QString url = QString( "%1/cases/%2/ensembles/%3/realizations" ).arg( server() ).arg( caseId.get() ).arg( ensembleName ); + m_networkRequest.setUrl( QUrl( url ) ); addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); - auto payload = payloadTemplate.arg( caseId.get() ).arg( ensembleName ); - auto reply = m_networkAccessManager->post( m_networkRequest, payload.toUtf8() ); + auto reply = m_networkAccessManager->get( m_networkRequest ); connect( reply, &QNetworkReply::finished, [this, reply, ensembleName, caseId]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseRealizationNumbers( reply, caseId, ensembleName ); - } + // parseRealizationNumbers handles the error case and always emits realizationIdsFinished, so + // the blocking caller returns immediately instead of waiting for the request to time out. + parseRealizationNumbers( reply, caseId, ensembleName ); } ); } @@ -443,9 +304,9 @@ QByteArray RiaSumoConnector::requestParametersParquetDataBlocking( const SumoCas { requestParametersBlobIdForEnsembleBlocking( caseId, ensembleName ); - if ( m_blobUrl.empty() ) return {}; + if ( m_blobId.empty() ) return {}; - auto blobId = m_blobUrl.back(); + auto blobId = m_blobId.back(); QEventLoop eventLoop; QTimer timer; @@ -486,46 +347,25 @@ void RiaSumoConnector::requestParametersBlobIdForEnsemble( const SumoCaseId& cas { requestTokenBlocking(); - QString payloadTemplate = R"( -{ - "track_total_hits": true, - "query": { "bool": { - "must": [ - {"term": {"class": "table"}}, - {"term": {"_sumo.parent_object.keyword": "%1"}}, - {"term": {"data.tagname.keyword": "all"}}, - {"term": {"fmu.ensemble.name.keyword": "%2"}}, - {"term": {"fmu.aggregation.operation.keyword": "collection"}} - ]} - }, - "fields": [ - "data.name", - "_sumo.blob_name" - ], - "size": 1 -} -)"; + QNetworkRequest networkRequest; - QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( m_server + "/api/v1/search" ) ); + // Properly URL-encode the path components + QString encodedEnsembleName = QUrl::toPercentEncoding( ensembleName ); - addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); + QString url = QString( "%1/cases/%2/ensembles/%3/parameters/blob_id" ).arg( server() ).arg( caseId.get() ).arg( encodedEnsembleName ); + networkRequest.setUrl( QUrl( url ) ); - auto payload = payloadTemplate.arg( caseId.get() ).arg( ensembleName ); - auto reply = m_networkAccessManager->post( m_networkRequest, payload.toUtf8() ); + addStandardHeader( networkRequest, token(), RiaCloudDefines::contentTypeJson() ); + + auto reply = m_networkAccessManager->get( networkRequest ); connect( reply, &QNetworkReply::finished, - [this, reply, ensembleName, caseId, payload]() + [this, reply, ensembleName, caseId]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseBlobIds( reply, caseId, ensembleName, "", true ); - } - else - { - RiaLogging::error( std::format( "Request parameters failed: : '%s'", reply->errorString() ) ); - } + // parseBlobId handles the error case and always emits blobIdFinished, so the blocking + // caller returns immediately instead of waiting for the request to time out. + parseBlobId( reply, caseId, ensembleName, "", true ); } ); } @@ -536,46 +376,27 @@ void RiaSumoConnector::requestBlobIdForEnsemble( const SumoCaseId& caseId, const { requestTokenBlocking(); - QString payloadTemplate = R"( -{ - "track_total_hits": true, - "query": { "bool": { - "must": [ - {"term": {"class": "table"}}, - {"term": {"_sumo.parent_object.keyword": "%1"}}, - {"term": {"fmu.ensemble.name.keyword": "%2"}}, - {"term": {"fmu.context.stage.keyword": "iteration"}}, - {"term": {"fmu.aggregation.operation.keyword": "collection"}}, - {"term": {"data.tagname.keyword": "summary"}}, - {"term": {"data.content.keyword": "timeseries"}}, - {"term": {"data.spec.columns.keyword": "%3"}} - ]} - }, - "fields": [ - "data.name", - "_sumo.blob_name" - ], - "_source": true, - "size": 1 -} -)"; + QNetworkRequest networkRequest; - QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( m_server + "/api/v1/search" ) ); + // Properly URL-encode the path components + QString encodedVectorName = QUrl::toPercentEncoding( vectorName ); + QString encodedEnsembleName = QUrl::toPercentEncoding( ensembleName ); - addStandardHeader( m_networkRequest, token(), RiaCloudDefines::contentTypeJson() ); + QString url = + QString( "%1/cases/%2/ensembles/%3/vectors/%4/blob_id" ).arg( server() ).arg( caseId.get() ).arg( encodedEnsembleName ).arg( encodedVectorName ); + networkRequest.setUrl( QUrl( url ) ); - auto payload = payloadTemplate.arg( caseId.get() ).arg( ensembleName ).arg( vectorName ); - auto reply = m_networkAccessManager->post( m_networkRequest, payload.toUtf8() ); + addStandardHeader( networkRequest, token(), RiaCloudDefines::contentTypeJson() ); + + auto reply = m_networkAccessManager->get( networkRequest ); connect( reply, &QNetworkReply::finished, [this, reply, ensembleName, caseId, vectorName]() { - if ( reply->error() == QNetworkReply::NoError ) - { - parseBlobIds( reply, caseId, ensembleName, vectorName, false ); - } + // parseBlobId handles the error case and always emits blobIdFinished, so the blocking + // caller returns immediately instead of waiting for the request to time out. + parseBlobId( reply, caseId, ensembleName, vectorName, false ); // false = vector data } ); } @@ -596,14 +417,10 @@ void RiaSumoConnector::requestBlobDownload( const QString& blobId ) { requestTokenBlocking(); - QString url = constructDownloadUrl( m_server, blobId ); + QString url = QString( "%1/blobs/%2/sas_token_and_blob_base_uri" ).arg( server() ).arg( blobId ); QNetworkRequest networkRequest; - networkRequest.setUrl( url ); - - // Other redirection policies are NoLessSafeRedirectPolicy, SameOriginRedirectPolicy, UserVerifiedRedirectPolicy. They were tested, but - // did not work. Use ManualRedirectPolicy instead, and inspect the reply for the redirection target. - networkRequest.setAttribute( QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy ); + networkRequest.setUrl( QUrl( url ) ); addStandardHeader( networkRequest, token(), RiaCloudDefines::contentTypeJson() ); @@ -613,65 +430,99 @@ void RiaSumoConnector::requestBlobDownload( const QString& blobId ) &QNetworkReply::finished, [this, reply, blobId, url]() { - if ( reply->error() == QNetworkReply::NoError ) - { - auto contents = reply->readAll(); + reply->deleteLater(); // don't leak the reply - QVariant redirectUrl = reply->attribute( QNetworkRequest::RedirectionTargetAttribute ); - if ( redirectUrl.isValid() ) - { - requestBlobByRedirectUri( blobId, redirectUrl.toString() ); - } - else - { - QString errorMessage = "Not able to parse and interpret valid redirect Url"; - RiaLogging::error( errorMessage.toStdString() ); - } - } - else + if ( reply->error() != QNetworkReply::NoError ) { - QString errorMessage = "Download failed: " + url + " failed." + reply->errorString(); - RiaLogging::error( errorMessage.toStdString() ); + RiaLogging::error( ( "Download failed: " + url + " failed. " + reply->errorString() ).toStdString() ); + return; } + + // The backend returns BlobAccessInfo as JSON: { "sasToken": "...", "blobStoreBaseUri": "..." } + const QByteArray contents = reply->readAll(); + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson( contents, &parseError ); + if ( parseError.error != QJsonParseError::NoError || !doc.isObject() ) + { + RiaLogging::error( + std::format( "Could not parse blob access info response as JSON: {}", parseError.errorString().toStdString() ) ); + return; + } + + const QJsonObject obj = doc.object(); + const QString sasToken = obj.value( "sasToken" ).toString(); + const QString blobBaseUri = obj.value( "blobStoreBaseUri" ).toString(); + if ( blobBaseUri.isEmpty() ) + { + RiaLogging::error( "Blob access info response did not contain a blobStoreBaseUri." ); + return; + } + + const QString sasUri = constructSasUri( blobBaseUri, blobId, sasToken ); + requestBlobBySasUri( blobId, sasUri ); } ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RiaSumoConnector::requestBlobByRedirectUri( const QString& blobId, const QString& redirectUri ) +void RiaSumoConnector::requestBlobBySasUri( const QString& blobId, const QString& sasUri ) { - RiaLogging::debug( std::format( "Requesting blob. Id: {} Redirect URL: {}", blobId, redirectUri ) ); - - requestTokenBlocking(); + RiaLogging::debug( std::format( "Requesting blob. Id: {} SAS URI: {}", blobId, sasUri ) ); QNetworkRequest networkRequest; - networkRequest.setUrl( redirectUri ); + networkRequest.setUrl( sasUri ); + + // The pre-signed SAS URI carries its own credential (signature in the query string), + // so no Authorization header is added here. Do NOT forward the bearer token to the + // storage host. Redirect policy is set explicitly so behaviour is not Qt-version dependent. + networkRequest.setAttribute( QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy ); auto reply = m_networkAccessManager->get( networkRequest ); connect( reply, &QNetworkReply::finished, - [this, reply, blobId, redirectUri]() + [this, reply, blobId, sasUri]() { - if ( reply->error() == QNetworkReply::NoError ) + reply->deleteLater(); + + if ( reply->error() != QNetworkReply::NoError ) { - auto contents = reply->readAll(); - - QString msg = "Received data from : " + redirectUri; - RiaLogging::info( msg.toStdString() ); - - parquetDownloadComplete( blobId, contents, redirectUri ); - - emit parquetDownloadFinished( contents, redirectUri ); - } - else - { - QString errorMessage = "Download failed: " + redirectUri + " failed." + reply->errorString(); + QString errorMessage = "Download failed: " + sasUri + " failed." + reply->errorString(); RiaLogging::error( errorMessage.toStdString() ); - emit parquetDownloadFinished( {}, redirectUri ); + emit parquetDownloadFinished( {}, sasUri ); + return; } + + auto statusCode = reply->attribute( QNetworkRequest::HttpStatusCodeAttribute ).toInt(); + auto contentLength = reply->header( QNetworkRequest::ContentLengthHeader ).toLongLong(); + auto bytesAvailable = reply->bytesAvailable(); + + RiaLogging::debug( + std::format( "Response: status={}, content-length={}, bytes-available={}", statusCode, contentLength, bytesAvailable ) ); + + auto contents = reply->readAll(); + + RiaLogging::debug( std::format( "Read {} bytes from reply", contents.size() ) ); + + // Guard against a silently truncated transfer: a dropped connection on a + // chunked response can still report NoError. If the server told us how many + // bytes to expect and we got fewer, treat it as a failure. + if ( contentLength > 0 && contents.size() != contentLength ) + { + RiaLogging::error( std::format( "Download truncated: expected {} bytes, got {}.", contentLength, contents.size() ) ); + + emit parquetDownloadFinished( {}, sasUri ); + return; + } + + QString msg = "Received data from : " + sasUri; + RiaLogging::debug( msg.toStdString() ); + + parquetDownloadComplete( blobId, contents, sasUri ); + + emit parquetDownloadFinished( contents, sasUri ); } ); } @@ -682,9 +533,10 @@ QByteArray RiaSumoConnector::requestParquetDataBlocking( const SumoCaseId& caseI { requestBlobIdForEnsembleBlocking( caseId, ensembleName, vectorName ); - if ( m_blobUrl.empty() ) return {}; + if ( m_blobId.empty() ) return {}; - auto blobId = m_blobUrl.back(); + // The REST API now returns the complete blob URL, not just an ID + auto blobId = m_blobId.back(); QEventLoop eventLoop; QTimer timer; @@ -709,20 +561,18 @@ QByteArray RiaSumoConnector::requestParquetDataBlocking( const SumoCaseId& caseI } //-------------------------------------------------------------------------------------------------- -// +/// Assemble the pre-signed download URI: {blobStoreBaseUri}/{blobId}?{sasToken} //-------------------------------------------------------------------------------------------------- -QString RiaSumoConnector::constructSearchUrl( const QString& server ) +QString RiaSumoConnector::constructSasUri( const QString& blobStoreBaseUri, const QString& blobId, const QString& sasToken ) { - return server + "/api/v1/search"; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString RiaSumoConnector::constructDownloadUrl( const QString& server, const QString& blobId ) -{ - return server + "/api/v1/objects('" + blobId + "')/blob"; - // https: // main-sumo-prod.radix.equinor.com/api/v1/objects('76d6d11f-2278-3fe2-f12f-77142ad163c6')/blob + QString sasUri = blobStoreBaseUri; + if ( !sasUri.endsWith( '/' ) ) sasUri += '/'; + sasUri += blobId; + if ( !sasToken.isEmpty() ) + { + sasUri += ( sasToken.startsWith( '?' ) ? sasToken : ( "?" + sasToken ) ); + } + return sasUri; } //-------------------------------------------------------------------------------------------------- @@ -750,29 +600,6 @@ void RiaSumoConnector::wrapAndCallNetworkRequest( std::function requestC eventLoop.exec( QEventLoop::ProcessEventsFlag::ExcludeUserInputEvents ); } -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QNetworkReply* RiaSumoConnector::makeRequest( const std::map& parameters, const QString& server, const QString& token ) -{ - QNetworkRequest m_networkRequest; - m_networkRequest.setUrl( QUrl( constructSearchUrl( server ) ) ); - - addStandardHeader( m_networkRequest, token, RiaCloudDefines::contentTypeJson() ); - - QJsonObject obj; - for ( auto [key, value] : parameters ) - { - obj.insert( key, value ); - } - - QJsonDocument doc( obj ); - QString strJson( doc.toJson( QJsonDocument::Compact ) ); - - auto reply = m_networkAccessManager->post( m_networkRequest, strJson.toUtf8() ); - return reply; -} - //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -783,27 +610,26 @@ void RiaSumoConnector::parseAssets( QNetworkReply* reply ) if ( reply->error() == QNetworkReply::NoError ) { - QJsonDocument doc = QJsonDocument::fromJson( result ); - QJsonObject jsonObj = doc.object(); + QJsonDocument doc = QJsonDocument::fromJson( result ); + QJsonArray jsonArray = doc.array(); m_assets.clear(); - for ( auto key : jsonObj.keys() ) + // This json is an array of AssetInfo + for ( const QJsonValue& assetInfo : jsonArray ) { - QString id; - QString kind; - QString fieldName = key; - m_assets.push_back( SumoAsset{ SumoAssetId( id ), kind, fieldName } ); + QString assetName = assetInfo["name"].toString(); + m_assets.push_back( SumoAsset{ SumoAssetId( "" ), "", assetName } ); } for ( auto a : m_assets ) { - RiaLogging::info( std::format( "Asset: {}", a.name ) ); + RiaLogging::debug( std::format( "Asset: {}", a.name ) ); } } else { - RiaLogging::error( std::format( "Request assets failed: : '%s'", reply->errorString() ) ); + RiaLogging::error( std::format( "Request assets failed: '{}'", reply->errorString() ) ); } emit assetsFinished(); @@ -819,31 +645,26 @@ void RiaSumoConnector::parseCases( QNetworkReply* reply ) if ( reply->error() == QNetworkReply::NoError ) { - QJsonDocument doc = QJsonDocument::fromJson( result ); - QJsonObject jsonObj = doc.object(); - QJsonObject rootHits = jsonObj["hits"].toObject(); - QJsonArray hitsObjects = rootHits["hits"].toArray(); + QJsonDocument doc = QJsonDocument::fromJson( result ); + QJsonArray jsonArray = doc.array(); m_cases.clear(); - for ( const QJsonValue& value : hitsObjects ) + for ( const QJsonValue& value : jsonArray ) { - QJsonObject resultObj = value.toObject(); - QJsonObject sourceObj = resultObj["_source"].toObject(); - QJsonObject fmuObj = sourceObj["fmu"].toObject(); - QJsonObject fmuCase = fmuObj["case"].toObject(); + QJsonObject caseObj = value.toObject(); - QString id = resultObj["_id"].toString(); - QString kind = ""; - QString fieldName = fmuCase["name"].toString(); - m_cases.push_back( SumoCase{ SumoCaseId( id ), kind, fieldName } ); + QString id = caseObj["id"].toString(); + QString kind = ""; + QString name = caseObj["name"].toString(); + m_cases.push_back( SumoCase{ SumoCaseId( id ), kind, name } ); } RiaLogging::debug( std::format( "Case count : {}", m_cases.size() ) ); } else { - RiaLogging::error( std::format( "Request cases failed: : '%s'", reply->errorString() ) ); + RiaLogging::error( std::format( "Request cases failed: '{}'", reply->errorString() ) ); } emit casesFinished(); @@ -861,22 +682,19 @@ void RiaSumoConnector::parseVectorNames( QNetworkReply* reply, const SumoCaseId& if ( reply->error() == QNetworkReply::NoError ) { - QJsonDocument doc = QJsonDocument::fromJson( result ); - QJsonObject jsonObj = doc.object(); + QJsonDocument doc = QJsonDocument::fromJson( result ); + QJsonArray jsonArray = doc.array(); - QJsonArray tableHits = jsonObj["aggregations"].toObject()["smry_tables"].toObject()["buckets"].toArray(); - for ( const auto& tableHit : tableHits ) + for ( const QJsonValue& value : jsonArray ) { - QJsonArray columnHits = tableHit.toObject()["smry_columns"].toObject()["buckets"].toArray(); - for ( const auto& columnHit : columnHits ) - { - m_vectorNames.push_back( columnHit.toObject()["key"].toString() ); - } + QJsonObject vectorObj = value.toObject(); + QString vectorName = vectorObj["name"].toString(); + m_vectorNames.push_back( vectorName ); } } else { - RiaLogging::error( std::format( "Request vector names failed: : '%s'", reply->errorString() ) ); + RiaLogging::error( std::format( "Request vector names failed: '{}'", reply->errorString() ) ); } emit vectorNamesFinished(); @@ -892,20 +710,20 @@ void RiaSumoConnector::parseRealizationNumbers( QNetworkReply* reply, const Sumo if ( reply->error() == QNetworkReply::NoError ) { - QJsonDocument doc = QJsonDocument::fromJson( result ); - QJsonObject jsonObj = doc.object(); + QJsonDocument doc = QJsonDocument::fromJson( result ); + QJsonArray jsonArray = doc.array(); - QJsonArray hits = jsonObj["aggregations"].toObject()["realization-ids"].toObject()["buckets"].toArray(); - for ( const auto& hit : hits ) + for ( const QJsonValue& value : jsonArray ) { - QJsonObject resultObj = hit.toObject(); - auto val = resultObj.value( "key" ); - auto intValue = val.toInt(); - + int intValue = value.toInt(); auto realizationId = QString::number( intValue ); m_realizationIds.push_back( realizationId ); } } + else + { + RiaLogging::error( std::format( "Request realization IDs failed: '{}'", reply->errorString() ) ); + } emit realizationIdsFinished(); } @@ -913,34 +731,45 @@ void RiaSumoConnector::parseRealizationNumbers( QNetworkReply* reply, const Sumo //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RiaSumoConnector::parseBlobIds( QNetworkReply* reply, - const SumoCaseId& caseId, - const QString& ensembleName, - const QString& vectorName, - bool isParameters ) +void RiaSumoConnector::parseBlobId( QNetworkReply* reply, + const SumoCaseId& caseId, + const QString& ensembleName, + const QString& vectorName, + bool isParameters ) { QByteArray result = reply->readAll(); reply->deleteLater(); - m_blobUrl.clear(); + m_blobId.clear(); if ( reply->error() == QNetworkReply::NoError ) { - QJsonDocument doc = QJsonDocument::fromJson( result ); - QJsonObject jsonObj = doc.object(); + // The REST API returns a plain string (the blob id) + QString blobId = QString::fromUtf8( result ).trimmed(); - QJsonObject rootHits = jsonObj["hits"].toObject(); - QJsonArray hitsObjects = rootHits["hits"].toArray(); - - for ( const QJsonValue& value : hitsObjects ) + // Remove quotes if present (FastAPI returns strings with quotes) + if ( blobId.startsWith( '"' ) && blobId.endsWith( '"' ) ) { - QJsonObject resultObj = value.toObject(); - QJsonObject sourceObj = resultObj["_source"].toObject(); - QJsonObject fmuObj = sourceObj["_sumo"].toObject(); - - auto blobName = fmuObj["blob_name"].toString(); - m_blobUrl.push_back( blobName ); + blobId = blobId.mid( 1, blobId.length() - 2 ); } + + m_blobId.push_back( blobId ); + + // Context-aware logging + if ( isParameters ) + { + RiaLogging::debug( std::format( "Received blob ID for parameters: {}", blobId.toStdString() ) ); + } + else + { + RiaLogging::debug( std::format( "Received blob ID for vector '{}': {}", vectorName.toStdString(), blobId.toStdString() ) ); + } + } + else + { + // Context-aware error logging + QString errorContext = isParameters ? "parameters" : QString( "vector '%1'" ).arg( vectorName ); + RiaLogging::error( std::format( "Request blob ID failed for {}: {}", errorContext.toStdString(), reply->errorString().toStdString() ) ); } emit blobIdFinished(); @@ -974,7 +803,7 @@ QNetworkReply* RiaSumoConnector::makeDownloadRequest( const QString& url, const //-------------------------------------------------------------------------------------------------- void RiaSumoConnector::requestParquetData( const QString& url, const QString& token ) { - RiaLogging::info( "Requesting download of parquet from: " + url.toStdString() ); + RiaLogging::debug( "Requesting download of parquet from: " + url.toStdString() ); auto reply = makeDownloadRequest( url, token, RiaCloudDefines::contentTypeJson() ); connect( reply, @@ -984,7 +813,8 @@ void RiaSumoConnector::requestParquetData( const QString& url, const QString& to if ( reply->error() == QNetworkReply::NoError ) { QByteArray contents = reply->readAll(); - RiaLogging::info( std::format( "Download succeeded: {} bytes.", contents.length() ) ); + RiaLogging::debug( std::format( "Download succeeded: {} bytes.", contents.length() ) ); + RiaLogging::debug( std::format( "Download succeeded for url: {}", url.toStdString() ) ); emit parquetDownloadFinished( contents, "" ); } else @@ -1047,9 +877,9 @@ std::vector RiaSumoConnector::realizationIds() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -std::vector RiaSumoConnector::blobUrls() const +std::vector RiaSumoConnector::blobIds() const { - return m_blobUrl; + return m_blobId; } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.h b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.h index db9ce25b84..909f6d0406 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.h +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoConnector.h @@ -25,6 +25,7 @@ #include #include +#include #include class QEventLoop; @@ -70,14 +71,19 @@ class RiaSumoConnector : public RiaCloudConnector { Q_OBJECT public: - RiaSumoConnector( QObject* parent, - const QString& server, - const QString& authority, - const QString& scopes, - const QString& clientId, - unsigned int port ); + // The Sumo data is served by a server whose address is not known when the connector is constructed, + // and which can change while the connector is alive. It is therefore supplied as a callable that is + // invoked for every request, rather than as a fixed address. + RiaSumoConnector( QObject* parent, + std::function serverUrlProvider, + const QString& authority, + const QString& scopes, + const QString& clientId, + unsigned int port ); ~RiaSumoConnector() override; + QString server() const override; + void requestAssets(); void requestAssetsBlocking(); @@ -101,7 +107,7 @@ public: void requestBlobIdForEnsembleBlocking( const SumoCaseId& caseId, const QString& ensembleName, const QString& vectorName ); void requestBlobDownload( const QString& blobId ); - void requestBlobByRedirectUri( const QString& blobId, const QString& redirectUri ); + void requestBlobBySasUri( const QString& blobId, const QString& sasUri ); QByteArray requestParquetDataBlocking( const SumoCaseId& caseId, const QString& ensembleName, const QString& vectorName ); @@ -110,7 +116,7 @@ public: std::vector ensembleNamesForCase( const SumoCaseId& caseId ) const; std::vector vectorNames() const; std::vector realizationIds() const; - std::vector blobUrls() const; + std::vector blobIds() const; std::vector blobContents() const; public slots: @@ -119,7 +125,7 @@ public slots: void parseCases( QNetworkReply* reply ); void parseVectorNames( QNetworkReply* reply, const SumoCaseId& caseId, const QString& ensembleName ); void parseRealizationNumbers( QNetworkReply* reply, const SumoCaseId& caseId, const QString& ensembleName ); - void parseBlobIds( QNetworkReply* reply, const SumoCaseId& caseId, const QString& ensembleName, const QString& vectorName, bool isParameters ); + void parseBlobId( QNetworkReply* reply, const SumoCaseId& caseId, const QString& ensembleName, const QString& vectorName, bool isParameters ); void requestFailed( const QAbstractOAuth::Error error ); void parquetDownloadComplete( const QString& blobId, const QByteArray&, const QString& url ); @@ -140,23 +146,23 @@ signals: private: void addStandardHeader( QNetworkRequest& networkRequest, const QString& token, const QString& contentType ); - QNetworkReply* makeRequest( const std::map& parameters, const QString& server, const QString& token ); QNetworkReply* makeDownloadRequest( const QString& url, const QString& token, const QString& contentType ); void requestParquetData( const QString& url, const QString& token ); - static QString constructSearchUrl( const QString& server ); - static QString constructDownloadUrl( const QString& server, const QString& blobId ); + static QString constructSasUri( const QString& blobStoreBaseUri, const QString& blobId, const QString& sasToken ); void wrapAndCallNetworkRequest( std::function requestCallable, const QMetaMethod& signalMethod ); private: + std::function m_serverUrlProvider; + std::vector m_assets; std::vector m_cases; std::vector m_vectorNames; std::vector m_realizationIds; std::vector m_ensembleNames; - std::vector m_blobUrl; + std::vector m_blobId; std::vector m_redirectInfo; }; diff --git a/ApplicationLibCode/Commands/ApplicationCommands/RicSumoDataFeature.cpp b/ApplicationLibCode/Commands/ApplicationCommands/RicSumoDataFeature.cpp index 5cf5931657..e7777e37b6 100644 --- a/ApplicationLibCode/Commands/ApplicationCommands/RicSumoDataFeature.cpp +++ b/ApplicationLibCode/Commands/ApplicationCommands/RicSumoDataFeature.cpp @@ -181,14 +181,14 @@ void SimpleDialog::onParquetClicked() { if ( !isTokenValid() ) return; - if ( m_sumoConnector->blobUrls().empty() ) + if ( m_sumoConnector->blobIds().empty() ) { onFindBlobIdClicked(); } - if ( !m_sumoConnector->blobUrls().empty() ) + if ( !m_sumoConnector->blobIds().empty() ) { - m_sumoConnector->requestBlobDownload( m_sumoConnector->blobUrls().back() ); + m_sumoConnector->requestBlobDownload( m_sumoConnector->blobIds().back() ); label->setText( "Requesting blob ID for vector name (see log for response" ); } diff --git a/ApplicationLibCode/Commands/Sumo/RicCreateSumoEnsembleFeature.cpp b/ApplicationLibCode/Commands/Sumo/RicCreateSumoEnsembleFeature.cpp index 84559f09a4..4474b9c11a 100644 --- a/ApplicationLibCode/Commands/Sumo/RicCreateSumoEnsembleFeature.cpp +++ b/ApplicationLibCode/Commands/Sumo/RicCreateSumoEnsembleFeature.cpp @@ -21,7 +21,7 @@ #include "RiaDefines.h" #include "Cloud/RimCloudDataSourceCollection.h" -#include "Sumo/RimSummarySumoDataSource.h" +#include "Sumo/RimSumoDataSource.h" #include "cafSelectionManagerTools.h" @@ -34,7 +34,7 @@ CAF_CMD_SOURCE_INIT( RicCreateSumoEnsembleFeature, "RicCreateSumoEnsembleFeature //-------------------------------------------------------------------------------------------------- void RicCreateSumoEnsembleFeature::onActionTriggered( bool isChecked ) { - auto dataSources = caf::selectedObjectsByType(); + auto dataSources = caf::selectedObjectsByType(); RimCloudDataSourceCollection::createEnsemblesFromSelectedDataSources( dataSources ); } diff --git a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp index 4705fcbd7d..ef9988186f 100644 --- a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp +++ b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp @@ -27,13 +27,18 @@ #include "RimProject.h" #include "RimSummaryCaseMainCollection.h" #include "Sumo/RimSummaryEnsembleSumo.h" -#include "Sumo/RimSummarySumoDataSource.h" +#include "Sumo/RimSumoDataSource.h" #include "RiuPlotMainWindowTools.h" +#include "cafPdmUiPropertyViewDialog.h" #include "cafPdmUiPushButtonEditor.h" #include "cafPdmUiTreeSelectionEditor.h" +#include + +#include + CAF_PDM_SOURCE_INIT( RimCloudDataSourceCollection, "RimCloudDataSourceCollection" ); //-------------------------------------------------------------------------------------------------- /// @@ -83,7 +88,7 @@ RimCloudDataSourceCollection* RimCloudDataSourceCollection::instance() //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -std::vector RimCloudDataSourceCollection::sumoDataSources() const +std::vector RimCloudDataSourceCollection::sumoDataSources() const { return m_sumoDataSources.childrenByType(); } @@ -91,7 +96,7 @@ std::vector RimCloudDataSourceCollection::sumoDataSou //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RimCloudDataSourceCollection::createEnsemblesFromSelectedDataSources( const std::vector& dataSources ) +void RimCloudDataSourceCollection::createEnsemblesFromSelectedDataSources( const std::vector& dataSources ) { auto sumCaseMainColl = RiaSummaryTools::summaryCaseMainCollection(); if ( !sumCaseMainColl ) return; @@ -116,10 +121,17 @@ void RimCloudDataSourceCollection::createEnsemblesFromSelectedDataSources( const //-------------------------------------------------------------------------------------------------- void RimCloudDataSourceCollection::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) { - // The Cloud API server controls are independent of the Sumo connection, so handle them before the - // Sumo connector guard below. This allows force-starting the server even when authentication failed. + // The Cloud API server controls are handled before the Sumo connector guard below, so the server can + // be started, stopped and restarted also when no connector is present or authentication failed. if ( changedField == &m_startServer ) { + // Authenticate up front, as the server is of little use without a token. The server is started + // regardless of the outcome, so it can still be force-started if authentication is cancelled. + if ( m_sumoConnector && !m_sumoConnector->isGranted() ) + { + m_sumoConnector->requestTokenWithCancelButton(); + } + RiaApplication::instance()->cloudApiService()->start(); m_startServer = false; @@ -147,6 +159,7 @@ void RimCloudDataSourceCollection::fieldChangedByUi( const caf::PdmFieldHandle* if ( changedField == &m_authenticate ) { + // Authentication only, the server is started from its own button. m_sumoConnector->requestTokenWithCancelButton(); m_authenticate = false; @@ -177,6 +190,16 @@ void RimCloudDataSourceCollection::fieldChangedByUi( const caf::PdmFieldHandle* } } +//-------------------------------------------------------------------------------------------------- +/// True once the local service is serving requests. Being launched is not enough, as uvicorn may still +/// be booting. +//-------------------------------------------------------------------------------------------------- +bool RimCloudDataSourceCollection::isCloudApiServerAvailable() +{ + auto* cloudApiService = RiaApplication::instance()->cloudApiService(); + return cloudApiService && cloudApiService->isResponding(); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -184,6 +207,10 @@ QList RimCloudDataSourceCollection::calculateValueOption { if ( !m_sumoConnector || !m_sumoConnector->isGranted() ) return {}; + // The Sumo data is read through the local service, so requesting anything before it is up produces + // failing requests against an empty server address. + if ( !isCloudApiServerAvailable() ) return {}; + QList options; if ( fieldNeedingOptions == &m_sumoFieldName ) { @@ -240,7 +267,7 @@ void RimCloudDataSourceCollection::defineUiOrdering( QString uiConfigName, caf:: authGroup->add( &m_authenticate ); bool isGranted = m_sumoConnector && m_sumoConnector->isGranted(); - QString text = "Authentication Status: "; + QString text = " Authentication Status: "; text += isGranted ? "✔ Granted" : "❌ Not Granted"; m_authenticate.uiCapability()->setUiName( text ); @@ -279,8 +306,8 @@ void RimCloudDataSourceCollection::defineUiOrdering( QString uiConfigName, caf:: serverGroup->add( &m_stopServer ); serverGroup->add( &m_restartServer ); - // Cloud selector - if ( isGranted ) + // Cloud selector. Both a token and a running service are required to browse the Sumo content. + if ( isGranted && isCloudApiServerAvailable() ) { caf::PdmUiOrdering::LayoutOptions layout = { .newRow = true, .totalColumnSpan = 3, .leftLabelColumnSpan = 1 }; uiOrdering.add( &m_sumoFieldName, layout ); @@ -348,14 +375,14 @@ void RimCloudDataSourceCollection::defineEditorAttribute( const caf::PdmFieldHan //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -std::vector RimCloudDataSourceCollection::addDataSources() +std::vector RimCloudDataSourceCollection::addDataSources() { if ( !m_sumoConnector ) return {}; - std::vector dataSources; + std::vector dataSources; - RimSummarySumoDataSource* objectToSelect = nullptr; - auto sumoCaseId = SumoCaseId( m_sumoCaseId ); + RimSumoDataSource* objectToSelect = nullptr; + auto sumoCaseId = SumoCaseId( m_sumoCaseId ); for ( const auto& ensembleName : m_sumoEnsembleNames() ) { @@ -387,14 +414,15 @@ std::vector RimCloudDataSourceCollection::addDataSour m_sumoConnector->requestRealizationIdsForEnsembleBlocking( sumoCaseId, ensembleName ); m_sumoConnector->requestVectorNamesForEnsembleBlocking( sumoCaseId, ensembleName ); - auto realizationIds = m_sumoConnector->realizationIds(); - auto vectorNames = m_sumoConnector->vectorNames(); + auto availableRealizationIds = m_sumoConnector->realizationIds(); + auto vectorNames = m_sumoConnector->vectorNames(); - auto dataSource = new RimSummarySumoDataSource(); + auto dataSource = new RimSumoDataSource(); dataSource->setCaseId( sumoCaseId ); + dataSource->setAssetName( m_sumoFieldName ); dataSource->setCaseName( caseName ); dataSource->setEnsembleName( ensembleName ); - dataSource->setRealizationIds( realizationIds ); + dataSource->setAvailableRealizationIds( availableRealizationIds ); dataSource->setVectorNames( vectorNames ); dataSource->updateName(); diff --git a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h index bbb1090d36..917848236b 100644 --- a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h +++ b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h @@ -27,7 +27,7 @@ #include #include -class RimSummarySumoDataSource; +class RimSumoDataSource; //================================================================================================== /// @@ -42,9 +42,9 @@ public: static RimCloudDataSourceCollection* instance(); - std::vector sumoDataSources() const; + std::vector sumoDataSources() const; - static void createEnsemblesFromSelectedDataSources( const std::vector& dataSources ); + static void createEnsemblesFromSelectedDataSources( const std::vector& dataSources ); private: void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; @@ -53,8 +53,10 @@ private: void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; void defineEditorAttribute( const caf::PdmFieldHandle* field, QString uiConfigName, caf::PdmUiEditorAttribute* attribute ) override; - std::vector addDataSources(); - void addEnsembles(); + std::vector addDataSources(); + void addEnsembles(); + + static bool isCloudApiServerAvailable(); private: caf::PdmField m_authenticate; @@ -62,9 +64,9 @@ private: caf::PdmField m_sumoCaseId; caf::PdmField> m_sumoEnsembleNames; - caf::PdmField m_addDataSources; - caf::PdmField m_addEnsembles; - caf::PdmChildArrayField m_sumoDataSources; + caf::PdmField m_addDataSources; + caf::PdmField m_addEnsembles; + caf::PdmChildArrayField m_sumoDataSources; caf::PdmField m_startServer; caf::PdmField m_stopServer; diff --git a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/CMakeLists_files.cmake index d5f3d3d1fa..fb0f4e254e 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/CMakeLists_files.cmake @@ -1,7 +1,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimSummaryEnsembleSumo.cpp ${CMAKE_CURRENT_LIST_DIR}/RimSummaryCaseSumo.cpp - ${CMAKE_CURRENT_LIST_DIR}/RimSummarySumoDataSource.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimSumoDataSource.cpp ) list(APPEND CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES}) diff --git a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.cpp b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.cpp index ee9f9eb96e..0fb675c8fe 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.cpp +++ b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.cpp @@ -30,13 +30,53 @@ #include "RifEclipseSummaryAddress.h" #include "Cloud/RimCloudDataSourceCollection.h" +#include "RimSummaryCaseMainCollection.h" #include "RimSummaryCaseSumo.h" -#include "RimSummarySumoDataSource.h" +#include "RimSumoDataSource.h" #include +#include +#include + CAF_PDM_SOURCE_INIT( RimSummaryEnsembleSumo, "RimSummaryEnsembleSumo" ); +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// Read an integer column of any width as int64. The bit width of a column is decided by the producer +/// of the parquet file, and can not be assumed to be a specific type. +//-------------------------------------------------------------------------------------------------- +std::optional> readIntegerColumn( const std::shared_ptr& column ) +{ + if ( !column ) return {}; + + const auto typeId = column->type()->id(); + + if ( typeId == arrow::Type::INT8 ) return RifArrowTools::chunkedArrayToVector( column ); + if ( typeId == arrow::Type::INT16 ) return RifArrowTools::chunkedArrayToVector( column ); + if ( typeId == arrow::Type::INT32 ) return RifArrowTools::chunkedArrayToVector( column ); + if ( typeId == arrow::Type::INT64 ) return RifArrowTools::chunkedArrayToVector( column ); + + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Read a floating point column of any precision as double +//-------------------------------------------------------------------------------------------------- +std::optional> readFloatingPointColumn( const std::shared_ptr& column ) +{ + if ( !column ) return {}; + + const auto typeId = column->type()->id(); + + if ( typeId == arrow::Type::FLOAT ) return RifArrowTools::chunkedArrayToVector( column ); + if ( typeId == arrow::Type::DOUBLE ) return RifArrowTools::chunkedArrayToVector( column ); + + return {}; +} +} // namespace + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -58,7 +98,7 @@ RimSummaryEnsembleSumo::RimSummaryEnsembleSumo() //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RimSummaryEnsembleSumo::setSumoDataSource( RimSummarySumoDataSource* sumoDataSource ) +void RimSummaryEnsembleSumo::setSumoDataSource( RimSumoDataSource* sumoDataSource ) { m_sumoDataSource = sumoDataSource; } @@ -102,6 +142,39 @@ std::pair RimSummaryEnsembleSumo::nameKeys() const return { "Sumo Data Source", "" }; } +//-------------------------------------------------------------------------------------------------- +/// Name the ensemble after the data source, like RimSummaryFileSetEnsemble does with its file set. A +/// lone ensemble needs no disambiguation and uses the short name ("iter-0"), otherwise the data source +/// name, which also carries the case name ("iter-0 (drogon_ahm)"). +/// +/// The base class KEY1/KEY2 template can not be used: KEY1 is the data source name and KEY2 is unused, +/// so a single Sumo ensemble falls back to the empty KEY2. +//-------------------------------------------------------------------------------------------------- +void RimSummaryEnsembleSumo::updateName( const std::set& existingEnsembleNames ) +{ + // A user defined name template is still resolved by the base class. + if ( !isAutoNameChecked() ) + { + RimSummaryEnsemble::updateName( existingEnsembleNames ); + return; + } + + QString candidateName = "Sumo Data Source"; + + if ( m_sumoDataSource() ) + { + auto mainCollection = firstAncestorOrThisOfType(); + bool isOnlyEnsemble = mainCollection && mainCollection->summaryEnsembles().size() == 1; + + candidateName = isOnlyEnsemble ? m_sumoDataSource()->ensembleName() : m_sumoDataSource()->name(); + } + + if ( m_name == candidateName ) return; + + m_name = candidateName; + caseNameChanged.send(); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -109,6 +182,11 @@ void RimSummaryEnsembleSumo::loadSummaryData( const RifEclipseSummaryAddress& re { if ( resultAddress.isStatistics() ) return; + // An address without a vector name has no blob to fetch. The special time address used as curve + // x-axis is one such address. Requesting it would produce a URL with an empty vector segment, which + // the service answers with 404, surfacing as a misleading "parquet file size is 0 bytes" error. + if ( resultAddress.vectorName().empty() ) return; + if ( !m_sumoDataSource() ) return; auto resultText = QString::fromStdString( resultAddress.toEclipseTextAddress() ); @@ -354,11 +432,10 @@ void RimSummaryEnsembleSumo::distributeParametersDataToRealizations( std::shared std::vector realizations; { - const std::string columnName = "REAL"; - std::shared_ptr column = table->GetColumnByName( columnName ); - if ( column && column->type()->id() == arrow::Type::INT64 ) + const std::string columnName = "REAL"; + if ( auto values = readIntegerColumn( table->GetColumnByName( columnName ) ) ) { - realizations = RifArrowTools::chunkedArrayToVector( column ); + realizations = *values; } else { @@ -378,20 +455,17 @@ void RimSummaryEnsembleSumo::distributeParametersDataToRealizations( std::shared if ( column ) { - if ( column->type()->id() == arrow::Type::DOUBLE ) + if ( auto values = readFloatingPointColumn( column ) ) { - std::vector values = RifArrowTools::chunkedArrayToVector( column ); - doubleValuesForRealizations[columnName] = values; + doubleValuesForRealizations[columnName] = *values; } - else if ( column->type()->id() == arrow::Type::INT64 ) + else if ( auto values = readIntegerColumn( column ) ) { - std::vector values = RifArrowTools::chunkedArrayToVector( column ); - intValuesForRealizations[columnName] = values; + intValuesForRealizations[columnName] = *values; } else if ( column->type()->id() == arrow::Type::STRING ) { - std::vector values = RifArrowTools::chunkedArrayToStringVector( column ); - stringValuesForRealizations[columnName] = values; + stringValuesForRealizations[columnName] = RifArrowTools::chunkedArrayToStringVector( column ); } } else @@ -402,14 +476,15 @@ void RimSummaryEnsembleSumo::distributeParametersDataToRealizations( std::shared } } - // find unique realizations - std::set uniqueRealizations; - for ( auto realizationNumber : realizations ) + // find the row index in the table for each unique realization. The realization number can not be used as row index, as + // the realization numbers are not guaranteed to be continuous and starting at zero. + std::map rowIndexForRealization; + for ( size_t i = 0; i < realizations.size(); ++i ) { - uniqueRealizations.insert( realizationNumber ); + rowIndexForRealization.try_emplace( static_cast( realizations[i] ), i ); } - auto findSummaryCase = [this]( int16_t realizationNumber ) -> RimSummaryCaseSumo* + auto findSummaryCase = [this]( int32_t realizationNumber ) -> RimSummaryCaseSumo* { for ( auto sumCase : allSummaryCases() ) { @@ -420,7 +495,7 @@ void RimSummaryEnsembleSumo::distributeParametersDataToRealizations( std::shared return nullptr; }; - for ( auto realizationNumber : uniqueRealizations ) + for ( const auto& [realizationNumber, rowIndex] : rowIndexForRealization ) { if ( auto summaryCase = findSummaryCase( realizationNumber ) ) { @@ -434,17 +509,23 @@ void RimSummaryEnsembleSumo::distributeParametersDataToRealizations( std::shared { if ( auto it = doubleValuesForRealizations.find( columnName ); it != doubleValuesForRealizations.end() ) { - double value = it->second[realizationNumber]; + if ( rowIndex >= it->second.size() ) continue; + + double value = it->second[rowIndex]; parameters->addParameter( QString::fromStdString( columnName ), value ); } else if ( auto it = intValuesForRealizations.find( columnName ); it != intValuesForRealizations.end() ) { - double value = it->second[realizationNumber]; + if ( rowIndex >= it->second.size() ) continue; + + double value = static_cast( it->second[rowIndex] ); parameters->addParameter( QString::fromStdString( columnName ), value ); } else if ( auto it = stringValuesForRealizations.find( columnName ); it != stringValuesForRealizations.end() ) { - QString value = QString::fromStdString( it->second[realizationNumber] ); + if ( rowIndex >= it->second.size() ) continue; + + QString value = QString::fromStdString( it->second[rowIndex] ); parameters->addParameter( QString::fromStdString( columnName ), value ); } } @@ -455,6 +536,29 @@ void RimSummaryEnsembleSumo::distributeParametersDataToRealizations( std::shared } } +//-------------------------------------------------------------------------------------------------- +/// Re-distribute already cached parquet tables to the current set of realization cases. Used after +/// the realization cases have been rebuilt (e.g. when the realization filter changes), as the cached +/// tables hold the data for all realizations and a re-download is not needed. +//-------------------------------------------------------------------------------------------------- +void RimSummaryEnsembleSumo::redistributeCachedDataToRealizations() +{ + for ( const auto& [key, table] : m_parquetTable ) + { + if ( !table ) continue; + + if ( key.isSensitivityParameters ) + { + distributeParametersDataToRealizations( table ); + } + else + { + auto resultAddress = RifEclipseSummaryAddress::fromEclipseTextAddress( key.vectorName.toStdString() ); + distributeDataToRealizations( resultAddress, table ); + } + } +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -559,31 +663,123 @@ void RimSummaryEnsembleSumo::onLoadDataAndUpdate() { if ( m_sumoDataSource() ) { - auto realizationIds = m_sumoDataSource->realizationIds(); - if ( realizationIds.size() != m_cases.size() ) + std::set selectedRealizations; + for ( const auto& realizationId : m_sumoDataSource->selectedRealizationIds() ) { - m_cases.deleteChildren(); + bool ok = false; + int value = realizationId.toInt( &ok ); + if ( ok ) selectedRealizations.insert( value ); + } - for ( auto realId : realizationIds ) + std::set currentRealizations; + for ( auto summaryCase : allSummaryCases() ) + { + if ( auto sumoCase = dynamic_cast( summaryCase ) ) { - auto realization = new RimSummaryCaseSumo(); - realization->setEnsemble( this ); - realization->setRealizationName( QString( "real-%1" ).arg( realId ) ); - realization->setRealizationNumber( realId.toInt() ); - realization->updateAutoShortName(); - - realization->setShowTreeNodes( m_cases.empty() ); - - m_cases.push_back( realization ); + currentRealizations.insert( sumoCase->realizationNumber() ); } } + + // Update the realization cases whenever the selected set changes (added, removed or swapped), + // so editing the realizations on the data source updates the summary plot. + if ( selectedRealizations != currentRealizations ) + { + // Update incrementally: a case whose realization is still selected is kept alive and reused. + // Widening the selection therefore deletes nothing, which keeps the curves referring to those + // cases valid and avoids a large tree structure change while the realization filter is being + // edited. Only the deselected realizations are deleted. + std::map reusableCases; + std::vector obsoleteCases; + for ( auto summaryCase : m_cases.childrenByType() ) + { + auto sumoCase = dynamic_cast( summaryCase ); + if ( sumoCase && selectedRealizations.contains( sumoCase->realizationNumber() ) ) + { + reusableCases[sumoCase->realizationNumber()] = sumoCase; + } + else + { + obsoleteCases.push_back( summaryCase ); + } + } + + // Detach everything without deleting, then re-attach in realization order. The reused cases + // survive this, so only the obsolete ones are destroyed. + m_cases.clearWithoutDelete(); + + for ( int realization : selectedRealizations ) + { + auto it = reusableCases.find( realization ); + if ( it != reusableCases.end() ) + { + m_cases.push_back( it->second ); + continue; + } + + auto realizationCase = new RimSummaryCaseSumo(); + realizationCase->setEnsemble( this ); + realizationCase->setRealizationName( QString( "real-%1" ).arg( realization ) ); + realizationCase->setRealizationNumber( realization ); + realizationCase->updateAutoShortName(); + + m_cases.push_back( realizationCase ); + } + + // Only the first case shows its tree nodes, also after cases have been reused. + bool isFirstCase = true; + for ( auto summaryCase : m_cases.childrenByType() ) + { + if ( auto sumoCase = dynamic_cast( summaryCase ) ) + { + sumoCase->setShowTreeNodes( isFirstCase ); + isFirstCase = false; + } + } + + // Refresh the connected editors while the obsolete cases are still alive, so no tree item + // editor ends up referencing freed objects during the update. Deleting them first caused a + // use-after-free crash. caf::CmdDeleteItemExec and RicDeleteSummaryCaseCollectionFeature use + // this same ordering. + m_cases.uiCapability()->updateConnectedEditors(); + + for ( auto obsoleteCase : obsoleteCases ) + { + delete obsoleteCase; + } + + // Newly created cases hold no values, while the parquet tables are still cached. Push the + // cached data into the cases, otherwise loadSummaryData() would skip the distribution (the + // cache lookup hits) and the new cases would have no values, making the plot empty. + redistributeCachedDataToRealizations(); + } } RiaSummaryTools::updateSummaryEnsembleNames(); updateResultAddresses(); - buildMetaData(); - - // call the base class method after data has been loaded + // The base class clears the child (address) nodes. Build the metadata afterwards so the available + // vectors remain present in the tree after the cases have been rebuilt (e.g. when editing the + // realization selection). RimSummaryEnsemble::onLoadDataAndUpdate(); + + buildMetaData(); +} + +//-------------------------------------------------------------------------------------------------- +/// Rebuild the realization cases from the data source selection and refresh dependent plots. This +/// mirrors RimSummaryFileSetEnsemble::onFileSetChanged, which updates the realization cases (the +/// RimSummaryCase collection) and the connected plots when the underlying file set changes. +//-------------------------------------------------------------------------------------------------- +void RimSummaryEnsembleSumo::onRealizationSelectionChanged() +{ + // Rebuild the realization cases (done in onLoadDataAndUpdate) and refresh the connected plots, + // mirroring RimSummaryFileSetEnsemble::onFileSetChanged. + // + // Note: we intentionally do not use addCase()/replaceCases() here. Those validate ensemble + // parameters, which Sumo realizations do not carry, and would raise a "case has no ensemble + // parameters" warning dialog. + loadDataAndUpdate(); + + RiaSummaryTools::updateConnectedPlots( this ); + updateAllRequiredEditors(); } diff --git a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.h b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.h index f1f230b40a..c3b07b09dc 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.h @@ -26,7 +26,7 @@ #include -class RimSummarySumoDataSource; +class RimSumoDataSource; //================================================================================================== // @@ -60,7 +60,9 @@ class RimSummaryEnsembleSumo : public RimSummaryEnsemble public: RimSummaryEnsembleSumo(); - void setSumoDataSource( RimSummarySumoDataSource* sumoDataSource ); + void setSumoDataSource( RimSumoDataSource* sumoDataSource ); + + void onRealizationSelectionChanged(); void loadSummaryData( const RifEclipseSummaryAddress& resultAddress ); std::string unitName( const RifEclipseSummaryAddress& resultAddress ); @@ -68,6 +70,7 @@ public: std::set allResultAddresses() const; std::pair nameKeys() const override; + void updateName( const std::set& existingEnsembleNames ) override; protected: void onLoadDataAndUpdate() override; @@ -86,11 +89,12 @@ private: void buildMetaData(); void distributeParametersDataToRealizations( std::shared_ptr table ); + void redistributeCachedDataToRealizations(); static std::shared_ptr readParquetTable( const QByteArray& contents, const QString& messageTag ); private: - caf::PdmPtrField m_sumoDataSource; + caf::PdmPtrField m_sumoDataSource; QPointer m_sumoConnector; diff --git a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.cpp b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.cpp deleted file mode 100644 index b2e2dc768a..0000000000 --- a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.cpp +++ /dev/null @@ -1,228 +0,0 @@ -///////////////////////////////////////////////////////////////////////////////// -// -// Copyright (C) 2024 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 "RimSummarySumoDataSource.h" - -#include "RiaStdStringTools.h" - -#include "cafCmdFeatureMenuBuilder.h" -#include "cafPdmUiTreeSelectionEditor.h" - -CAF_PDM_SOURCE_INIT( RimSummarySumoDataSource, "RimSummarySumoDataSource" ); - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -RimSummarySumoDataSource::RimSummarySumoDataSource() -{ - CAF_PDM_InitObject( "Sumo Data Source", ":/CloudBlobs.svg" ); - - CAF_PDM_InitFieldNoDefault( &m_caseId, "CaseId", "Case Id" ); - CAF_PDM_InitFieldNoDefault( &m_caseName, "CaseName", "Case Name" ); - CAF_PDM_InitFieldNoDefault( &m_ensembleName, "EnsembleName", "Ensemble Name" ); - CAF_PDM_InitFieldNoDefault( &m_customName, "CustomName", "Custom Name" ); - - CAF_PDM_InitFieldNoDefault( &m_realizationIds, "RealizationIds", "Realizations Ids" ); - m_realizationIds.uiCapability()->setUiHidden( true ); - - CAF_PDM_InitFieldNoDefault( &m_realizationInfo, "NameProxy", "Realization Info" ); - m_realizationInfo.registerGetMethod( this, &RimSummarySumoDataSource::realizationInfoText ); - - CAF_PDM_InitFieldNoDefault( &m_vectorNames, "VectorNames", "Vector Names" ); - m_vectorNames.uiCapability()->setUiReadOnly( true ); - - setDeletable( true ); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -SumoCaseId RimSummarySumoDataSource::caseId() const -{ - return SumoCaseId( m_caseId() ); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::setCaseId( const SumoCaseId& caseId ) -{ - m_caseId = caseId.get(); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString RimSummarySumoDataSource::caseName() const -{ - return m_caseName(); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::setCaseName( const QString& caseName ) -{ - m_caseName = caseName; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString RimSummarySumoDataSource::ensembleName() const -{ - return m_ensembleName(); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::setEnsembleName( const QString& ensembleName ) -{ - m_ensembleName = ensembleName; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -std::vector RimSummarySumoDataSource::realizationIds() const -{ - return m_realizationIds(); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::setRealizationIds( const std::vector& realizationIds ) -{ - m_realizationIds = realizationIds; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -std::vector RimSummarySumoDataSource::vectorNames() const -{ - return m_vectorNames(); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::setVectorNames( const std::vector& vectorNames ) -{ - m_vectorNames = vectorNames; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::updateName() -{ - if ( !m_customName().isEmpty() ) - { - setName( m_customName() ); - return; - } - - auto name = QString( "%1 (%2)" ).arg( ensembleName(), caseName() ); - - setName( name ); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const -{ - menuBuilder.addCmdFeature( "RicCreateSumoEnsembleFeature" ); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::defineEditorAttribute( const caf::PdmFieldHandle* field, QString uiConfigName, caf::PdmUiEditorAttribute* attribute ) -{ - if ( field == &m_vectorNames ) - { - if ( auto attr = dynamic_cast( attribute ) ) - { - attr->showCheckBoxes = false; - attr->showContextMenu = false; - attr->showToggleAllCheckbox = false; - } - } -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) -{ - auto group = uiOrdering.addNewGroup( "General" ); - - group->add( nameField() ); - nameField()->uiCapability()->setUiReadOnly( true ); - - group->add( &m_caseId ); - m_caseId.uiCapability()->setUiReadOnly( true ); - - group->add( &m_caseName ); - m_caseName.uiCapability()->setUiReadOnly( true ); - - group->add( &m_ensembleName ); - m_ensembleName.uiCapability()->setUiReadOnly( true ); - - group->add( &m_customName ); - - auto summaryInfo = uiOrdering.addNewGroup( "Info" ); - summaryInfo->setCollapsedByDefault(); - summaryInfo->add( &m_realizationInfo ); - summaryInfo->add( &m_vectorNames ); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -void RimSummarySumoDataSource::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) -{ - if ( changedField == &m_customName ) - { - updateName(); - } -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString RimSummarySumoDataSource::realizationInfoText() const -{ - std::vector intValues; - for ( const auto& realizationId : realizationIds() ) - { - bool ok = false; - int value = realizationId.toInt( &ok ); - if ( ok ) - { - intValues.push_back( value ); - } - } - - auto rangeString = RiaStdStringTools::formatRangeSelection( intValues ); - return QString::fromStdString( rangeString ); -} diff --git a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.cpp b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.cpp new file mode 100644 index 0000000000..cb9be041e0 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.cpp @@ -0,0 +1,327 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2024 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 "RimSumoDataSource.h" + +#include "RiaStdStringTools.h" + +#include "RimSummaryEnsembleSumo.h" + +#include "cafCmdFeatureMenuBuilder.h" +#include "cafPdmPointer.h" +#include "cafPdmUiLineEditor.h" +#include "cafPdmUiTextEditor.h" + +#include + +CAF_PDM_SOURCE_INIT( RimSumoDataSource, "RimSumoDataSource", "RimSummarySumoDataSource" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimSumoDataSource::RimSumoDataSource() +{ + CAF_PDM_InitObject( "Sumo Data Source", ":/CloudBlobs.svg" ); + + CAF_PDM_InitFieldNoDefault( &m_caseId, "CaseId", "Case Id" ); + CAF_PDM_InitFieldNoDefault( &m_assetName, "AssetName", "Asset Name" ); + CAF_PDM_InitFieldNoDefault( &m_caseName, "CaseName", "Case Name" ); + CAF_PDM_InitFieldNoDefault( &m_ensembleName, "EnsembleName", "Ensemble Name" ); + CAF_PDM_InitFieldNoDefault( &m_customName, "CustomName", "Custom Name" ); + + CAF_PDM_InitFieldNoDefault( &m_availableRealizationIds, "RealizationIds", "Available Realization Ids" ); + m_availableRealizationIds.uiCapability()->setUiHidden( true ); + + CAF_PDM_InitField( &m_realizationFilter, + "RealizationFilter", + QString(), + "Realization Filter", + "", + "Specify realization numbers. Example: 1-5, 8, 11-20, !4 will include 1, 2, 3, 5, 8, 11-20" ); + + CAF_PDM_InitFieldNoDefault( &m_realizationFilterInfo, "RealizationFilterInfo", "Info" ); + m_realizationFilterInfo.registerGetMethod( this, &RimSumoDataSource::realizationFilterInfoText ); + m_realizationFilterInfo.uiCapability()->setUiReadOnly( true ); + m_realizationFilterInfo.uiCapability()->setUiEditorTypeName( caf::PdmUiTextEditor::uiEditorTypeName() ); + + CAF_PDM_InitFieldNoDefault( &m_vectorNames, "VectorNames", "Vector Names" ); + m_vectorNames.uiCapability()->setUiHidden( true ); + + setDeletable( true ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +SumoCaseId RimSumoDataSource::caseId() const +{ + return SumoCaseId( m_caseId() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::setCaseId( const SumoCaseId& caseId ) +{ + m_caseId = caseId.get(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimSumoDataSource::assetName() const +{ + return m_assetName(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::setAssetName( const QString& assetName ) +{ + m_assetName = assetName; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimSumoDataSource::caseName() const +{ + return m_caseName(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::setCaseName( const QString& caseName ) +{ + m_caseName = caseName; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimSumoDataSource::ensembleName() const +{ + return m_ensembleName(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::setEnsembleName( const QString& ensembleName ) +{ + m_ensembleName = ensembleName; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimSumoDataSource::availableRealizationIds() const +{ + return m_availableRealizationIds(); +} + +//-------------------------------------------------------------------------------------------------- +/// The available realizations are the source of truth. An empty filter selects all of them. +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::setAvailableRealizationIds( const std::vector& realizationIds ) +{ + m_availableRealizationIds = realizationIds; + + // Show the full range instead of an empty field. Only an empty filter is replaced, keeping a user + // or project defined filter. Selection is unchanged, as empty and full range both select all. + if ( m_realizationFilter().trimmed().isEmpty() ) + { + m_realizationFilter = availableRealizationsRangeText(); + } +} + +//-------------------------------------------------------------------------------------------------- +/// The subset of available realizations matching the realization filter. An empty filter (or '*') +/// selects all available realizations. +//-------------------------------------------------------------------------------------------------- +std::vector RimSumoDataSource::selectedRealizationIds() const +{ + const auto& available = m_availableRealizationIds(); + + auto filter = m_realizationFilter(); + if ( filter.trimmed().isEmpty() || filter.contains( '*' ) ) + { + return available; + } + + auto selectedValues = RiaStdStringTools::valuesFromRangeSelection( filter.toStdString() ); + + std::vector result; + for ( const auto& realizationId : available ) + { + bool ok = false; + int value = realizationId.toInt( &ok ); + if ( ok && selectedValues.count( value ) > 0 ) + { + result.push_back( realizationId ); + } + } + + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimSumoDataSource::vectorNames() const +{ + return m_vectorNames(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::setVectorNames( const std::vector& vectorNames ) +{ + m_vectorNames = vectorNames; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::updateName() +{ + if ( !m_customName().isEmpty() ) + { + setName( m_customName() ); + return; + } + + auto name = QString( "%1 (%2)" ).arg( ensembleName(), caseName() ); + + setName( name ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const +{ + menuBuilder.addCmdFeature( "RicCreateSumoEnsembleFeature" ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::defineEditorAttribute( const caf::PdmFieldHandle* field, QString uiConfigName, caf::PdmUiEditorAttribute* attribute ) +{ + if ( field == &m_realizationFilter ) + { + if ( auto lineEdAttr = dynamic_cast( attribute ) ) + { + lineEdAttr->placeholderText = "E.g. 0,1,4-10,!6. Use '*' for all."; + } + } + else if ( field == &m_realizationFilterInfo ) + { + if ( auto* myAttr = dynamic_cast( attribute ) ) + { + myAttr->heightHint = -1; + } + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) +{ + auto group = uiOrdering.addNewGroup( "General" ); + + group->add( nameField() ); + nameField()->uiCapability()->setUiReadOnly( true ); + + group->add( &m_caseId ); + m_caseId.uiCapability()->setUiReadOnly( true ); + + group->add( &m_assetName ); + m_assetName.uiCapability()->setUiReadOnly( true ); + + group->add( &m_caseName ); + m_caseName.uiCapability()->setUiReadOnly( true ); + + group->add( &m_ensembleName ); + m_ensembleName.uiCapability()->setUiReadOnly( true ); + + group->add( &m_customName ); + + auto ensembleGroup = uiOrdering.addNewGroup( "Ensemble Selection" ); + ensembleGroup->add( &m_realizationFilter ); + ensembleGroup->add( &m_realizationFilterInfo ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +{ + if ( changedField == &m_customName ) + { + updateName(); + } + else if ( changedField == &m_realizationFilter ) + { + onRealizationFilterChanged(); + } +} + +//-------------------------------------------------------------------------------------------------- +/// Propagate a change in the realization filter to the ensembles created from this data source, so +/// editing the filter updates the realization cases (and the connected plots/views). +//-------------------------------------------------------------------------------------------------- +void RimSumoDataSource::onRealizationFilterChanged() +{ + // Update any summary ensembles created from this data source (same behaviour as summary ensembles + // loaded from disk, see RimSummaryFileSetEnsemble::onFileSetChanged). + for ( auto ensemble : objectsWithReferringPtrFieldsOfType() ) + { + ensemble->onRealizationSelectionChanged(); + } +} + +//-------------------------------------------------------------------------------------------------- +/// Info text shown next to the realization filter. +//-------------------------------------------------------------------------------------------------- +QString RimSumoDataSource::realizationFilterInfoText() const +{ + return "Available realizations: " + availableRealizationsRangeText(); +} + +//-------------------------------------------------------------------------------------------------- +/// Compact range text for the available ensemble realizations, e.g. "0-99". +//-------------------------------------------------------------------------------------------------- +QString RimSumoDataSource::availableRealizationsRangeText() const +{ + std::vector intValues; + for ( const auto& realizationId : m_availableRealizationIds() ) + { + bool ok = false; + int value = realizationId.toInt( &ok ); + if ( ok ) intValues.push_back( value ); + } + + auto rangeString = RiaStdStringTools::formatRangeSelection( intValues ); + return QString::fromStdString( rangeString ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.h b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.h similarity index 57% rename from ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.h rename to ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.h index 992b460a2e..19cc8e848c 100644 --- a/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.h +++ b/ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSumoDataSource.h @@ -25,29 +25,43 @@ //================================================================================================== // -// +// Common data source describing a single Sumo ensemble. Holds the information required by summary +// ensembles (asset, vector names). The available ensemble realizations - fetched from the +// realizations endpoint - are the source of truth, and the user selects a subset of them ("ensemble +// selection"). Consumers listen to the selected realization id subset. All values are populated by +// RimCloudDataSourceCollection, which owns the RiaSumoConnector; this object does not talk to the +// connector directly. // //================================================================================================== -class RimSummarySumoDataSource : public RimNamedObject +class RimSumoDataSource : public RimNamedObject { CAF_PDM_HEADER_INIT; public: - RimSummarySumoDataSource(); + RimSumoDataSource(); SumoCaseId caseId() const; void setCaseId( const SumoCaseId& caseId ); + QString assetName() const; + void setAssetName( const QString& assetName ); + QString caseName() const; void setCaseName( const QString& caseName ); QString ensembleName() const; void setEnsembleName( const QString& ensembleName ); - std::vector realizationIds() const; - void setRealizationIds( const std::vector& realizationIds ); + // All realizations available for the ensemble (the source of truth). + std::vector availableRealizationIds() const; + void setAvailableRealizationIds( const std::vector& realizationIds ); + // The subset of realizations matching the realization filter. Summary ensemble creation listens to this. + std::vector selectedRealizationIds() const; + + // Available summary vectors for the ensemble. Not shown in the UI, but used to populate the + // ensemble's available result addresses (RimSummaryEnsembleSumo::updateResultAddresses). std::vector vectorNames() const; void setVectorNames( const std::vector& vectorNames ); @@ -59,16 +73,21 @@ private: void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; - QString realizationInfoText() const; + void onRealizationFilterChanged(); + + QString realizationFilterInfoText() const; + QString availableRealizationsRangeText() const; private: caf::PdmField m_caseId; + caf::PdmField m_assetName; caf::PdmField m_caseName; caf::PdmField m_ensembleName; caf::PdmField m_customName; - caf::PdmProxyValueField m_realizationInfo; - caf::PdmField> m_realizationIds; + caf::PdmField> m_availableRealizationIds; + caf::PdmField m_realizationFilter; + caf::PdmProxyValueField m_realizationFilterInfo; caf::PdmField> m_vectorNames; }; diff --git a/ApplicationLibCode/ProjectDataModelCommands/RimcProject.cpp b/ApplicationLibCode/ProjectDataModelCommands/RimcProject.cpp index c5ae8a4c2b..4172040d30 100644 --- a/ApplicationLibCode/ProjectDataModelCommands/RimcProject.cpp +++ b/ApplicationLibCode/ProjectDataModelCommands/RimcProject.cpp @@ -37,6 +37,7 @@ #include "RimOilField.h" #include "RimProject.h" #include "RimSummaryCase.h" +#include "RimSummaryCaseMainCollection.h" #include "RimSurfaceCollection.h" #include "RimTools.h" #include "RimValveTemplateCollection.h" diff --git a/docs/cloud-service-api.md b/docs/cloud-service-api.md index ad00c913a0..a86a85ab69 100644 --- a/docs/cloud-service-api.md +++ b/docs/cloud-service-api.md @@ -17,6 +17,10 @@ strictly required in the environment. - Python 3.11 or later, which is what `ri-cloud-api` requires. - The submodule checked out: `git submodule update --init`. +- **Development builds only:** *Preferences -> Scripting -> Shared Script Folder(s)* has to include + `/scripts/ri-cloud-api`, or the `scripts` folder above it. Nothing is copied into the build + folder, so this is the only way a development build finds the service. See + [where ResInsight looks](#where-resinsight-looks-for-the-package). ## Python Environment diff --git a/scripts/ri-cloud-api b/scripts/ri-cloud-api index 36b996a96e..67826bf7ad 160000 --- a/scripts/ri-cloud-api +++ b/scripts/ri-cloud-api @@ -1 +1 @@ -Subproject commit 36b996a96e49ede6245bc71abe0dd82c05c5731a +Subproject commit 67826bf7ad401afb527bd7370d4d60d45e085927