diff --git a/ApplicationLibCode/Application/CMakeLists_files.cmake b/ApplicationLibCode/Application/CMakeLists_files.cmake index e61a2c36e28..9ed741f3dd2 100644 --- a/ApplicationLibCode/Application/CMakeLists_files.cmake +++ b/ApplicationLibCode/Application/CMakeLists_files.cmake @@ -13,6 +13,7 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesOsdu.h ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesOpm.h ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesSumo.h + ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesSumoExplorer.h ${CMAKE_CURRENT_LIST_DIR}/RiaPorosityModel.h ${CMAKE_CURRENT_LIST_DIR}/RiaCurveSetDefinition.h ${CMAKE_CURRENT_LIST_DIR}/RiaRftPltCurveDefinition.h @@ -58,6 +59,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesOsdu.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesOpm.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesSumo.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiaPreferencesSumoExplorer.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaPorosityModel.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaCurveSetDefinition.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaRftPltCurveDefinition.cpp diff --git a/ApplicationLibCode/Application/RiaApplication.cpp b/ApplicationLibCode/Application/RiaApplication.cpp index 41a7f4c6934..61e2e4a19e4 100644 --- a/ApplicationLibCode/Application/RiaApplication.cpp +++ b/ApplicationLibCode/Application/RiaApplication.cpp @@ -21,6 +21,7 @@ #include "Cloud/RiaOsduConnector.h" #include "Cloud/RiaSumoConnector.h" #include "Cloud/RiaSumoDefines.h" +#include "Cloud/RiaSumoExplorerConnector.h" #include "KeyValueStore/RiaKeyValueStore.h" #include "RiaDefines.h" #include "RiaOsduDefines.h" @@ -36,6 +37,7 @@ #include "RiaPreferences.h" #include "RiaPreferencesOsdu.h" #include "RiaPreferencesSumo.h" +#include "RiaPreferencesSumoExplorer.h" #include "RiaPreferencesSystem.h" #include "RiaProjectModifier.h" #include "RiaRegressionTestRunner.h" @@ -1889,6 +1891,27 @@ RiaSumoConnector* RiaApplication::makeSumoConnector() return m_sumoConnector; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaSumoExplorerConnector* RiaApplication::makeSumoExplorerConnector() +{ + if ( RiaRegressionTestRunner::instance()->isRunningRegressionTests() ) + { + return nullptr; + } + + if ( !m_sumoExplorerConnector ) + { + auto prefs = preferences()->sumoExplorerPreferences(); + m_sumoExplorerConnector = new RiaSumoExplorerConnector( RiuMainWindow::instance(), prefs->pythonPath(), prefs->serverPort() ); + + // Note: Auto-start is handled in RiaConnectorTools::configureCloudServices() + } + + return m_sumoExplorerConnector; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/Application/RiaApplication.h b/ApplicationLibCode/Application/RiaApplication.h index 257bcb1cc51..fd08af1cac2 100644 --- a/ApplicationLibCode/Application/RiaApplication.h +++ b/ApplicationLibCode/Application/RiaApplication.h @@ -71,6 +71,7 @@ class RiuRecentFileActionProvider; class RiaArgumentParser; class RiaOsduConnector; class RiaSumoConnector; +class RiaSumoExplorerConnector; namespace caf { @@ -200,8 +201,9 @@ class RiaApplication virtual void addToRecentFiles( const QString& fileName ) {} virtual void showFormattedTextInMessageBoxOrConsole( const QString& errMsg ) = 0; - RiaOsduConnector* makeOsduConnector(); - RiaSumoConnector* makeSumoConnector(); + RiaOsduConnector* makeOsduConnector(); + RiaSumoConnector* makeSumoConnector(); + RiaSumoExplorerConnector* makeSumoExplorerConnector(); RiaKeyValueStore* keyValueStore() const; @@ -268,7 +270,8 @@ class RiaApplication std::optional m_logLevelFromCommandLine; private: - static RiaApplication* s_riaApplication; - QPointer m_osduConnector; - QPointer m_sumoConnector; + static RiaApplication* s_riaApplication; + QPointer m_osduConnector; + QPointer m_sumoConnector; + QPointer m_sumoExplorerConnector; }; diff --git a/ApplicationLibCode/Application/RiaPreferences.cpp b/ApplicationLibCode/Application/RiaPreferences.cpp index c7ee9fa0c02..ded79b5c27b 100644 --- a/ApplicationLibCode/Application/RiaPreferences.cpp +++ b/ApplicationLibCode/Application/RiaPreferences.cpp @@ -32,6 +32,7 @@ #include "RiaPreferencesOsdu.h" #include "RiaPreferencesSummary.h" #include "RiaPreferencesSumo.h" +#include "RiaPreferencesSumoExplorer.h" #include "RiaPreferencesSystem.h" #include "RiaQDateTimeTools.h" #include "RiaValidRegExpValidator.h" @@ -285,6 +286,9 @@ RiaPreferences::RiaPreferences() caf::PdmUiPushButtonEditor::configureEditorLabelHidden( &m_deleteSumoToken ); m_deleteSumoToken.xmlCapability()->disableIO(); + CAF_PDM_InitFieldNoDefault( &m_sumoExplorerPreferences, "sumoExplorerPreferences", "sumoExplorerPreferences" ); + m_sumoExplorerPreferences = new RiaPreferencesSumoExplorer; + CAF_PDM_InitFieldNoDefault( &m_openTelemetryPreferences, "openTelemetryPreferences", "openTelemetryPreferences" ); m_openTelemetryPreferences = new RiaPreferencesOpenTelemetry; @@ -527,6 +531,10 @@ void RiaPreferences::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& m_sumoPreferences()->uiOrdering( uiConfigName, *sumoGroup ); sumoGroup->add( &m_deleteSumoToken ); + caf::PdmUiGroup* sumoExplorerGroup = uiOrdering.addNewGroup( "SUMO Explorer" ); + sumoExplorerGroup->setCollapsedByDefault(); + m_sumoExplorerPreferences()->uiOrdering( uiConfigName, *sumoExplorerGroup ); + caf::PdmUiGroup* openTelemetryGroup = uiOrdering.addNewGroup( "OpenTelemetry" ); openTelemetryGroup->setCollapsedByDefault(); m_openTelemetryPreferences()->uiOrdering( uiConfigName, *openTelemetryGroup ); @@ -1086,6 +1094,14 @@ RiaPreferencesSumo* RiaPreferences::sumoPreferences() const return m_sumoPreferences(); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaPreferencesSumoExplorer* RiaPreferences::sumoExplorerPreferences() const +{ + return m_sumoExplorerPreferences(); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/Application/RiaPreferences.h b/ApplicationLibCode/Application/RiaPreferences.h index f19608ea6fb..599e1b46f62 100644 --- a/ApplicationLibCode/Application/RiaPreferences.h +++ b/ApplicationLibCode/Application/RiaPreferences.h @@ -46,6 +46,7 @@ class RiaPreferencesSystem; class RiaPreferencesOsdu; class RiaPreferencesGrid; class RiaPreferencesSumo; +class RiaPreferencesSumoExplorer; class RiaPreferencesOpm; class RiaPreferencesOpenTelemetry; @@ -128,6 +129,7 @@ class RiaPreferences : public caf::PdmObject RiaPreferencesSystem* systemPreferences() const; RiaPreferencesOsdu* osduPreferences() const; RiaPreferencesSumo* sumoPreferences() const; + RiaPreferencesSumoExplorer* sumoExplorerPreferences() const; RiaPreferencesGrid* gridPreferences() const; RiaPreferencesOpm* opmPreferences() const; RiaPreferencesOpenTelemetry* openTelemetryPreferences() const; @@ -243,6 +245,9 @@ class RiaPreferences : public caf::PdmObject caf::PdmChildField m_sumoPreferences; caf::PdmField m_deleteSumoToken; + // sumo explorer settings + caf::PdmChildField m_sumoExplorerPreferences; + // OpenTelemetry settings caf::PdmChildField m_openTelemetryPreferences; diff --git a/ApplicationLibCode/Application/RiaPreferencesSumoExplorer.cpp b/ApplicationLibCode/Application/RiaPreferencesSumoExplorer.cpp new file mode 100644 index 00000000000..a68b2968c1e --- /dev/null +++ b/ApplicationLibCode/Application/RiaPreferencesSumoExplorer.cpp @@ -0,0 +1,94 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- 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 "RiaPreferencesSumoExplorer.h" + +#include "Cloud/RiaSumoExplorerDefines.h" +#include "RiaApplication.h" +#include "RiaPreferences.h" + +#include "cafPdmUiOrdering.h" + +CAF_PDM_SOURCE_INIT( RiaPreferencesSumoExplorer, "RiaPreferencesSumoExplorer" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaPreferencesSumoExplorer::RiaPreferencesSumoExplorer() +{ + CAF_PDM_InitField( &m_serverPort, "serverPort", RiaSumoExplorerDefines::defaultPort(), "Server Port" ); + + CAF_PDM_InitFieldNoDefault( &m_pythonPath, "pythonPath", "Python Executable Path" ); + + CAF_PDM_InitField( &m_autoStartServer, "autoStartServer", true, "Auto-Start Server" ); + + CAF_PDM_InitField( &m_sumoEnvironment, "sumoEnvironment", QString( "prod" ), "Sumo Environment" ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaPreferencesSumoExplorer* RiaPreferencesSumoExplorer::current() +{ + return RiaApplication::instance()->preferences()->sumoExplorerPreferences(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +unsigned int RiaPreferencesSumoExplorer::serverPort() const +{ + return m_serverPort; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaPreferencesSumoExplorer::pythonPath() const +{ + return m_pythonPath; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaPreferencesSumoExplorer::autoStartServer() const +{ + return m_autoStartServer; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaPreferencesSumoExplorer::sumoEnvironment() const +{ + return m_sumoEnvironment; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaPreferencesSumoExplorer::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) +{ + uiOrdering.add( &m_autoStartServer ); + uiOrdering.add( &m_serverPort ); + uiOrdering.add( &m_pythonPath ); + uiOrdering.add( &m_sumoEnvironment ); + + uiOrdering.skipRemainingFields( true ); +} diff --git a/ApplicationLibCode/Application/RiaPreferencesSumoExplorer.h b/ApplicationLibCode/Application/RiaPreferencesSumoExplorer.h new file mode 100644 index 00000000000..2d12eb17d9b --- /dev/null +++ b/ApplicationLibCode/Application/RiaPreferencesSumoExplorer.h @@ -0,0 +1,49 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafPdmField.h" +#include "cafPdmObject.h" + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +class RiaPreferencesSumoExplorer : public caf::PdmObject +{ + CAF_PDM_HEADER_INIT; + +public: + RiaPreferencesSumoExplorer(); + + static RiaPreferencesSumoExplorer* current(); + + unsigned int serverPort() const; + QString pythonPath() const; + bool autoStartServer() const; + QString sumoEnvironment() const; + +protected: + void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; + +private: + caf::PdmField m_serverPort; + caf::PdmField m_pythonPath; + caf::PdmField m_autoStartServer; + caf::PdmField m_sumoEnvironment; +}; diff --git a/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake b/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake index 4d6757eebc8..f67b300f466 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake +++ b/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake @@ -2,6 +2,8 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RiaCloudConnector.h ${CMAKE_CURRENT_LIST_DIR}/RiaSumoConnector.h ${CMAKE_CURRENT_LIST_DIR}/RiaSumoDefines.h + ${CMAKE_CURRENT_LIST_DIR}/RiaSumoExplorerConnector.h + ${CMAKE_CURRENT_LIST_DIR}/RiaSumoExplorerDefines.h ${CMAKE_CURRENT_LIST_DIR}/RiaConnectorTools.h ${CMAKE_CURRENT_LIST_DIR}/RiaOsduConnector.h ${CMAKE_CURRENT_LIST_DIR}/RiaOAuthHttpServerReplyHandler.h @@ -11,6 +13,8 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiaCloudConnector.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaSumoConnector.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaSumoDefines.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiaSumoExplorerConnector.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiaSumoExplorerDefines.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaConnectorTools.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaOsduConnector.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaOAuthHttpServerReplyHandler.cpp diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaConnectorTools.cpp b/ApplicationLibCode/Application/Tools/Cloud/RiaConnectorTools.cpp index 6b2919e2aed..0a0cb1e8b43 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/RiaConnectorTools.cpp +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaConnectorTools.cpp @@ -25,6 +25,8 @@ #include "RiaPreferencesOpenTelemetry.h" #include "RiaPreferencesOsdu.h" #include "RiaPreferencesSumo.h" +#include "RiaPreferencesSumoExplorer.h" +#include "RiaSumoExplorerConnector.h" #include #include @@ -241,5 +243,23 @@ void RiaConnectorTools::configureCloudServices() RiaLogging::debug( "OpenTelemetry initialization failed or not configured" ); } } + + // Start Sumo Explorer server if auto-start is enabled + if ( preferences->sumoExplorerPreferences()->autoStartServer() ) + { + RiaLogging::info( "Auto-starting Sumo Explorer server..." ); + auto* connector = RiaApplication::instance()->makeSumoExplorerConnector(); + if ( connector && !connector->isServerRunning() ) + { + if ( connector->startServer() ) + { + RiaLogging::info( "Sumo Explorer server started successfully" ); + } + else + { + RiaLogging::warning( QString( "Failed to auto-start Sumo Explorer server: %1" ).arg( connector->lastError() ) ); + } + } + } } } diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerConnector.cpp b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerConnector.cpp new file mode 100644 index 00000000000..3563a9a9069 --- /dev/null +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerConnector.cpp @@ -0,0 +1,880 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- 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 "RiaSumoExplorerConnector.h" + +#include "RiaLogging.h" +#include "RiaSumoExplorerDefines.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaSumoExplorerConnector::RiaSumoExplorerConnector( QObject* parent, const QString& pythonPath, unsigned int port ) + : QObject( parent ) + , m_serverProcess( nullptr ) + , m_networkManager( new QNetworkAccessManager( this ) ) + , m_pythonPath( pythonPath ) + , m_port( port ) + , m_serverRunning( false ) +{ +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaSumoExplorerConnector::~RiaSumoExplorerConnector() +{ + stopServer(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaSumoExplorerConnector::startServer() +{ + if ( m_serverRunning ) + { + RiaLogging::info( "Sumo Explorer server already running" ); + return true; + } + + // Determine Python executable + QString pythonCmd = m_pythonPath.isEmpty() ? "python" : m_pythonPath; + + // Determine server script path + QString appPath = QCoreApplication::applicationDirPath(); + QString serverPath = appPath + "/Python/sumo_explorer_server/sumo_explorer_server.py"; + + if ( !QFile::exists( serverPath ) ) + { + setError( QString( "Sumo Explorer server script not found: %1" ).arg( serverPath ) ); + return false; + } + + // Create process + m_serverProcess = new QProcess( this ); + + connect( m_serverProcess, &QProcess::errorOccurred, this, &RiaSumoExplorerConnector::onProcessError ); + connect( m_serverProcess, QOverload::of( &QProcess::finished ), this, &RiaSumoExplorerConnector::onProcessFinished ); + connect( m_serverProcess, &QProcess::readyReadStandardOutput, this, &RiaSumoExplorerConnector::onProcessReadyReadStandardOutput ); + connect( m_serverProcess, &QProcess::readyReadStandardError, this, &RiaSumoExplorerConnector::onProcessReadyReadStandardError ); + + // Build command + QStringList args; + args << "-m" + << "uvicorn" + << "sumo_explorer_server.sumo_explorer_server:app" + << "--host" + << "127.0.0.1" + << "--port" << QString::number( m_port ); + + // Set working directory to Python directory + QString pythonDir = appPath + "/Python"; + m_serverProcess->setWorkingDirectory( pythonDir ); + + RiaLogging::info( QString( "Starting Sumo Explorer server: %1 %2" ).arg( pythonCmd ).arg( args.join( " " ) ) ); + + // Start process + m_serverProcess->start( pythonCmd, args ); + + if ( !m_serverProcess->waitForStarted( 5000 ) ) + { + QString errorMsg = QString( "Failed to start Sumo Explorer server process. Command: %1 %2" ).arg( pythonCmd ).arg( args.join( " " ) ); + setError( errorMsg ); + + // Log process error details + if ( m_serverProcess->error() == QProcess::FailedToStart ) + { + RiaLogging::error( "Process failed to start. Check that Python is installed and in PATH." ); + } + + delete m_serverProcess; + m_serverProcess = nullptr; + return false; + } + + RiaLogging::info( "Python process started, waiting for server to be ready..." ); + + // Wait for server to be ready + if ( !waitForServerReady() ) + { + // Read any error output before stopping + if ( m_serverProcess ) + { + QString stdErr = QString::fromUtf8( m_serverProcess->readAllStandardError() ); + QString stdOut = QString::fromUtf8( m_serverProcess->readAllStandardOutput() ); + + if ( !stdErr.isEmpty() ) + { + RiaLogging::error( QString( "Server stderr: %1" ).arg( stdErr ) ); + } + if ( !stdOut.isEmpty() ) + { + RiaLogging::info( QString( "Server stdout: %1" ).arg( stdOut ) ); + } + } + + setError( QString( "Sumo Explorer server failed to become ready after 10 seconds. Check that uvicorn and required packages are " + "installed (run: pip install -r %1/Python/sumo_explorer_server/requirements.txt)" ) + .arg( appPath ) ); + stopServer(); + return false; + } + + m_serverRunning = true; + RiaLogging::info( QString( "Sumo Explorer server started on port %1" ).arg( m_port ) ); + emit serverStarted(); + + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::stopServer() +{ + if ( !m_serverProcess ) return; + + RiaLogging::info( "Stopping Sumo Explorer server" ); + + m_serverProcess->terminate(); + + if ( !m_serverProcess->waitForFinished( 5000 ) ) + { + RiaLogging::warning( "Sumo Explorer server did not terminate, killing process" ); + m_serverProcess->kill(); + m_serverProcess->waitForFinished( 1000 ); + } + + delete m_serverProcess; + m_serverProcess = nullptr; + m_serverRunning = false; + + emit serverStopped(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaSumoExplorerConnector::isServerRunning() const +{ + return m_serverRunning; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaSumoExplorerConnector::waitForServerReady( int timeoutMs ) +{ + QString healthUrl = makeUrl( "/health" ); + + QElapsedTimer timer; + timer.start(); + + int attemptCount = 0; + + while ( timer.elapsed() < timeoutMs ) + { + attemptCount++; + RiaLogging::debug( QString( "Health check attempt %1, URL: %2" ).arg( attemptCount ).arg( healthUrl ) ); + + QNetworkRequest request( healthUrl ); + QNetworkReply* reply = m_networkManager->get( request ); + + QEventLoop loop; + connect( reply, &QNetworkReply::finished, &loop, &QEventLoop::quit ); + + QTimer timeoutTimer; + timeoutTimer.setSingleShot( true ); + connect( &timeoutTimer, &QTimer::timeout, &loop, &QEventLoop::quit ); + timeoutTimer.start( 1000 ); + + loop.exec(); + + if ( reply->error() == QNetworkReply::NoError ) + { + RiaLogging::info( QString( "Server health check succeeded after %1 attempts" ).arg( attemptCount ) ); + reply->deleteLater(); + return true; + } + else + { + RiaLogging::debug( QString( "Health check failed: %1" ).arg( reply->errorString() ) ); + } + + reply->deleteLater(); + + // Wait a bit before retrying + QThread::msleep( 500 ); + } + + RiaLogging::error( QString( "Server failed to respond to health checks after %1 attempts over %2ms" ).arg( attemptCount ).arg( timeoutMs ) ); + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestAssetsBlocking() +{ + auto requestCallable = [this]() { requestAssets(); }; + auto replyHandler = [this]( QNetworkReply* reply ) { parseAssetsReply( reply ); }; + wrapAndCallNetworkRequest( requestCallable, replyHandler ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestCasesForFieldBlocking( const QString& fieldName ) +{ + auto requestCallable = [this, fieldName]() { requestCasesForField( fieldName ); }; + auto replyHandler = [this]( QNetworkReply* reply ) { parseCasesReply( reply ); }; + wrapAndCallNetworkRequest( requestCallable, replyHandler ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestEnsemblesForCaseBlocking( const QString& caseId ) +{ + auto requestCallable = [this, caseId]() { requestEnsemblesForCase( caseId ); }; + auto replyHandler = [this]( QNetworkReply* reply ) { parseEnsemblesReply( reply ); }; + wrapAndCallNetworkRequest( requestCallable, replyHandler ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestVectorNamesBlocking( const QString& caseId, const QString& ensembleName ) +{ + auto requestCallable = [this, caseId, ensembleName]() { requestVectorNames( caseId, ensembleName ); }; + auto replyHandler = [this]( QNetworkReply* reply ) { parseVectorNamesReply( reply ); }; + wrapAndCallNetworkRequest( requestCallable, replyHandler ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestRealizationIdsBlocking( const QString& caseId, const QString& ensembleName ) +{ + auto requestCallable = [this, caseId, ensembleName]() { requestRealizationIds( caseId, ensembleName ); }; + auto replyHandler = [this]( QNetworkReply* reply ) { parseRealizationIdsReply( reply ); }; + wrapAndCallNetworkRequest( requestCallable, replyHandler ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QByteArray RiaSumoExplorerConnector::requestSummaryDataBlocking( const QString& caseId, const QString& ensembleName, const QString& vectorName ) +{ + QString url = makeUrl( QString( "/summary/data?case_id=%1&ensemble=%2&vector=%3" ) + .arg( QString( QUrl::toPercentEncoding( caseId ) ) ) + .arg( QString( QUrl::toPercentEncoding( ensembleName ) ) ) + .arg( QString( QUrl::toPercentEncoding( vectorName ) ) ) ); + + QByteArray response = executeGetRequest( url ); + if ( response.isEmpty() ) return {}; + + QJsonDocument doc = QJsonDocument::fromJson( response ); + QJsonObject obj = doc.object(); + QString base64 = obj["data_base64"].toString(); + + return base64ToBytes( base64 ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QByteArray RiaSumoExplorerConnector::requestParametersBlocking( const QString& caseId, const QString& ensembleName ) +{ + QString url = makeUrl( QString( "/summary/parameters?case_id=%1&ensemble=%2" ) + .arg( QString( QUrl::toPercentEncoding( caseId ) ) ) + .arg( QString( QUrl::toPercentEncoding( ensembleName ) ) ) ); + + QByteArray response = executeGetRequest( url ); + if ( response.isEmpty() ) return {}; + + QJsonDocument doc = QJsonDocument::fromJson( response ); + QJsonObject obj = doc.object(); + QString base64 = obj["data_base64"].toString(); + + return base64ToBytes( base64 ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestAssets() +{ + if ( !m_serverRunning ) + { + RiaLogging::error( "Sumo Explorer server not running. Please start the server first." ); + return; + } + + QString url = makeUrl( "/assets" ); + RiaLogging::debug( QString( "Requesting assets from: %1" ).arg( url ) ); + + QNetworkRequest request( url ); + QNetworkReply* reply = m_networkManager->get( request ); + + connect( reply, + &QNetworkReply::finished, + [this, reply, url]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + parseAssetsReply( reply ); + } + else + { + RiaLogging::error( QString( "Failed to request assets from %1: %2" ).arg( url ).arg( reply->errorString() ) ); + if ( reply->error() == QNetworkReply::ConnectionRefusedError ) + { + m_serverRunning = false; + RiaLogging::error( "Server connection refused. The server may have stopped or failed to start properly." ); + } + } + reply->deleteLater(); + } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestCasesForField( const QString& fieldName ) +{ + if ( !m_serverRunning ) + { + RiaLogging::error( "Sumo Explorer server not running" ); + return; + } + + QString url = makeUrl( QString( "/cases/%1" ).arg( QString( QUrl::toPercentEncoding( fieldName ) ) ) ); + QNetworkRequest request( url ); + QNetworkReply* reply = m_networkManager->get( request ); + + connect( reply, + &QNetworkReply::finished, + [this, reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + parseCasesReply( reply ); + } + else + { + RiaLogging::error( QString( "Failed to request cases: %1" ).arg( reply->errorString() ) ); + } + reply->deleteLater(); + } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestEnsemblesForCase( const QString& caseId ) +{ + if ( !m_serverRunning ) + { + RiaLogging::error( "Sumo Explorer server not running" ); + return; + } + + QString url = makeUrl( QString( "/ensembles/%1" ).arg( QString( QUrl::toPercentEncoding( caseId ) ) ) ); + QNetworkRequest request( url ); + QNetworkReply* reply = m_networkManager->get( request ); + + connect( reply, + &QNetworkReply::finished, + [this, reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + parseEnsemblesReply( reply ); + } + else + { + RiaLogging::error( QString( "Failed to request ensembles: %1" ).arg( reply->errorString() ) ); + } + reply->deleteLater(); + } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestVectorNames( const QString& caseId, const QString& ensembleName ) +{ + if ( !m_serverRunning ) + { + RiaLogging::error( "Sumo Explorer server not running" ); + return; + } + + QString url = makeUrl( QString( "/summary/vectors?case_id=%1&ensemble=%2" ) + .arg( QString( QUrl::toPercentEncoding( caseId ) ) ) + .arg( QString( QUrl::toPercentEncoding( ensembleName ) ) ) ); + + QNetworkRequest request( url ); + QNetworkReply* reply = m_networkManager->get( request ); + + connect( reply, + &QNetworkReply::finished, + [this, reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + parseVectorNamesReply( reply ); + } + else + { + RiaLogging::error( QString( "Failed to request vector names: %1" ).arg( reply->errorString() ) ); + } + reply->deleteLater(); + } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::requestRealizationIds( const QString& caseId, const QString& ensembleName ) +{ + if ( !m_serverRunning ) + { + RiaLogging::error( "Sumo Explorer server not running" ); + return; + } + + QString url = makeUrl( QString( "/summary/realizations?case_id=%1&ensemble=%2" ) + .arg( QString( QUrl::toPercentEncoding( caseId ) ) ) + .arg( QString( QUrl::toPercentEncoding( ensembleName ) ) ) ); + + QNetworkRequest request( url ); + QNetworkReply* reply = m_networkManager->get( request ); + + connect( reply, + &QNetworkReply::finished, + [this, reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + parseRealizationIdsReply( reply ); + } + else + { + RiaLogging::error( QString( "Failed to request realization IDs: %1" ).arg( reply->errorString() ) ); + } + reply->deleteLater(); + } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RiaSumoExplorerConnector::assets() const +{ + return m_assets; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RiaSumoExplorerConnector::cases() const +{ + return m_cases; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RiaSumoExplorerConnector::ensembles() const +{ + return m_ensembles; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RiaSumoExplorerConnector::vectorNames() const +{ + return m_vectorNames; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RiaSumoExplorerConnector::realizationIds() const +{ + return m_realizationIds; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaSumoExplorerConnector::serverUrl() const +{ + return QString( "http://127.0.0.1:%1" ).arg( m_port ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaSumoExplorerConnector::lastError() const +{ + return m_lastError; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::onProcessError( QProcess::ProcessError error ) +{ + QString errorMsg; + switch ( error ) + { + case QProcess::FailedToStart: + errorMsg = "Failed to start Sumo Explorer server (Python not found or script missing)"; + break; + case QProcess::Crashed: + errorMsg = "Sumo Explorer server crashed"; + break; + default: + errorMsg = QString( "Sumo Explorer server error: %1" ).arg( static_cast( error ) ); + break; + } + + setError( errorMsg ); + m_serverRunning = false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::onProcessFinished( int exitCode, QProcess::ExitStatus exitStatus ) +{ + if ( exitStatus == QProcess::CrashExit ) + { + RiaLogging::error( QString( "Sumo Explorer server crashed with exit code %1" ).arg( exitCode ) ); + } + else if ( exitCode != 0 ) + { + RiaLogging::warning( QString( "Sumo Explorer server exited with code %1" ).arg( exitCode ) ); + } + + m_serverRunning = false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::onProcessReadyReadStandardOutput() +{ + if ( !m_serverProcess ) return; + + QByteArray data = m_serverProcess->readAllStandardOutput(); + logServerOutput( QString::fromUtf8( data ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::onProcessReadyReadStandardError() +{ + if ( !m_serverProcess ) return; + + QByteArray data = m_serverProcess->readAllStandardError(); + QString output = QString::fromUtf8( data ); + + // Log error output + if ( !output.trimmed().isEmpty() ) + { + RiaLogging::warning( QString( "Sumo Explorer server: %1" ).arg( output.trimmed() ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::parseAssetsReply( QNetworkReply* reply ) +{ + m_assets.clear(); + + QByteArray data = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson( data ); + + if ( !doc.isArray() ) + { + RiaLogging::error( "Invalid assets response format" ); + emit assetsFinished(); + return; + } + + QJsonArray array = doc.array(); + for ( const QJsonValue& value : array ) + { + QJsonObject obj = value.toObject(); + + SumoExplorerAsset asset; + asset.assetId = obj["asset_id"].toString(); + asset.kind = obj["kind"].toString(); + asset.name = obj["name"].toString(); + + m_assets.push_back( asset ); + } + + emit assetsFinished(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::parseCasesReply( QNetworkReply* reply ) +{ + m_cases.clear(); + + QByteArray data = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson( data ); + + if ( !doc.isArray() ) + { + RiaLogging::error( "Invalid cases response format" ); + emit casesFinished(); + return; + } + + QJsonArray array = doc.array(); + for ( const QJsonValue& value : array ) + { + QJsonObject obj = value.toObject(); + + SumoExplorerCase sumoCase; + sumoCase.caseId = obj["case_id"].toString(); + sumoCase.kind = obj["kind"].toString(); + sumoCase.name = obj["name"].toString(); + sumoCase.assetId = obj["asset_id"].toString(); + + m_cases.push_back( sumoCase ); + } + + emit casesFinished(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::parseEnsemblesReply( QNetworkReply* reply ) +{ + m_ensembles.clear(); + + QByteArray data = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson( data ); + + if ( !doc.isArray() ) + { + RiaLogging::error( "Invalid ensembles response format" ); + emit ensemblesFinished(); + return; + } + + QJsonArray array = doc.array(); + for ( const QJsonValue& value : array ) + { + QJsonObject obj = value.toObject(); + + SumoExplorerEnsemble ensemble; + ensemble.ensembleName = obj["ensemble_name"].toString(); + ensemble.caseId = obj["case_id"].toString(); + + m_ensembles.push_back( ensemble ); + } + + emit ensemblesFinished(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::parseVectorNamesReply( QNetworkReply* reply ) +{ + m_vectorNames.clear(); + + QByteArray data = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson( data ); + + if ( !doc.isArray() ) + { + RiaLogging::error( "Invalid vector names response format" ); + emit vectorNamesFinished(); + return; + } + + QJsonArray array = doc.array(); + for ( const QJsonValue& value : array ) + { + QJsonObject obj = value.toObject(); + + SumoExplorerVectorInfo vectorInfo; + vectorInfo.name = obj["name"].toString(); + + m_vectorNames.push_back( vectorInfo ); + } + + emit vectorNamesFinished(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::parseRealizationIdsReply( QNetworkReply* reply ) +{ + m_realizationIds.clear(); + + QByteArray data = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson( data ); + + if ( !doc.isArray() ) + { + RiaLogging::error( "Invalid realization IDs response format" ); + emit realizationIdsFinished(); + return; + } + + QJsonArray array = doc.array(); + for ( const QJsonValue& value : array ) + { + QJsonObject obj = value.toObject(); + + SumoExplorerRealizationInfo realInfo; + realInfo.realizationId = obj["realization_id"].toInt(); + + m_realizationIds.push_back( realInfo ); + } + + emit realizationIdsFinished(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaSumoExplorerConnector::makeUrl( const QString& path ) const +{ + return QString( "http://127.0.0.1:%1%2" ).arg( m_port ).arg( path ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::logServerOutput( const QString& output ) +{ + if ( output.trimmed().isEmpty() ) return; + + // Log server output at debug level to avoid spam + RiaLogging::debug( QString( "Sumo Explorer server: %1" ).arg( output.trimmed() ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::setError( const QString& error ) +{ + m_lastError = error; + RiaLogging::error( error ); + emit serverError( error ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QByteArray RiaSumoExplorerConnector::executeGetRequest( const QString& url ) +{ + if ( !m_serverRunning ) + { + RiaLogging::error( "Sumo Explorer server not running" ); + return {}; + } + + QNetworkRequest request( url ); + QNetworkReply* reply = m_networkManager->get( request ); + + QEventLoop loop; + connect( reply, &QNetworkReply::finished, &loop, &QEventLoop::quit ); + + QTimer timer; + timer.setSingleShot( true ); + connect( &timer, &QTimer::timeout, &loop, &QEventLoop::quit ); + timer.start( RiaSumoExplorerDefines::requestTimeoutMillis() ); + + loop.exec(); + + QByteArray result; + if ( reply->error() == QNetworkReply::NoError ) + { + result = reply->readAll(); + } + else + { + RiaLogging::error( QString( "Request failed: %1" ).arg( reply->errorString() ) ); + } + + reply->deleteLater(); + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaSumoExplorerConnector::wrapAndCallNetworkRequest( std::function requestCallable, + const std::function& replyHandler ) +{ + QEventLoop eventLoop; + + QTimer timer; + timer.setSingleShot( true ); + + QObject::connect( &timer, &QTimer::timeout, [&] { RiaLogging::error( "Sumo Explorer request timed out." ); } ); + QObject::connect( &timer, &QTimer::timeout, &eventLoop, &QEventLoop::quit ); + + // Call the function that will execute the request + requestCallable(); + + timer.start( RiaSumoExplorerDefines::requestTimeoutMillis() ); + eventLoop.exec( QEventLoop::ProcessEventsFlag::ExcludeUserInputEvents ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QByteArray RiaSumoExplorerConnector::base64ToBytes( const QString& base64 ) +{ + return QByteArray::fromBase64( base64.toUtf8() ); +} diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerConnector.h b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerConnector.h new file mode 100644 index 00000000000..07200302972 --- /dev/null +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerConnector.h @@ -0,0 +1,126 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RiaSumoExplorerDefines.h" + +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; +class QNetworkReply; + +//================================================================================================== +// +// Sumo Explorer Connector +// +// Manages a local Python FastAPI server that wraps the Sumo Explorer API. +// Provides synchronous and asynchronous access to Sumo data via HTTP. +// +//================================================================================================== +class RiaSumoExplorerConnector : public QObject +{ + Q_OBJECT + +public: + RiaSumoExplorerConnector( QObject* parent, const QString& pythonPath, unsigned int port ); + ~RiaSumoExplorerConnector() override; + + // Server lifecycle management + bool startServer(); + void stopServer(); + bool isServerRunning() const; + bool waitForServerReady( int timeoutMs = 10000 ); + + // Synchronous data requests (blocking) + void requestAssetsBlocking(); + void requestCasesForFieldBlocking( const QString& fieldName ); + void requestEnsemblesForCaseBlocking( const QString& caseId ); + void requestVectorNamesBlocking( const QString& caseId, const QString& ensembleName ); + void requestRealizationIdsBlocking( const QString& caseId, const QString& ensembleName ); + QByteArray requestSummaryDataBlocking( const QString& caseId, const QString& ensembleName, const QString& vectorName ); + QByteArray requestParametersBlocking( const QString& caseId, const QString& ensembleName ); + + // Asynchronous data requests (non-blocking) + void requestAssets(); + void requestCasesForField( const QString& fieldName ); + void requestEnsemblesForCase( const QString& caseId ); + void requestVectorNames( const QString& caseId, const QString& ensembleName ); + void requestRealizationIds( const QString& caseId, const QString& ensembleName ); + + // Data accessors + std::vector assets() const; + std::vector cases() const; + std::vector ensembles() const; + std::vector vectorNames() const; + std::vector realizationIds() const; + + // Server info + QString serverUrl() const; + QString lastError() const; + +signals: + void serverStarted(); + void serverStopped(); + void serverError( const QString& error ); + void assetsFinished(); + void casesFinished(); + void ensemblesFinished(); + void vectorNamesFinished(); + void realizationIdsFinished(); + +private slots: + void onProcessError( QProcess::ProcessError error ); + void onProcessFinished( int exitCode, QProcess::ExitStatus exitStatus ); + void onProcessReadyReadStandardOutput(); + void onProcessReadyReadStandardError(); + + void parseAssetsReply( QNetworkReply* reply ); + void parseCasesReply( QNetworkReply* reply ); + void parseEnsemblesReply( QNetworkReply* reply ); + void parseVectorNamesReply( QNetworkReply* reply ); + void parseRealizationIdsReply( QNetworkReply* reply ); + +private: + QString makeUrl( const QString& path ) const; + void logServerOutput( const QString& output ); + void setError( const QString& error ); + + QByteArray executeGetRequest( const QString& url ); + void wrapAndCallNetworkRequest( std::function requestCallable, const std::function& replyHandler ); + QByteArray base64ToBytes( const QString& base64 ); + +private: + QProcess* m_serverProcess; + QNetworkAccessManager* m_networkManager; + QString m_pythonPath; + unsigned int m_port; + bool m_serverRunning; + QString m_lastError; + + std::vector m_assets; + std::vector m_cases; + std::vector m_ensembles; + std::vector m_vectorNames; + std::vector m_realizationIds; +}; diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.cpp b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.cpp new file mode 100644 index 00000000000..c041c2b051b --- /dev/null +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.cpp @@ -0,0 +1,47 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- 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 "RiaSumoExplorerDefines.h" + +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaSumoExplorerDefines::defaultServerPath() +{ + QString appPath = QCoreApplication::applicationDirPath(); + return appPath + "/Python/sumo_explorer_server/sumo_explorer_server.py"; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +unsigned int RiaSumoExplorerDefines::defaultPort() +{ + return 54527; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +int RiaSumoExplorerDefines::requestTimeoutMillis() +{ + return 30 * 1000; // 30 seconds for Sumo operations +} diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.h b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.h new file mode 100644 index 00000000000..965975b01fd --- /dev/null +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.h @@ -0,0 +1,89 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2025- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include + +//================================================================================================== +// +// Sumo Explorer Asset +// +//================================================================================================== +struct SumoExplorerAsset +{ + QString assetId; + QString kind; + QString name; +}; + +//================================================================================================== +// +// Sumo Explorer Case +// +//================================================================================================== +struct SumoExplorerCase +{ + QString caseId; + QString kind; + QString name; + QString assetId; +}; + +//================================================================================================== +// +// Sumo Explorer Ensemble +// +//================================================================================================== +struct SumoExplorerEnsemble +{ + QString ensembleName; + QString caseId; +}; + +//================================================================================================== +// +// Sumo Explorer Vector Info +// +//================================================================================================== +struct SumoExplorerVectorInfo +{ + QString name; +}; + +//================================================================================================== +// +// Sumo Explorer Realization Info +// +//================================================================================================== +struct SumoExplorerRealizationInfo +{ + int realizationId; +}; + +//================================================================================================== +// +// Sumo Explorer Defines +// +//================================================================================================== +namespace RiaSumoExplorerDefines +{ +QString defaultServerPath(); +unsigned int defaultPort(); +int requestTimeoutMillis(); +}; // namespace RiaSumoExplorerDefines diff --git a/ApplicationLibCode/ProjectDataModel/RimEclipseStatisticsCaseEvaluator.cpp b/ApplicationLibCode/ProjectDataModel/RimEclipseStatisticsCaseEvaluator.cpp index bee9035cfd5..423c601dfdd 100644 --- a/ApplicationLibCode/ProjectDataModel/RimEclipseStatisticsCaseEvaluator.cpp +++ b/ApplicationLibCode/ProjectDataModel/RimEclipseStatisticsCaseEvaluator.cpp @@ -20,6 +20,8 @@ #include "RimEclipseStatisticsCaseEvaluator.h" +#include "RiaLogging.h" + #include "RigCaseCellResultsData.h" #include "RigEclipseCaseData.h" #include "RigEclipseResultInfo.h" @@ -262,13 +264,20 @@ void RimEclipseStatisticsCaseEvaluator::evaluateForResults( const QList pValPoss.push_back( m_statisticsConfig.m_pMinPos ); pValPoss.push_back( m_statisticsConfig.m_pMidPos ); pValPoss.push_back( m_statisticsConfig.m_pMaxPos ); - std::vector pVals = + auto resultValues = RigStatisticsMath::calculateNearestRankPercentiles( values, pValPoss, RigStatisticsMath::PercentileStyle::SWITCHED ); - statParams[PMIN] = pVals[0]; - statParams[PMID] = pVals[1]; - statParams[PMAX] = pVals[2]; + if ( resultValues.has_value() ) + { + statParams[PMIN] = ( *resultValues )[0]; + statParams[PMID] = ( *resultValues )[1]; + statParams[PMAX] = ( *resultValues )[2]; + } + else + { + RiaLogging::warning( QString::fromStdString( resultValues.error() ) ); + } } else if ( m_statisticsConfig.m_pValMethod == RimEclipseStatisticsCase::PercentileCalcType::HISTOGRAM_ESTIMATED ) { @@ -285,17 +294,24 @@ void RimEclipseStatisticsCaseEvaluator::evaluateForResults( const QList else if ( m_statisticsConfig.m_pValMethod == RimEclipseStatisticsCase::PercentileCalcType::INTERPOLATED_OBSERVATION ) { - std::vector pValPoss; - pValPoss.push_back( m_statisticsConfig.m_pMinPos ); - pValPoss.push_back( m_statisticsConfig.m_pMidPos ); - pValPoss.push_back( m_statisticsConfig.m_pMaxPos ); - std::vector pVals = + std::vector percentiles; + percentiles.push_back( m_statisticsConfig.m_pMinPos ); + percentiles.push_back( m_statisticsConfig.m_pMidPos ); + percentiles.push_back( m_statisticsConfig.m_pMaxPos ); + auto resultValues = RigStatisticsMath::calculateInterpolatedPercentiles( values, - pValPoss, + percentiles, RigStatisticsMath::PercentileStyle::SWITCHED ); - statParams[PMIN] = pVals[0]; - statParams[PMID] = pVals[1]; - statParams[PMAX] = pVals[2]; + if ( resultValues.has_value() ) + { + statParams[PMIN] = ( *resultValues )[0]; + statParams[PMID] = ( *resultValues )[1]; + statParams[PMAX] = ( *resultValues )[2]; + } + else + { + RiaLogging::warning( QString::fromStdString( resultValues.error() ) ); + } } else { diff --git a/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.cpp b/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.cpp index a61b4a4b9c4..fad42bec2bc 100644 --- a/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.cpp +++ b/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.cpp @@ -23,10 +23,48 @@ #include #include #include +#include #include +namespace +{ +bool isValidQuantile( double quantile ) +{ + return quantile >= 0.0 && quantile <= 1.0; +} + +bool isValidPercentile( double percentile ) +{ + return percentile >= 0.0 && percentile <= 100.0; +} + +bool areValidQuantiles( const std::vector& quantiles ) +{ + return std::all_of( quantiles.begin(), quantiles.end(), isValidQuantile ); +} + +bool areValidPercentiles( const std::vector& percentiles ) +{ + return std::all_of( percentiles.begin(), percentiles.end(), isValidPercentile ); +} +} // namespace + //-------------------------------------------------------------------------------------------------- /// A function to do basic statistical calculations +/// +/// Formulas: +/// mean = sum(x) / n +/// +/// Standard deviation (population): +/// stdev = sqrt((n * sum(x^2) - (sum(x))^2)) / n +/// +/// Which is equivalent to: sqrt(sum((x - mean)^2) / n) +/// +/// range = max - min +/// +/// References: +/// Standard deviation: https://en.wikipedia.org/wiki/Standard_deviation +/// Rapid calculation method: https://en.wikipedia.org/wiki/Standard_deviation#Rapid_calculation_methods //-------------------------------------------------------------------------------------------------- void RigStatisticsMath::calculateBasicStatistics( const std::vector& values, @@ -85,8 +123,18 @@ void RigStatisticsMath::calculateBasicStatistics( const std::vector& val } //-------------------------------------------------------------------------------------------------- -/// Algorithm: -/// https://en.wikipedia.org/wiki/Percentile#Third_variant,_'%22%60UNIQ--postMath-00000052-QINU%60%22' +/// Calculate statistical curves (P10, P50, P90, mean) +/// +/// Percentiles (P10, P50, P90) are calculated using linear interpolation: +/// rank = percentile * (n + 1) - 1 +/// value = sorted[floor(rank)] + frac(rank) * (sorted[floor(rank)+1] - sorted[floor(rank)]) +/// +/// Mean is calculated as: +/// mean = sum(x) / n +/// +/// References: +/// Percentiles: https://en.wikipedia.org/wiki/Percentile#Third_variant,_C_=_0 +/// P10/P50/P90: https://en.wikipedia.org/wiki/Percentile#Definitions //-------------------------------------------------------------------------------------------------- void RigStatisticsMath::calculateStatisticsCurves( const std::vector& values, double* p10, @@ -99,12 +147,76 @@ void RigStatisticsMath::calculateStatisticsCurves( const std::vector& va if ( values.empty() ) return; - enum PValue + // Use the vector-based implementation + std::vector percentiles = { 0.1, 0.5, 0.9 }; + auto results = calculatePercentiles( values, percentiles, percentileStyle ); + + if ( results.has_value() && results->size() == 3 ) + { + *p10 = ( *results )[0]; + *p50 = ( *results )[1]; + *p90 = ( *results )[2]; + } + else + { + *p10 = HUGE_VAL; + *p50 = HUGE_VAL; + *p90 = HUGE_VAL; + } + + // Calculate mean separately + std::vector validValues = values; + validValues.erase( std::remove_if( validValues.begin(), + validValues.end(), + []( double x ) { return !RiaStatisticsTools::isValidNumber( x ); } ), + validValues.end() ); + + if ( !validValues.empty() ) + { + double valueSum = std::accumulate( validValues.begin(), validValues.end(), 0.0 ); + *mean = valueSum / validValues.size(); + } + else + { + *mean = HUGE_VAL; + } +} + +//-------------------------------------------------------------------------------------------------- +/// Calculate percentiles using linear interpolation method +/// +/// Formula: +/// rank = percentile * (n + 1) - 1 +/// +/// If rank is not an integer: +/// value = sorted[floor(rank)] + frac(rank) * (sorted[floor(rank)+1] - sorted[floor(rank)]) +/// +/// Where frac(rank) is the fractional part of rank +/// +/// Valid for percentiles in range [1/(n+1), n/(n+1)] +/// +/// References: +/// https://en.wikipedia.org/wiki/Percentile +/// https://en.wikipedia.org/wiki/Percentile#Third_variant,_C_=_0 +//-------------------------------------------------------------------------------------------------- +std::expected, std::string> RigStatisticsMath::calculatePercentiles( const std::vector& values, + const std::vector& quantiles, + PercentileStyle percentileStyle ) +{ + if ( !areValidQuantiles( quantiles ) ) + { + return std::unexpected( "Quantiles must be in range [0-1]" ); + } + + if ( values.empty() ) + { + return std::unexpected( "Input values are empty" ); + } + + if ( quantiles.empty() ) { - P10, - P50, - P90 - }; + return std::unexpected( "Quantiles are empty" ); + } std::vector sortedValues = values; @@ -113,62 +225,89 @@ void RigStatisticsMath::calculateStatisticsCurves( const std::vector& va []( double x ) { return !RiaStatisticsTools::isValidNumber( x ); } ), sortedValues.end() ); - std::sort( sortedValues.begin(), sortedValues.end() ); + if ( sortedValues.empty() ) + { + return std::unexpected( "No valid values in input" ); + } - double valueSum = std::accumulate( sortedValues.begin(), sortedValues.end(), 0.0 ); + std::sort( sortedValues.begin(), sortedValues.end() ); - int valueCount = (int)sortedValues.size(); - double percentiles[] = { 0.1, 0.5, 0.9 }; - double pValues[] = { HUGE_VAL, HUGE_VAL, HUGE_VAL }; + int valueCount = (int)sortedValues.size(); + std::vector resultValues; + resultValues.reserve( quantiles.size() ); - for ( int i = P10; i <= P90; i++ ) + for ( size_t i = 0; i < quantiles.size(); ++i ) { - // Check valid params - if ( ( percentiles[i] < 1.0 / ( (double)valueCount + 1 ) ) || ( percentiles[i] > (double)valueCount / ( (double)valueCount + 1 ) ) ) - continue; - - double rank = percentiles[i] * ( valueCount + 1 ) - 1; - double rankRem; - double rankFrac = std::modf( rank, &rankRem ); - int rankInt = static_cast( rankRem ); + double quantile = quantiles[i]; - if ( rankInt < valueCount - 1 ) + if ( percentileStyle == PercentileStyle::SWITCHED ) { - pValues[i] = sortedValues[rankInt] + rankFrac * ( sortedValues[rankInt + 1] - sortedValues[rankInt] ); + quantile = 1.0 - quantile; } - else + + double value = HUGE_VAL; + + // Check valid params + if ( quantile >= 1.0 / ( static_cast( valueCount ) + 1 ) && + quantile <= static_cast( valueCount ) / ( static_cast( valueCount ) + 1 ) ) { - pValues[i] = sortedValues.back(); - } - } + double rank = quantile * ( valueCount + 1 ) - 1; + double rankRem; + double rankFrac = std::modf( rank, &rankRem ); + int rankInt = static_cast( rankRem ); - *p50 = pValues[P50]; + if ( rankInt < valueCount - 1 ) + { + value = sortedValues[rankInt] + rankFrac * ( sortedValues[rankInt + 1] - sortedValues[rankInt] ); + } + else + { + value = sortedValues.back(); + } + } - if ( percentileStyle == PercentileStyle::REGULAR ) - { - *p10 = pValues[P10]; - *p90 = pValues[P90]; - } - else - { - CVF_ASSERT( percentileStyle == PercentileStyle::SWITCHED ); - *p10 = pValues[P90]; - *p90 = pValues[P10]; + resultValues.push_back( value ); } - *mean = valueSum / valueCount; + return resultValues; } //-------------------------------------------------------------------------------------------------- /// Calculate the percentiles of /a inputValues at the pValPosition percentages using the "Nearest Rank" /// method. This method treats HUGE_VAL as "undefined" values, and ignores these. Will return HUGE_VAL if /// the inputValues does not contain any valid values +/// +/// Formula (Nearest Rank Method): +/// index = floor(n * percentile) +/// value = sorted[index] +/// +/// Note: pValPositions are expected as percentages (0-100), converted to fraction (0-1) internally +/// +/// References: +/// https://en.wikipedia.org/wiki/Percentile#The_nearest-rank_method +/// https://en.wikipedia.org/wiki/Percentile#First_variant,_C_=_1/2 //-------------------------------------------------------------------------------------------------- -std::vector RigStatisticsMath::calculateNearestRankPercentiles( const std::vector& inputValues, - const std::vector& pValPositions, - RigStatisticsMath::PercentileStyle percentileStyle ) +std::expected, std::string> + RigStatisticsMath::calculateNearestRankPercentiles( const std::vector& inputValues, + const std::vector& percentiles, + RigStatisticsMath::PercentileStyle percentileStyle ) { + if ( !areValidPercentiles( percentiles ) ) + { + return std::unexpected( "Percentiles must be in range [0-100]" ); + } + + if ( inputValues.empty() ) + { + return std::unexpected( "Input values are empty" ); + } + + if ( percentiles.empty() ) + { + return std::unexpected( "Percentiles are empty" ); + } + std::vector sortedValues; sortedValues.reserve( inputValues.size() ); @@ -180,39 +319,69 @@ std::vector RigStatisticsMath::calculateNearestRankPercentiles( const st } } + if ( sortedValues.empty() ) + { + return std::unexpected( "No valid values in input" ); + } + std::sort( sortedValues.begin(), sortedValues.end() ); - std::vector percentiles( pValPositions.size(), HUGE_VAL ); - if ( !sortedValues.empty() ) + std::vector resultValues( percentiles.size(), HUGE_VAL ); + for ( size_t i = 0; i < percentiles.size(); ++i ) { - for ( size_t i = 0; i < pValPositions.size(); ++i ) - { - double pVal = HUGE_VAL; - - double pValPosition = cvf::Math::abs( pValPositions[i] ) / 100; - if ( percentileStyle == RigStatisticsMath::PercentileStyle::SWITCHED ) pValPosition = 1.0 - pValPosition; + double quantile = cvf::Math::abs( percentiles[i] ) / 100; + if ( percentileStyle == RigStatisticsMath::PercentileStyle::SWITCHED ) quantile = 1.0 - quantile; - size_t pValIndex = static_cast( sortedValues.size() * pValPosition ); + size_t index = static_cast( sortedValues.size() * quantile ); - if ( pValIndex >= sortedValues.size() ) pValIndex = sortedValues.size() - 1; + if ( index >= sortedValues.size() ) index = sortedValues.size() - 1; - pVal = sortedValues[pValIndex]; - percentiles[i] = pVal; - } + auto value = sortedValues[index]; + resultValues[i] = value; } - return percentiles; -}; + return resultValues; +} //-------------------------------------------------------------------------------------------------- /// Calculate the percentiles of /a inputValues at the pValPosition percentages by interpolating input values. /// This method treats HUGE_VAL as "undefined" values, and ignores these. Will return HUGE_VAL if /// the inputValues does not contain any valid values +/// +/// Formula (Linear Interpolation Method): +/// doubleIndex = (n - 1) * percentile +/// lowerIndex = floor(doubleIndex) +/// upperIndex = lowerIndex + 1 +/// weight = doubleIndex - lowerIndex +/// +/// value = (1 - weight) * sorted[lowerIndex] + weight * sorted[upperIndex] +/// +/// Note: pValPositions are expected as percentages (0-100), convert to fraction (0-1) internally +/// +/// References: +/// https://en.wikipedia.org/wiki/Percentile#The_linear_interpolation_between_closest_ranks_method +/// https://en.wikipedia.org/wiki/Percentile#Second_variant,_C_=_1 //-------------------------------------------------------------------------------------------------- -std::vector RigStatisticsMath::calculateInterpolatedPercentiles( const std::vector& inputValues, - const std::vector& pValPositions, - RigStatisticsMath::PercentileStyle percentileStyle ) +std::expected, std::string> + RigStatisticsMath::calculateInterpolatedPercentiles( const std::vector& inputValues, + const std::vector& percentiles, + RigStatisticsMath::PercentileStyle percentileStyle ) { + if ( !areValidPercentiles( percentiles ) ) + { + return std::unexpected( "Percentiles must be in range [0-100]" ); + } + + if ( inputValues.empty() ) + { + return std::unexpected( "Input values are empty" ); + } + + if ( percentiles.empty() ) + { + return std::unexpected( "Percentiles are empty" ); + } + std::vector sortedValues; sortedValues.reserve( inputValues.size() ); @@ -224,39 +393,41 @@ std::vector RigStatisticsMath::calculateInterpolatedPercentiles( const s } } + if ( sortedValues.empty() ) + { + return std::unexpected( "No valid values in input" ); + } + std::sort( sortedValues.begin(), sortedValues.end() ); - std::vector percentiles( pValPositions.size(), HUGE_VAL ); - if ( !sortedValues.empty() ) + std::vector resultValues( percentiles.size(), HUGE_VAL ); + for ( size_t i = 0; i < percentiles.size(); ++i ) { - for ( size_t i = 0; i < pValPositions.size(); ++i ) - { - double pVal = HUGE_VAL; + double value = HUGE_VAL; - double pValPosition = cvf::Math::abs( pValPositions[i] ) / 100.0; - if ( percentileStyle == RigStatisticsMath::PercentileStyle::SWITCHED ) pValPosition = 1.0 - pValPosition; + double quantile = cvf::Math::abs( percentiles[i] ) / 100.0; + if ( percentileStyle == RigStatisticsMath::PercentileStyle::SWITCHED ) quantile = 1.0 - quantile; - double doubleIndex = ( sortedValues.size() - 1 ) * pValPosition; + double doubleIndex = ( sortedValues.size() - 1 ) * quantile; - size_t lowerValueIndex = static_cast( floor( doubleIndex ) ); - size_t upperValueIndex = lowerValueIndex + 1; + size_t lowerValueIndex = static_cast( floor( doubleIndex ) ); + size_t upperValueIndex = lowerValueIndex + 1; - double upperValueWeight = doubleIndex - lowerValueIndex; - assert( upperValueWeight < 1.0 ); + double upperValueWeight = doubleIndex - lowerValueIndex; + assert( upperValueWeight < 1.0 ); - if ( upperValueIndex < sortedValues.size() ) - { - pVal = ( 1.0 - upperValueWeight ) * sortedValues[lowerValueIndex] + upperValueWeight * sortedValues[upperValueIndex]; - } - else - { - pVal = sortedValues[lowerValueIndex]; - } - percentiles[i] = pVal; + if ( upperValueIndex < sortedValues.size() ) + { + value = ( 1.0 - upperValueWeight ) * sortedValues[lowerValueIndex] + upperValueWeight * sortedValues[upperValueIndex]; } + else + { + value = sortedValues[lowerValueIndex]; + } + resultValues[i] = value; } - return percentiles; + return resultValues; } //-------------------------------------------------------------------------------------------------- @@ -328,7 +499,22 @@ void RigHistogramCalculator::addData( const std::vector& data ) } //-------------------------------------------------------------------------------------------------- +/// Calculate percentile from histogram data +/// +/// Formula: +/// 1. Find cumulative count up to target: targetCount = percentile * totalObservations +/// 2. Find bin where cumulative count >= targetCount +/// 3. Interpolate within bin: +/// unusedFraction = (cumulativeCount - targetCount) / binCount +/// value = binEndValue - unusedFraction * binWidth +/// +/// Where: +/// binWidth = (max - min) / numberOfBins +/// binEndValue = min + (binIndex + 1) * binWidth /// +/// References: +/// https://en.wikipedia.org/wiki/Histogram +/// https://en.wikipedia.org/wiki/Percentile#Estimating_percentiles_from_a_histogram //-------------------------------------------------------------------------------------------------- double RigHistogramCalculator::calculatePercentil( double pVal, RigStatisticsMath::PercentileStyle percentileStyle ) { @@ -354,7 +540,7 @@ double RigHistogramCalculator::calculatePercentil( double pVal, RigStatisticsMat if ( accObsCount >= pValObservationCount ) { double domainValueAtEndOfBin = m_min + ( binIdx + 1 ) * binWidth; - double unusedFractionOfLastBin = (double)( accObsCount - pValObservationCount ) / binObsCount; + double unusedFractionOfLastBin = static_cast( accObsCount - pValObservationCount ) / binObsCount; double histogramBasedEstimate = domainValueAtEndOfBin - unusedFractionOfLastBin * binWidth; diff --git a/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.h b/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.h index f8e9355bbed..5c8ad135922 100644 --- a/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.h +++ b/ApplicationLibCode/ResultStatisticsCache/RigStatisticsMath.h @@ -21,7 +21,9 @@ #include #include +#include #include +#include #include class RigStatisticsMath @@ -42,14 +44,16 @@ class RigStatisticsMath double* p90, double* mean, PercentileStyle percentileStyle ); + static std::expected, std::string> + calculatePercentiles( const std::vector& values, const std::vector& quantiles, PercentileStyle percentileStyle ); - static std::vector calculateNearestRankPercentiles( const std::vector& inputValues, - const std::vector& pValPositions, - PercentileStyle percentileStyle ); + static std::expected, std::string> calculateNearestRankPercentiles( const std::vector& inputValues, + const std::vector& percentiles, + PercentileStyle percentileStyle ); - static std::vector calculateInterpolatedPercentiles( const std::vector& inputValues, - const std::vector& pValPositions, - PercentileStyle percentileStyle ); + static std::expected, std::string> calculateInterpolatedPercentiles( const std::vector& inputValues, + const std::vector& percentiles, + PercentileStyle percentileStyle ); }; //================================================================================================== diff --git a/ApplicationLibCode/UnitTests/RigStatisticsMath-Test.cpp b/ApplicationLibCode/UnitTests/RigStatisticsMath-Test.cpp index 360c45e25e0..6ed9419c5cf 100644 --- a/ApplicationLibCode/UnitTests/RigStatisticsMath-Test.cpp +++ b/ApplicationLibCode/UnitTests/RigStatisticsMath-Test.cpp @@ -85,18 +85,18 @@ TEST( RigStatisticsMath, RankPercentiles ) values.push_back( HUGE_VAL ); values.push_back( -57020.4389966513000 ); - std::vector pValPos; - pValPos.push_back( 10 ); - pValPos.push_back( 40 ); - pValPos.push_back( 50 ); - pValPos.push_back( 90 ); - std::vector pVals = - RigStatisticsMath::calculateNearestRankPercentiles( values, pValPos, RigStatisticsMath::PercentileStyle::REGULAR ); - - EXPECT_DOUBLE_EQ( -76092.8157632591000, pVals[0] ); - EXPECT_DOUBLE_EQ( 2788.2723335651900, pVals[1] ); - EXPECT_DOUBLE_EQ( 6391.979999097290, pVals[2] ); - EXPECT_DOUBLE_EQ( 96161.7546348456000, pVals[3] ); + std::vector resultValues; + resultValues.push_back( 10 ); + resultValues.push_back( 40 ); + resultValues.push_back( 50 ); + resultValues.push_back( 90 ); + auto pVals = RigStatisticsMath::calculateNearestRankPercentiles( values, resultValues, RigStatisticsMath::PercentileStyle::REGULAR ); + + ASSERT_TRUE( pVals.has_value() ); + EXPECT_DOUBLE_EQ( -76092.8157632591000, ( *pVals )[0] ); + EXPECT_DOUBLE_EQ( 2788.2723335651900, ( *pVals )[1] ); + EXPECT_DOUBLE_EQ( 6391.979999097290, ( *pVals )[2] ); + EXPECT_DOUBLE_EQ( 96161.7546348456000, ( *pVals )[3] ); } //-------------------------------------------------------------------------------------------------- @@ -164,18 +164,18 @@ TEST( RigStatisticsMath, InterpolatedPercentiles ) values.push_back( HUGE_VAL ); values.push_back( -57020.4389966513000 ); - std::vector pValPos; - pValPos.push_back( 10 ); - pValPos.push_back( 40 ); - pValPos.push_back( 50 ); - pValPos.push_back( 90 ); - std::vector pVals = - RigStatisticsMath::calculateInterpolatedPercentiles( values, pValPos, RigStatisticsMath::PercentileStyle::REGULAR ); - - EXPECT_DOUBLE_EQ( -72278.340409937548, pVals[0] ); - EXPECT_DOUBLE_EQ( -2265.6006907818496, pVals[1] ); - EXPECT_DOUBLE_EQ( 6391.9799990972897, pVals[2] ); - EXPECT_DOUBLE_EQ( 93073.49128098879, pVals[3] ); + std::vector resultValues; + resultValues.push_back( 10 ); + resultValues.push_back( 40 ); + resultValues.push_back( 50 ); + resultValues.push_back( 90 ); + auto pVals = RigStatisticsMath::calculateInterpolatedPercentiles( values, resultValues, RigStatisticsMath::PercentileStyle::REGULAR ); + + ASSERT_TRUE( pVals.has_value() ); + EXPECT_DOUBLE_EQ( -72278.340409937548, ( *pVals )[0] ); + EXPECT_DOUBLE_EQ( -2265.6006907818496, ( *pVals )[1] ); + EXPECT_DOUBLE_EQ( 6391.9799990972897, ( *pVals )[2] ); + EXPECT_DOUBLE_EQ( 93073.49128098879, ( *pVals )[3] ); } //-------------------------------------------------------------------------------------------------- diff --git a/GrpcInterface/CMakeLists.txt b/GrpcInterface/CMakeLists.txt index 8c2bdb3a358..af0a1f1dafa 100644 --- a/GrpcInterface/CMakeLists.txt +++ b/GrpcInterface/CMakeLists.txt @@ -277,4 +277,12 @@ endif() if(RESINSIGHT_GRPC_PYTHON_EXECUTABLE) install(DIRECTORY ${GRPC_PYTHON_SOURCE_PATH}/ DESTINATION ${RESINSIGHT_INSTALL_FOLDER}/Python) + + # Install Sumo Explorer FastAPI server + install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/Python/sumo_explorer_server/ + DESTINATION ${RESINSIGHT_INSTALL_FOLDER}/Python/sumo_explorer_server + FILES_MATCHING + PATTERN "*.py" + PATTERN "*.txt" + PATTERN "*.md") endif() diff --git a/GrpcInterface/Python/sumo_explorer_server/README.md b/GrpcInterface/Python/sumo_explorer_server/README.md new file mode 100644 index 00000000000..c46101b4ccf --- /dev/null +++ b/GrpcInterface/Python/sumo_explorer_server/README.md @@ -0,0 +1,51 @@ +# Sumo Explorer FastAPI Server + +## Overview + +This server provides a REST API wrapper around the Sumo Explorer Python API, enabling ResInsight's C++ code to interact with Sumo Cloud data without implementing OAuth2 authentication in C++. + +## Architecture + +``` +ResInsight (C++) → RiaSumoExplorerConnector → FastAPI Server → Sumo Explorer API → Sumo Cloud +``` + +## Installation + +```bash +pip install -r requirements.txt +``` + +## Running the Server + +```bash +python -m uvicorn sumo_explorer_server.sumo_explorer_server:app --host 127.0.0.1 --port 54527 +``` + +Or use the launcher script: + +```bash +python sumo_explorer_server.py --port 54527 +``` + +## API Endpoints + +- `GET /health` - Health check +- `GET /status` - Sumo connection status +- `GET /assets` - List available assets (fields) +- `GET /cases/{field_name}` - Get cases for a specific field +- `GET /ensembles/{case_id}` - Get ensembles for a case +- `GET /summary/vectors?case_id=X&ensemble=Y` - Get available vector names +- `GET /summary/realizations?case_id=X&ensemble=Y` - Get realization IDs +- `GET /summary/data?case_id=X&ensemble=Y&vector=Z` - Get summary data (Base64-encoded parquet) +- `GET /summary/parameters?case_id=X&ensemble=Y` - Get parameters (Base64-encoded parquet) + +## Configuration + +The server uses the Sumo Explorer API with default authentication. Ensure you have valid Sumo credentials configured in your environment. + +## Security + +- Server binds to 127.0.0.1 only (localhost) +- Not exposed to external networks +- Authentication handled by Sumo Explorer API diff --git a/GrpcInterface/Python/sumo_explorer_server/__init__.py b/GrpcInterface/Python/sumo_explorer_server/__init__.py new file mode 100644 index 00000000000..afdc054084f --- /dev/null +++ b/GrpcInterface/Python/sumo_explorer_server/__init__.py @@ -0,0 +1,8 @@ +""" +Sumo Explorer FastAPI Server + +This package provides a FastAPI server that wraps the Sumo Explorer API +for use by ResInsight's C++ connector. +""" + +__version__ = "1.0.0" diff --git a/GrpcInterface/Python/sumo_explorer_server/models.py b/GrpcInterface/Python/sumo_explorer_server/models.py new file mode 100644 index 00000000000..d97bf034985 --- /dev/null +++ b/GrpcInterface/Python/sumo_explorer_server/models.py @@ -0,0 +1,80 @@ +""" +Pydantic models for Sumo Explorer API responses +""" + +from pydantic import BaseModel, Field +from typing import Optional + + +class Asset(BaseModel): + """Represents a Sumo asset (field)""" + + asset_id: str = Field(..., description="Unique identifier for the asset") + kind: str = Field(..., description="Type of asset") + name: str = Field(..., description="Display name of the asset") + + +class Case(BaseModel): + """Represents a Sumo case""" + + case_id: str = Field(..., description="Unique identifier for the case") + kind: str = Field(..., description="Type of case") + name: str = Field(..., description="Display name of the case") + asset_id: Optional[str] = Field(None, description="Parent asset ID") + + +class Ensemble(BaseModel): + """Represents a Sumo ensemble""" + + ensemble_name: str = Field(..., description="Name of the ensemble") + case_id: str = Field(..., description="Parent case ID") + + +class VectorInfo(BaseModel): + """Information about an available summary vector""" + + name: str = Field(..., description="Vector name (e.g., WOPR:OP1)") + + +class RealizationInfo(BaseModel): + """Information about a realization""" + + realization_id: int = Field(..., description="Realization number") + + +class SummaryDataResponse(BaseModel): + """Response containing summary data as Base64-encoded parquet""" + + case_id: str = Field(..., description="Case identifier") + ensemble_name: str = Field(..., description="Ensemble name") + vector_name: Optional[str] = Field( + None, description="Vector name (if single vector)" + ) + data_base64: str = Field(..., description="Base64-encoded parquet data") + row_count: int = Field(..., description="Number of rows in the data") + + +class ParametersResponse(BaseModel): + """Response containing parameter data as Base64-encoded parquet""" + + case_id: str = Field(..., description="Case identifier") + ensemble_name: str = Field(..., description="Ensemble name") + data_base64: str = Field(..., description="Base64-encoded parquet data") + row_count: int = Field(..., description="Number of rows in the data") + + +class HealthResponse(BaseModel): + """Health check response""" + + status: str = Field(..., description="Server status") + version: str = Field(..., description="Server version") + + +class StatusResponse(BaseModel): + """Sumo connection status response""" + + connected: bool = Field(..., description="Whether connected to Sumo") + environment: Optional[str] = Field( + None, description="Sumo environment (e.g., 'prod')" + ) + error: Optional[str] = Field(None, description="Error message if not connected") diff --git a/GrpcInterface/Python/sumo_explorer_server/requirements.txt b/GrpcInterface/Python/sumo_explorer_server/requirements.txt new file mode 100644 index 00000000000..465ba054438 --- /dev/null +++ b/GrpcInterface/Python/sumo_explorer_server/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +fmu-sumo>=1.0.0 +pydantic>=2.0.0 +pandas>=2.0.0 +pyarrow>=14.0.0 diff --git a/GrpcInterface/Python/sumo_explorer_server/sumo_client.py b/GrpcInterface/Python/sumo_explorer_server/sumo_client.py new file mode 100644 index 00000000000..f08f831d283 --- /dev/null +++ b/GrpcInterface/Python/sumo_explorer_server/sumo_client.py @@ -0,0 +1,352 @@ +""" +Sumo Explorer client wrapper + +Wraps the fmu.sumo.explorer.Explorer API for easier access from FastAPI. +""" + +import logging +from typing import List, Optional +import pandas as pd +import io + +try: + from fmu.sumo.explorer import Explorer +except ImportError: + Explorer = None + +from .models import Asset, Case, Ensemble, VectorInfo, RealizationInfo + +logger = logging.getLogger(__name__) + + +class SumoClientWrapper: + """Wrapper around Sumo Explorer API""" + + def __init__(self, environment: str = "prod"): + """ + Initialize Sumo Explorer client + + Args: + environment: Sumo environment to connect to (default: "prod") + """ + self.environment = environment + self._explorer: Optional[Explorer] = None + self._connected = False + self._error: Optional[str] = None + + if Explorer is None: + self._error = "fmu-sumo package not installed" + logger.error(self._error) + return + + try: + self._explorer = Explorer(env=environment) + self._connected = True + logger.info(f"Connected to Sumo environment: {environment}") + except Exception as e: + self._error = f"Failed to connect to Sumo: {str(e)}" + logger.error(self._error) + + @property + def is_connected(self) -> bool: + """Check if connected to Sumo""" + return self._connected + + @property + def error(self) -> Optional[str]: + """Get connection error if any""" + return self._error + + def get_assets(self) -> List[Asset]: + """ + Get list of available assets (fields) + + Returns: + List of Asset objects + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return [] + + try: + # Get all cases and extract unique assets + cases = self._explorer.cases + assets_dict = {} + + for case in cases: + asset_name = getattr(case, "name", "Unknown") + asset_id = getattr(case, "uuid", asset_name) + + if asset_id not in assets_dict: + assets_dict[asset_id] = Asset( + asset_id=asset_id, kind="field", name=asset_name + ) + + return list(assets_dict.values()) + except Exception as e: + logger.error(f"Failed to get assets: {e}") + return [] + + def get_cases(self, field_name: str) -> List[Case]: + """ + Get cases for a specific field + + Args: + field_name: Name of the field/asset + + Returns: + List of Case objects + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return [] + + try: + cases = self._explorer.cases + result = [] + + for case in cases: + case_name = getattr(case, "name", "Unknown") + case_id = getattr(case, "uuid", "") + + # Filter by field name if specified + if field_name and case_name != field_name: + continue + + result.append( + Case( + case_id=case_id, + kind="case", + name=case_name, + asset_id=field_name, + ) + ) + + return result + except Exception as e: + logger.error(f"Failed to get cases for field {field_name}: {e}") + return [] + + def get_ensembles(self, case_id: str) -> List[Ensemble]: + """ + Get ensembles for a case + + Args: + case_id: Case identifier + + Returns: + List of Ensemble objects + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return [] + + try: + # Get case + case = self._explorer.get_case_by_uuid(case_id) + if not case: + logger.warning(f"Case not found: {case_id}") + return [] + + # Get iterations (ensembles) + iterations = case.iterations + result = [] + + for iteration in iterations: + ensemble_name = iteration.get("name", "Unknown") + result.append(Ensemble(ensemble_name=ensemble_name, case_id=case_id)) + + return result + except Exception as e: + logger.error(f"Failed to get ensembles for case {case_id}: {e}") + return [] + + def get_vector_names(self, case_id: str, ensemble_name: str) -> List[VectorInfo]: + """ + Get available vector names for a case/ensemble + + Args: + case_id: Case identifier + ensemble_name: Ensemble name + + Returns: + List of VectorInfo objects + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return [] + + try: + # Get case and iteration + case = self._explorer.get_case_by_uuid(case_id) + if not case: + logger.warning(f"Case not found: {case_id}") + return [] + + iteration = case.get_iteration_by_name(ensemble_name) + if not iteration: + logger.warning(f"Ensemble not found: {ensemble_name}") + return [] + + # Get summary vectors + summaries = iteration.get_summaries() + vector_names = set() + + for summary in summaries: + columns = summary.columns + for col in columns: + if col != "DATE" and col != "TIME": + vector_names.add(col) + + return [VectorInfo(name=name) for name in sorted(vector_names)] + except Exception as e: + logger.error( + f"Failed to get vectors for case {case_id}, ensemble {ensemble_name}: {e}" + ) + return [] + + def get_realizations( + self, case_id: str, ensemble_name: str + ) -> List[RealizationInfo]: + """ + Get realization IDs for a case/ensemble + + Args: + case_id: Case identifier + ensemble_name: Ensemble name + + Returns: + List of RealizationInfo objects + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return [] + + try: + # Get case and iteration + case = self._explorer.get_case_by_uuid(case_id) + if not case: + logger.warning(f"Case not found: {case_id}") + return [] + + iteration = case.get_iteration_by_name(ensemble_name) + if not iteration: + logger.warning(f"Ensemble not found: {ensemble_name}") + return [] + + # Get realizations + realizations = iteration.realizations + return [RealizationInfo(realization_id=real_id) for real_id in realizations] + except Exception as e: + logger.error( + f"Failed to get realizations for case {case_id}, ensemble {ensemble_name}: {e}" + ) + return [] + + def get_summary_data( + self, case_id: str, ensemble_name: str, vector_name: str + ) -> bytes: + """ + Get summary data as parquet bytes + + Args: + case_id: Case identifier + ensemble_name: Ensemble name + vector_name: Vector name + + Returns: + Parquet data as bytes + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return b"" + + try: + # Get case and iteration + case = self._explorer.get_case_by_uuid(case_id) + if not case: + logger.warning(f"Case not found: {case_id}") + return b"" + + iteration = case.get_iteration_by_name(ensemble_name) + if not iteration: + logger.warning(f"Ensemble not found: {ensemble_name}") + return b"" + + # Get summary data + summaries = iteration.get_summaries() + if not summaries: + logger.warning("No summaries found") + return b"" + + # Combine data from all realizations + all_data = [] + for summary in summaries: + df = summary.to_pandas() + if vector_name in df.columns: + # Add realization column + df["REAL"] = summary.realization + # Select relevant columns + df = df[["DATE", "REAL", vector_name]] + all_data.append(df) + + if not all_data: + logger.warning(f"No data found for vector {vector_name}") + return b"" + + # Combine all dataframes + combined_df = pd.concat(all_data, ignore_index=True) + + # Convert to parquet + buffer = io.BytesIO() + combined_df.to_parquet(buffer, index=False) + return buffer.getvalue() + except Exception as e: + logger.error( + f"Failed to get summary data for {case_id}/{ensemble_name}/{vector_name}: {e}" + ) + return b"" + + def get_parameters(self, case_id: str, ensemble_name: str) -> bytes: + """ + Get parameter data as parquet bytes + + Args: + case_id: Case identifier + ensemble_name: Ensemble name + + Returns: + Parquet data as bytes + """ + if not self._connected or self._explorer is None: + logger.warning("Not connected to Sumo") + return b"" + + try: + # Get case and iteration + case = self._explorer.get_case_by_uuid(case_id) + if not case: + logger.warning(f"Case not found: {case_id}") + return b"" + + iteration = case.get_iteration_by_name(ensemble_name) + if not iteration: + logger.warning(f"Ensemble not found: {ensemble_name}") + return b"" + + # Get parameters + parameters = iteration.get_parameters() + if not parameters: + logger.warning("No parameters found") + return b"" + + # Convert to pandas DataFrame + df = parameters.to_pandas() + + # Convert to parquet + buffer = io.BytesIO() + df.to_parquet(buffer, index=False) + return buffer.getvalue() + except Exception as e: + logger.error(f"Failed to get parameters for {case_id}/{ensemble_name}: {e}") + return b"" diff --git a/GrpcInterface/Python/sumo_explorer_server/sumo_explorer_server.py b/GrpcInterface/Python/sumo_explorer_server/sumo_explorer_server.py new file mode 100644 index 00000000000..672e6559035 --- /dev/null +++ b/GrpcInterface/Python/sumo_explorer_server/sumo_explorer_server.py @@ -0,0 +1,260 @@ +""" +Sumo Explorer FastAPI Server + +Provides REST API wrapper around Sumo Explorer for ResInsight C++ connector. +""" + +import base64 +import logging +import argparse +from contextlib import asynccontextmanager +from typing import List, Optional + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse +import uvicorn + +from .models import ( + Asset, + Case, + Ensemble, + VectorInfo, + RealizationInfo, + SummaryDataResponse, + ParametersResponse, + HealthResponse, + StatusResponse, +) +from .sumo_client import SumoClientWrapper + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global Sumo client +sumo_client: Optional[SumoClientWrapper] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI lifespan context manager""" + global sumo_client + + # Startup + logger.info("Starting Sumo Explorer server...") + sumo_client = SumoClientWrapper(environment="prod") + if sumo_client.is_connected: + logger.info("Successfully connected to Sumo") + else: + logger.error(f"Failed to connect to Sumo: {sumo_client.error}") + + yield + + # Shutdown + logger.info("Shutting down Sumo Explorer server...") + sumo_client = None + + +app = FastAPI( + title="Sumo Explorer API", + description="REST API wrapper for Sumo Explorer", + version="1.0.0", + lifespan=lifespan, +) + + +@app.get("/health", response_model=HealthResponse) +async def health_check(): + """Health check endpoint""" + return HealthResponse(status="healthy", version="1.0.0") + + +@app.get("/status", response_model=StatusResponse) +async def status(): + """Get Sumo connection status""" + if sumo_client is None: + return StatusResponse( + connected=False, environment=None, error="Sumo client not initialized" + ) + + return StatusResponse( + connected=sumo_client.is_connected, + environment=sumo_client.environment if sumo_client.is_connected else None, + error=sumo_client.error, + ) + + +@app.get("/assets", response_model=List[Asset]) +async def get_assets(): + """Get list of available assets (fields)""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + assets = sumo_client.get_assets() + return assets + except Exception as e: + logger.error(f"Failed to get assets: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/cases/{field_name}", response_model=List[Case]) +async def get_cases(field_name: str): + """Get cases for a specific field""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + cases = sumo_client.get_cases(field_name) + return cases + except Exception as e: + logger.error(f"Failed to get cases for field {field_name}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/ensembles/{case_id}", response_model=List[Ensemble]) +async def get_ensembles(case_id: str): + """Get ensembles for a case""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + ensembles = sumo_client.get_ensembles(case_id) + return ensembles + except Exception as e: + logger.error(f"Failed to get ensembles for case {case_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/summary/vectors", response_model=List[VectorInfo]) +async def get_vector_names( + case_id: str = Query(..., description="Case ID"), + ensemble: str = Query(..., description="Ensemble name"), +): + """Get available vector names for a case/ensemble""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + vectors = sumo_client.get_vector_names(case_id, ensemble) + return vectors + except Exception as e: + logger.error(f"Failed to get vectors for {case_id}/{ensemble}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/summary/realizations", response_model=List[RealizationInfo]) +async def get_realizations( + case_id: str = Query(..., description="Case ID"), + ensemble: str = Query(..., description="Ensemble name"), +): + """Get realization IDs for a case/ensemble""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + realizations = sumo_client.get_realizations(case_id, ensemble) + return realizations + except Exception as e: + logger.error(f"Failed to get realizations for {case_id}/{ensemble}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/summary/data", response_model=SummaryDataResponse) +async def get_summary_data( + case_id: str = Query(..., description="Case ID"), + ensemble: str = Query(..., description="Ensemble name"), + vector: str = Query(..., description="Vector name"), +): + """Get summary data as Base64-encoded parquet""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + # Get parquet data + parquet_bytes = sumo_client.get_summary_data(case_id, ensemble, vector) + + if not parquet_bytes: + raise HTTPException( + status_code=404, detail=f"No data found for vector {vector}" + ) + + # Encode as Base64 + data_base64 = base64.b64encode(parquet_bytes).decode("utf-8") + + # Estimate row count (parquet metadata would be better, but this is simpler) + row_count = len(parquet_bytes) // 100 # Rough estimate + + return SummaryDataResponse( + case_id=case_id, + ensemble_name=ensemble, + vector_name=vector, + data_base64=data_base64, + row_count=row_count, + ) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to get summary data for {case_id}/{ensemble}/{vector}: {e}" + ) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/summary/parameters", response_model=ParametersResponse) +async def get_parameters( + case_id: str = Query(..., description="Case ID"), + ensemble: str = Query(..., description="Ensemble name"), +): + """Get parameter data as Base64-encoded parquet""" + if sumo_client is None or not sumo_client.is_connected: + raise HTTPException(status_code=503, detail="Not connected to Sumo") + + try: + # Get parquet data + parquet_bytes = sumo_client.get_parameters(case_id, ensemble) + + if not parquet_bytes: + raise HTTPException(status_code=404, detail="No parameters found") + + # Encode as Base64 + data_base64 = base64.b64encode(parquet_bytes).decode("utf-8") + + # Estimate row count + row_count = len(parquet_bytes) // 100 # Rough estimate + + return ParametersResponse( + case_id=case_id, + ensemble_name=ensemble, + data_base64=data_base64, + row_count=row_count, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get parameters for {case_id}/{ensemble}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser(description="Sumo Explorer FastAPI Server") + parser.add_argument( + "--port", type=int, default=54527, help="Port to listen on (default: 54527)" + ) + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Host to bind to (default: 127.0.0.1)", + ) + args = parser.parse_args() + + logger.info(f"Starting server on {args.host}:{args.port}") + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/docs/plans/sumo-explorer-integration.md b/docs/plans/sumo-explorer-integration.md new file mode 100644 index 00000000000..f581acc98a1 --- /dev/null +++ b/docs/plans/sumo-explorer-integration.md @@ -0,0 +1,376 @@ +# Integration Plan: RiaSumoExplorerConnector in RimCloudDataSourceCollection + +## Overview + +Make data from the Python-based `RiaSumoExplorerConnector` available in the existing `RimCloudDataSourceCollection` UI, allowing users to choose between OAuth2-based (`RiaSumoConnector`) and Python Explorer-based (`RiaSumoExplorerConnector`) access to Sumo cloud data. + +## Background + +Currently, `RimCloudDataSourceCollection` only supports `RiaSumoConnector` (OAuth2 REST API). The newer `RiaSumoExplorerConnector` wraps the Python `fmu.sumo.explorer` API via a local FastAPI server. Both connectors provide identical hierarchical data (Assets → Cases → Ensembles → Vectors/Realizations) with near-identical APIs. + +## Approach: Preference-Based Connector Selection with Type Conversion + +**Key Design Decisions:** +1. **Reuse existing data structures** - `RimSummarySumoDataSource` works for both connectors +2. **User preference controls connector choice** - Add `useSumoExplorerForCloudData` boolean to preferences +3. **Type conversion layer** - Convert Explorer types (SumoExplorerAsset) to OAuth types (SumoAsset) since they have identical fields +4. **Minimal code changes** - Use conditional logic based on preference rather than full abstraction layer +5. **Backward compatible** - Default to OAuth2 connector, existing projects continue to work + +## Implementation Steps + +### 1. Add Type Conversion Utilities + +**File:** `ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.h` +**File:** `ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.cpp` + +Add conversion functions to map Explorer types to OAuth types: + +```cpp +// In RiaSumoExplorerDefines namespace +SumoAsset toSumoAsset(const SumoExplorerAsset& asset); +SumoCase toSumoCase(const SumoExplorerCase& explorerCase); +SumoCaseId toSumoCaseId(const QString& caseId); +QString fromSumoCaseId(const SumoCaseId& caseId); +``` + +These simply copy field values since the structures are identical except for naming. + +### 2. Update Preferences + +**File:** `ApplicationLibCode/Application/RiaPreferencesSumoExplorer.h` +**File:** `ApplicationLibCode/Application/RiaPreferencesSumoExplorer.cpp` + +Add preference field to choose connector type: + +```cpp +caf::PdmField m_useSumoExplorerForCloudData; +bool useSumoExplorerForCloudData() const; +``` + +Initialize to `false` (default to OAuth2 for backward compatibility). Add to UI with description explaining the choice. + +### 3. Update RimCloudDataSourceCollection + +**File:** `ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h` +**File:** `ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp` + +#### 3a. Add Support for Both Connectors + +Add member variables: +```cpp +QPointer m_sumoConnector; // existing +QPointer m_sumoExplorerConnector; // new +bool m_useExplorerConnector; // cache preference value +``` + +#### 3b. Initialize Both Connectors in Constructor + +```cpp +RimCloudDataSourceCollection::RimCloudDataSourceCollection() +{ + // ... existing field initialization ... + + auto prefs = RiaApplication::instance()->preferences(); + m_useExplorerConnector = prefs->sumoExplorerPreferences()->useSumoExplorerForCloudData(); + + if (m_useExplorerConnector) { + m_sumoExplorerConnector = RiaApplication::instance()->makeSumoExplorerConnector(); + } else { + m_sumoConnector = RiaApplication::instance()->makeSumoConnector(); + } +} +``` + +#### 3c. Update Authentication Logic in `fieldChangedByUi()` + +Handle authentication differently based on connector type: + +```cpp +if (changedField == &m_authenticate) +{ + if (m_useExplorerConnector && m_sumoExplorerConnector) { + // Start server if not running + if (!m_sumoExplorerConnector->isServerRunning()) { + m_sumoExplorerConnector->startServer(); + m_sumoExplorerConnector->waitForServerReady(); + } + } else if (m_sumoConnector) { + m_sumoConnector->requestTokenWithCancelButton(); + } + m_authenticate = false; +} +``` + +#### 3d. Update `calculateValueOptions()` + +Add conditional logic to use appropriate connector: + +```cpp +QList RimCloudDataSourceCollection::calculateValueOptions(...) +{ + // Check which connector is active and granted + bool isGranted = false; + if (m_useExplorerConnector && m_sumoExplorerConnector) { + isGranted = m_sumoExplorerConnector->isServerRunning(); + } else if (m_sumoConnector) { + isGranted = m_sumoConnector->isGranted(); + } + + if (!isGranted) return {}; + + QList options; + + if (fieldNeedingOptions == &m_sumoFieldName) + { + if (m_useExplorerConnector) { + // Use explorer connector + if (m_sumoExplorerConnector->assets().empty()) { + m_sumoExplorerConnector->requestAssetsBlocking(); + } + for (const auto& asset : m_sumoExplorerConnector->assets()) { + if (m_sumoFieldName().isEmpty()) m_sumoFieldName = asset.name; + options.push_back({asset.name, asset.name}); + } + } else { + // Use OAuth connector (existing code) + if (m_sumoConnector->assets().empty()) { + m_sumoConnector->requestAssetsBlocking(); + } + for (const auto& asset : m_sumoConnector->assets()) { + if (m_sumoFieldName().isEmpty()) m_sumoFieldName = asset.name; + options.push_back({asset.name, asset.name}); + } + } + } + else if (fieldNeedingOptions == &m_sumoCaseId && !m_sumoFieldName().isEmpty()) + { + if (m_useExplorerConnector) { + // Use explorer connector + if (m_sumoExplorerConnector->cases().empty()) { + m_sumoExplorerConnector->requestCasesForFieldBlocking(m_sumoFieldName); + } + for (const auto& explorerCase : m_sumoExplorerConnector->cases()) { + options.push_back({explorerCase.name, explorerCase.caseId}); + } + } else { + // Use OAuth connector (existing code) + // ... existing code ... + } + } + else if (fieldNeedingOptions == &m_sumoEnsembleNames && !m_sumoCaseId().isEmpty()) + { + if (m_useExplorerConnector) { + // Use explorer connector + if (m_sumoExplorerConnector->ensembles().empty()) { + m_sumoExplorerConnector->requestEnsemblesForCaseBlocking(m_sumoCaseId); + } + for (const auto& ensemble : m_sumoExplorerConnector->ensembles()) { + options.push_back({ensemble.ensembleName, ensemble.ensembleName}); + } + } else { + // Use OAuth connector (existing code) + // ... existing code ... + } + } + + return options; +} +``` + +#### 3e. Update `defineUiOrdering()` + +Update authentication status message: + +```cpp +bool isGranted = false; +if (m_useExplorerConnector && m_sumoExplorerConnector) { + isGranted = m_sumoExplorerConnector->isServerRunning(); +} else if (m_sumoConnector) { + isGranted = m_sumoConnector->isGranted(); +} + +QString statusType = m_useExplorerConnector ? "Server Status" : "Authentication Status"; +QString text = statusType + ": "; +text += isGranted ? "✔ Granted" : "❌ Not Granted"; +``` + +#### 3f. Update `addDataSources()` + +Handle data fetching from both connectors: + +```cpp +std::vector RimCloudDataSourceCollection::addDataSources() +{ + std::vector dataSources; + auto sumoCaseId = SumoCaseId(m_sumoCaseId); + + for (const auto& ensembleName : m_sumoEnsembleNames()) + { + // Check for duplicates (existing code) + bool createNewDataSource = true; + for (const auto dataSource : sumoDataSources()) { + if (dataSource->caseId() == sumoCaseId && dataSource->ensembleName() == ensembleName) { + createNewDataSource = false; + break; + } + } + if (!createNewDataSource) continue; + + // Get case name + QString caseName; + if (m_useExplorerConnector) { + for (const auto& explorerCase : m_sumoExplorerConnector->cases()) { + if (explorerCase.caseId == m_sumoCaseId()) { + caseName = explorerCase.name; + break; + } + } + } else { + for (const auto& sumoCase : m_sumoConnector->cases()) { + if (sumoCase.caseId == sumoCaseId) { + caseName = sumoCase.name; + break; + } + } + } + + // Request metadata + std::vector realizationIds; + std::vector vectorNames; + + if (m_useExplorerConnector) { + m_sumoExplorerConnector->requestRealizationIdsBlocking(m_sumoCaseId(), ensembleName); + m_sumoExplorerConnector->requestVectorNamesBlocking(m_sumoCaseId(), ensembleName); + + // Convert Explorer types to OAuth types + for (const auto& r : m_sumoExplorerConnector->realizationIds()) { + realizationIds.push_back(QString::number(r.realizationId)); + } + for (const auto& v : m_sumoExplorerConnector->vectorNames()) { + vectorNames.push_back(v.name); + } + } else { + m_sumoConnector->requestRealizationIdsForEnsembleBlocking(sumoCaseId, ensembleName); + m_sumoConnector->requestVectorNamesForEnsembleBlocking(sumoCaseId, ensembleName); + realizationIds = m_sumoConnector->realizationIds(); + vectorNames = m_sumoConnector->vectorNames(); + } + + // Create data source (existing code pattern) + auto dataSource = new RimSummarySumoDataSource(); + dataSource->setCaseId(sumoCaseId); + dataSource->setCaseName(caseName); + dataSource->setEnsembleName(ensembleName); + dataSource->setRealizationIds(realizationIds); + dataSource->setVectorNames(vectorNames); + dataSource->setConnectorType(m_useExplorerConnector ? "Explorer" : "OAuth2"); + dataSource->updateName(); + + m_sumoDataSources.push_back(dataSource); + dataSources.push_back(dataSource); + } + + // ... existing UI update code ... + return dataSources; +} +``` + +### 4. Track Connector Type in Data Source + +**File:** `ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.h` +**File:** `ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.cpp` + +Add field to track which connector created the data source: + +```cpp +caf::PdmField m_connectorType; // "OAuth2" or "Explorer" +QString connectorType() const; +void setConnectorType(const QString& type); +``` + +This allows `RimSummaryEnsembleSumo` to know which connector to use for loading parquet data. + +### 5. Update Ensemble Data Loading + +**File:** `ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.cpp` + +Update data loading methods to support both connectors: + +```cpp +QByteArray RimSummaryEnsembleSumo::requestParquetDataBlocking(...) +{ + if (m_sumoDataSource && m_sumoDataSource->connectorType() == "Explorer") { + auto connector = RiaApplication::instance()->makeSumoExplorerConnector(); + if (connector && connector->isServerRunning()) { + QString caseIdStr = m_sumoDataSource->caseId().get(); + return connector->requestSummaryDataBlocking(caseIdStr, ensembleId, vectorName); + } + } else { + // Use OAuth connector (existing code) + return m_sumoConnector->requestParquetDataBlocking(...); + } +} +``` + +Similar updates for parameter loading. + +## Critical Files to Modify + +1. **ApplicationLibCode/Application/Tools/Cloud/RiaSumoExplorerDefines.h/cpp** - Type conversion functions +2. **ApplicationLibCode/Application/RiaPreferencesSumoExplorer.h/cpp** - Add connector selection preference +3. **ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h/cpp** - Main integration logic +4. **ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummarySumoDataSource.h/cpp** - Track connector type +5. **ApplicationLibCode/ProjectDataModel/Summary/Sumo/RimSummaryEnsembleSumo.cpp** - Support both connectors for data loading + +## Verification Steps + +1. **Test OAuth2 Flow (Existing Functionality)** + - Launch ResInsight with `useSumoExplorerForCloudData = false` (default) + - Navigate to Cloud Data collection + - Click Authenticate → verify OAuth2 flow works + - Select Field/Case/Ensemble → verify dropdowns populate + - Add data source → verify it appears in project tree + - Create ensemble → verify data loads correctly + +2. **Test Explorer Flow (New Functionality)** + - Set preference `useSumoExplorerForCloudData = true` + - Restart ResInsight (or reload preferences) + - Navigate to Cloud Data collection + - Click Authenticate → verify Python server starts + - Verify status shows "Server Status: ✔ Granted" + - Select Field/Case/Ensemble → verify dropdowns populate from Explorer + - Add data source → verify it appears with connectorType="Explorer" + - Create ensemble → verify data loads from Explorer connector + +3. **Test Server Auto-Start** + - With `autoStartServer = true` in preferences + - Verify server starts automatically on application launch + - Verify Cloud Data collection can use server immediately + +4. **Test Error Handling** + - Explorer connector: Stop Python, verify error message shown + - OAuth2 connector: Deny authentication, verify appropriate message + - Verify switching between connectors via preferences works + +5. **Test Backward Compatibility** + - Open existing project files created with OAuth2 connector + - Verify they load correctly regardless of current preference + - Verify ensembles still load data correctly + +## Benefits + +- **User choice** - Select between OAuth2 (direct API) and Python Explorer (SDK-based) access +- **Minimal changes** - Reuses existing data structures and UI +- **Backward compatible** - Defaults to OAuth2, existing functionality preserved +- **Future-proof** - Easy to add more connector types or deprecate old ones +- **Clean separation** - Type conversion isolated in dedicated functions + +## Risks and Mitigation + +| Risk | Mitigation | +|------|------------| +| Explorer server fails to start | Check server status, show clear error message with troubleshooting steps | +| Type conversion introduces bugs | Create unit tests for conversion functions, validate field mapping | +| Performance differences between connectors | Both use blocking HTTP, similar performance; monitor and optimize if needed | +| Project file compatibility | Store only connector type string, both access same backend data |