Skip to content

Add support for custom percentile levels#654

Open
magnesj wants to merge 13 commits intodevfrom
custom-percentiles
Open

Add support for custom percentile levels#654
magnesj wants to merge 13 commits intodevfrom
custom-percentiles

Conversation

@magnesj
Copy link
Owner

@magnesj magnesj commented Feb 5, 2026

PR Type

Enhancement


Description

  • Add custom percentile support to ensemble statistics calculations

  • Allow users to specify arbitrary percentiles via text input field

  • Display custom percentiles with "P[value]" format in UI text

  • Extend hash function to support range types for cache validation


Diagram Walkthrough

flowchart LR
  A["User Input<br/>Custom Percentiles"] -->|"Parse Range<br/>e.g. 5,25,75,95"| B["RimEnsembleStatistics<br/>allPercentiles()"]
  B -->|"Pass to Calculate"| C["RimEnsembleStatisticsCase<br/>calculate()"]
  C -->|"Compute Values"| D["RigStatisticsMath<br/>calculateInterpolatedPercentiles()"]
  D -->|"Store in Map"| E["m_percentileData<br/>int to vector"]
  E -->|"Create Curves"| F["RimEnsembleCurveSet<br/>updateStatisticsCurves()"]
  F -->|"Format as P[n]"| G["RifEclipseSummaryAddress<br/>uiText()"]
  G -->|"Display"| H["UI Visualization"]
Loading

File Walkthrough

Relevant files
Enhancement
18 files
RifEclipseSummaryAddress.h
Add percentile field and accessor methods                               
+7/-0     
RifEclipseSummaryAddress.cpp
Initialize percentile field and implement getter/setter   
+39/-2   
RifEclipseSummaryAddressDefines.h
Add CUSTOM statistics type enum value                                       
+2/-1     
RifEclipseSummaryAddressDefines.cpp
Register CUSTOM statistics type in AppEnum                             
+1/-0     
RimEnsembleStatistics.h
Add custom percentiles field and getter methods                   
+7/-0     
RimEnsembleStatistics.cpp
Implement custom percentile parsing and UI integration     
+67/-0   
RimEnsembleStatisticsCase.h
Add percentile data storage and calculation support           
+14/-6   
RimEnsembleStatisticsCase.cpp
Calculate custom percentiles and store in map structure   
+57/-2   
RimEnsembleCurveSet.h
Add hasPercentileData method to interface                               
+1/-0     
RimEnsembleCurveSet.cpp
Generate curves for custom percentiles with deduplication
+47/-3   
RimEnsembleCrossPlotStatisticsCase.h
Add hasPercentileData stub method                                               
+1/-0     
RimEnsembleCrossPlotStatisticsCase.cpp
Implement hasPercentileData returning false for cross-plots
+9/-0     
RimSummaryAddress.h
Add percentile field to address persistence                           
+1/-0     
RimSummaryAddress.cpp
Persist and restore percentile in address conversion         
+21/-17 
RimEnsembleWellLogCurveSet.h
Add hasPercentileData method to interface                               
+1/-0     
RimEnsembleWellLogCurveSet.cpp
Implement hasPercentileData returning false for well logs
+9/-0     
RimEnsembleCurveSetInterface.h
Add hasPercentileData abstract method to interface             
+5/-4     
RiaHashTools.h
Add range type support to hash combining function               
+17/-0   

@qodo-code-review
Copy link

qodo-code-review bot commented Feb 5, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Range expansion DoS

Description: User-controlled m_customPercentiles is parsed via
RiaStdStringTools::valuesFromRangeSelection(...) (line 182), which may expand
large/degenerate range inputs (e.g., 0-1000000000) into a huge set before the later
[0,100] filter, potentially enabling a CPU/memory denial-of-service via the UI input
field.
RimEnsembleStatistics.cpp [170-201]

Referred Code
std::vector<int> RimEnsembleStatistics::allPercentiles() const
{
    std::vector<int> percentiles;

    // Add standard percentiles from checkboxes
    if ( m_showP10Curve ) percentiles.push_back( 10 );
    if ( m_showP50Curve ) percentiles.push_back( 50 );
    if ( m_showP90Curve ) percentiles.push_back( 90 );

    // Parse custom percentiles string
    if ( !m_customPercentiles().isEmpty() )
    {
        std::set<int> customValues = RiaStdStringTools::valuesFromRangeSelection( m_customPercentiles().toStdString() );
        for ( int p : customValues )
        {
            // Only include valid percentiles in range [0, 100]
            if ( p >= 0 && p <= 100 )
            {
                // Avoid duplicates with standard percentiles
                if ( std::find( percentiles.begin(), percentiles.end(), p ) == percentiles.end() )
                {


 ... (clipped 11 lines)
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Input parse errors: Custom percentile parsing via RiaStdStringTools::valuesFromRangeSelection is not wrapped
with explicit validation/error handling, so malformed user input may fail without
actionable feedback depending on the parser's behavior.

Referred Code
if ( !m_customPercentiles().isEmpty() )
{
    std::set<int> customValues = RiaStdStringTools::valuesFromRangeSelection( m_customPercentiles().toStdString() );
    for ( int p : customValues )
    {
        // Only include valid percentiles in range [0, 100]
        if ( p >= 0 && p <= 100 )
        {
            // Avoid duplicates with standard percentiles
            if ( std::find( percentiles.begin(), percentiles.end(), p ) == percentiles.end() )
            {
                percentiles.push_back( p );
            }
        }
    }
}

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Unvalidated free text: The free-text field m_customPercentiles is only filtered post-parse to [0,100] and does
not show explicit rejection/feedback for malformed tokens, so the security and stability
of input handling depends on the robustness of valuesFromRangeSelection.

Referred Code
CAF_PDM_InitField( &m_customPercentiles, "CustomPercentiles", QString(), "Custom Percentiles" );
m_customPercentiles.uiCapability()->setUiEditorTypeName( caf::PdmUiLineEditor::uiEditorTypeName() );
CAF_PDM_InitField( &m_showCurveLabels, "ShowCurveLabels", true, "Show Curve Labels" );
CAF_PDM_InitField( &m_includeIncompleteCurves, "IncludeIncompleteCurves", false, "Include Incomplete Curves" );

CAF_PDM_InitField( &m_crossPlotCurvesBinCount, "CrossPlotCurvesBinCount", 100, "Bin Count" );
CAF_PDM_InitField( &m_crossPlotCurvesStatisticsRealizationCountThresholdPerBin,
                   "CrossPlotCurvesStatisticsRealizationCountThresholdPerBin",
                   10,
                   "Realization Count Threshold per Bin" );

CAF_PDM_InitField( &m_warningLabel, "WarningLabel", QString( "Warning: Ensemble time range mismatch" ), "" );

CAF_PDM_InitField( &m_color, "Color", RiaColorTools::textColor3f(), "Color" );
CAF_PDM_InitField( &m_customColor, "CustomColorColor", false, "Custom Color" );

m_warningLabel.xmlCapability()->disableIO();
m_warningLabel.uiCapability()->setUiLabelPosition( caf::PdmUiItemInfo::LabelPosition::HIDDEN );
m_warningLabel.uiCapability()->setUiReadOnly( true );

if ( RimProject::current() && RimProject::current()->isProjectFileVersionEqualOrOlderThan( "2023.1.0" ) )


 ... (clipped 123 lines)

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review
Copy link

qodo-code-review bot commented Feb 5, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Parse custom percentile prefix

Update fromTokens to parse the "P" prefix for custom percentiles. This involves
detecting the prefix, setting the statistics type to CUSTOM, storing the
percentile value, and then continuing to parse the remaining tokens.

ApplicationLibCode/FileInterface/RifEclipseSummaryAddress.cpp [1081-1280]

 RifEclipseSummaryAddress RifEclipseSummaryAddress::fromTokens( const std::vector<std::string>& tokens )
 {
     if ( tokens.empty() ) return {};
-    ...
-    // existing code parses standard statisticsType prefix
-    // but does not recognize "P<number>" custom percentile prefix
+    auto toks = tokens;
+    // Handle custom percentile prefix "P<number>"
+    {
+        std::string first = RiaStdStringTools::trimString( toks[0] );
+        if ( first.size() > 1 && first[0] == 'P' &&
+             std::all_of( first.begin()+1, first.end(), ::isdigit ) )
+        {
+            int p = std::stoi( first.substr(1) );
+            toks.erase( toks.begin() );
+            auto addr = fromTokens( toks );
+            addr.setStatisticsType( RifEclipseSummaryAddressDefines::StatisticsType::CUSTOM );
+            addr.setPercentile( p );
+            return addr;
+        }
+    }
+    // ... rest of existing parsing logic ...
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: This suggestion addresses a bug where custom percentile addresses are not correctly parsed back from their string representation, which is critical for serialization and deserialization.

Medium
Use percentile in export address

Modify toEclipseTextAddress to generate a "P" prefix for custom percentiles,
similar to the logic in uiText, ensuring correct serialization.

ApplicationLibCode/FileInterface/RifEclipseSummaryAddress.cpp [776-783]

-std::string RifEclipseSummaryAddress::toEclipseTextAddress() const
+if ( isStatistics() )
 {
-    ...
-    if ( isStatistics() )
+    std::string prefix;
+    if ( statisticsType() == RifEclipseSummaryAddressDefines::StatisticsType::CUSTOM
+         && m_percentile >= MIN_PERCENTILE && m_percentile <= MAX_PERCENTILE )
     {
-        auto prefix = RifEclipseSummaryAddressDefines::statisticsTypeToString( statisticsType() );
-        text        = prefix + ":" + text;
+        prefix = "P" + std::to_string( m_percentile );
     }
-    return text;
+    else
+    {
+        prefix = RifEclipseSummaryAddressDefines::statisticsTypeToString( statisticsType() );
+    }
+    text = prefix + ":" + text;
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: This suggestion fixes a bug where toEclipseTextAddress does not correctly serialize custom percentiles, which is crucial for features like exporting and round-trip data conversion.

Medium
General
Avoid redundant percentile calculations
Suggestion Impact:The commit removed separate storage/calculation for m_p10Data/m_p50Data/m_p90Data and instead relies on the unified m_percentileData map for P10/P50/P90 as well as custom percentiles, computing percentiles once per timestep (alongside mean) and reusing those values for the standard percentile accessors.

code diff:

@@ -42,7 +42,7 @@
 //--------------------------------------------------------------------------------------------------
 bool RimEnsembleStatisticsCase::hasP10Data() const
 {
-    return !m_p10Data.empty();
+    return hasPercentileData( 10 );
 }
 
 //--------------------------------------------------------------------------------------------------
@@ -50,7 +50,7 @@
 //--------------------------------------------------------------------------------------------------
 bool RimEnsembleStatisticsCase::hasP50Data() const
 {
-    return !m_p50Data.empty();
+    return hasPercentileData( 50 );
 }
 
 //--------------------------------------------------------------------------------------------------
@@ -58,7 +58,7 @@
 //--------------------------------------------------------------------------------------------------
 bool RimEnsembleStatisticsCase::hasP90Data() const
 {
-    return !m_p90Data.empty();
+    return hasPercentileData( 90 );
 }
 
 //--------------------------------------------------------------------------------------------------
@@ -90,11 +90,23 @@
     switch ( resultAddress.statisticsType() )
     {
         case RifEclipseSummaryAddressDefines::StatisticsType::P10:
-            return { true, m_p10Data };
+        {
+            auto it = m_percentileData.find( 10 );
+            if ( it != m_percentileData.end() ) return { true, it->second };
+            return { true, {} };
+        }
         case RifEclipseSummaryAddressDefines::StatisticsType::P50:
-            return { true, m_p50Data };
+        {
+            auto it = m_percentileData.find( 50 );
+            if ( it != m_percentileData.end() ) return { true, it->second };
+            return { true, {} };
+        }
         case RifEclipseSummaryAddressDefines::StatisticsType::P90:
-            return { true, m_p90Data };
+        {
+            auto it = m_percentileData.find( 90 );
+            if ( it != m_percentileData.end() ) return { true, it->second };
+            return { true, {} };
+        }
         case RifEclipseSummaryAddressDefines::StatisticsType::MEAN:
             return { true, m_meanData };
         case RifEclipseSummaryAddressDefines::StatisticsType::CUSTOM:
@@ -228,13 +240,9 @@
 
     m_timeSteps = curveMerger.allXValues();
 
-    // Calculate standard percentiles for backward compatibility
-    m_p10Data.reserve( m_timeSteps.size() );
-    m_p50Data.reserve( m_timeSteps.size() );
-    m_p90Data.reserve( m_timeSteps.size() );
     m_meanData.reserve( m_timeSteps.size() );
 
-    // Initialize custom percentile storage
+    // Initialize percentile storage
     for ( int p : percentiles )
     {
         m_percentileData[p].reserve( m_timeSteps.size() );
@@ -250,25 +258,18 @@
             valuesAtTimeStep.push_back( curveValues[curveIdx][timeStepIndex] );
         }
 
-        // Calculate standard percentiles for backward compatibility
-        double p10, p50, p90, mean;
-        RigStatisticsMath::calculateStatisticsCurves( valuesAtTimeStep, &p10, &p50, &p90, &mean, RigStatisticsMath::PercentileStyle::SWITCHED );
-        m_p10Data.push_back( p10 );
-        m_p50Data.push_back( p50 );
-        m_p90Data.push_back( p90 );
+        double mean = RigStatisticsMath::calculateMean( valuesAtTimeStep );
         m_meanData.push_back( mean );
 
-        // Calculate custom percentiles using interpolated method
+        // Calculate percentiles
         std::vector<double> percentilePositions;
         for ( int p : percentiles )
         {
-            percentilePositions.push_back( static_cast<double>( p ) );
+            percentilePositions.push_back( static_cast<double>( p ) / 100.0 );
         }
 
         std::vector<double> percentileValues =
-            RigStatisticsMath::calculateInterpolatedPercentiles( valuesAtTimeStep,
-                                                                 percentilePositions,
-                                                                 RigStatisticsMath::PercentileStyle::SWITCHED );
+            RigStatisticsMath::calculatePercentiles( valuesAtTimeStep, percentilePositions, RigStatisticsMath::PercentileStyle::SWITCHED );
 
         for ( size_t i = 0; i < percentiles.size(); i++ )
         {
@@ -303,9 +304,6 @@
 void RimEnsembleStatisticsCase::clearData()
 {
     m_timeSteps.clear();
-    m_p10Data.clear();
-    m_p50Data.clear();
-    m_p90Data.clear();
     m_meanData.clear();
     m_percentileData.clear();
     m_requestedPercentiles.clear();

Optimize the calculate function by computing all required percentiles (standard
and custom) in a single pass with calculateInterpolatedPercentiles to avoid
redundant calculations.

ApplicationLibCode/ProjectDataModel/Summary/RimEnsembleStatisticsCase.cpp [158-285]

 void RimEnsembleStatisticsCase::calculate( const std::vector<RimSummaryCase*>& summaryCases,
                                            const RifEclipseSummaryAddress&     inputAddress,
                                            bool                                includeIncompleteCurves,
                                            const std::vector<int>&             percentiles )
 {
     auto hash = RiaHashTools::hash( summaryCases, inputAddress.toEclipseTextAddress(), includeIncompleteCurves, percentiles );
     if ( hash == m_hash ) return;
 
     auto startTime = RiaLogging::currentTime();
 
     m_hash = hash;
 
     clearData();
 
     m_requestedPercentiles = percentiles;
 
     if ( !inputAddress.isValid() ) return;
     if ( summaryCases.empty() ) return;
 
     // Use first summary case to get unit system and other meta data
     m_firstSummaryCase = summaryCases.front();
 
     const auto [minTime, maxTime] = findMinMaxTime( summaryCases, inputAddress );
 
     // The last time step for the individual realizations in an ensemble is usually identical. Add a small threshold to improve robustness.
     const auto timeThreshold = RiaSummaryTools::calculateTimeThreshold( minTime, maxTime );
 
     auto                      interpolationMethod = inputAddress.hasAccumulatedData() ? RiaCurveDefines::InterpolationMethod::LINEAR
                                                                                        : RiaCurveDefines::InterpolationMethod::STEP_RIGHT;
     RiaTimeHistoryCurveMerger curveMerger( interpolationMethod );
     std::vector<std::vector<double>> curveValues;
 
     if ( !includeIncompleteCurves )
     {
         RiaTimeHistoryCurveResampler::resampleCurvesToCommonTimeSteps(
             summaryCases, inputAddress, minTime, maxTime, timeThreshold, curveMerger, RiaCurveDefines::ExtrapolationMethod::NONE );
     }
     else
     {
         RiaTimeHistoryCurveResampler::resampleCurvesToUnionOfTimeSteps(
             summaryCases, inputAddress, minTime, maxTime, timeThreshold, curveMerger, RiaCurveDefines::ExtrapolationMethod::NONE );
     }
 
     if ( curveMerger.curveCount() == 0 ) return;
 
     curveValues.reserve( curveMerger.curveCount() );
     for ( size_t i = 0; i < curveMerger.curveCount(); i++ )
     {
         curveValues.push_back( curveMerger.interpolatedYValuesForAllXValues( i ) );
     }
 
     m_timeSteps = curveMerger.allXValues();
 
-    // Calculate standard percentiles for backward compatibility
     m_p10Data.reserve( m_timeSteps.size() );
     m_p50Data.reserve( m_timeSteps.size() );
     m_p90Data.reserve( m_timeSteps.size() );
     m_meanData.reserve( m_timeSteps.size() );
 
-    // Initialize custom percentile storage
     for ( int p : percentiles )
     {
         m_percentileData[p].reserve( m_timeSteps.size() );
     }
 
     for ( size_t timeStepIndex = 0; timeStepIndex < m_timeSteps.size(); timeStepIndex++ )
     {
         std::vector<double> valuesAtTimeStep;
         valuesAtTimeStep.reserve( curveMerger.curveCount() );
 
         for ( size_t curveIdx = 0; curveIdx < curveMerger.curveCount(); curveIdx++ )
         {
             valuesAtTimeStep.push_back( curveValues[curveIdx][timeStepIndex] );
         }
 
-        // Calculate standard percentiles for backward compatibility
-        double p10, p50, p90, mean;
-        RigStatisticsMath::calculateStatisticsCurves( valuesAtTimeStep, &p10, &p50, &p90, &mean, RigStatisticsMath::PercentileStyle::SWITCHED );
-        m_p10Data.push_back( p10 );
-        m_p50Data.push_back( p50 );
-        m_p90Data.push_back( p90 );
-        m_meanData.push_back( mean );
+        m_meanData.push_back( RigStatisticsMath::calculateMean( valuesAtTimeStep ) );
 
-        // Calculate custom percentiles using interpolated method
         std::vector<double> percentilePositions;
         for ( int p : percentiles )
         {
             percentilePositions.push_back( static_cast<double>( p ) );
         }
 
         std::vector<double> percentileValues =
             RigStatisticsMath::calculateInterpolatedPercentiles( valuesAtTimeStep,
                                                                  percentilePositions,
                                                                  RigStatisticsMath::PercentileStyle::SWITCHED );
 
         for ( size_t i = 0; i < percentiles.size(); i++ )
         {
-            m_percentileData[percentiles[i]].push_back( percentileValues[i] );
+            int p = percentiles[i];
+            m_percentileData[p].push_back( percentileValues[i] );
+
+            if ( p == 10 ) m_p10Data.push_back( percentileValues[i] );
+            if ( p == 50 ) m_p50Data.push_back( percentileValues[i] );
+            if ( p == 90 ) m_p90Data.push_back( percentileValues[i] );
         }
     }
 
     bool showDebugTiming = false;
     if ( showDebugTiming )
     {
         QString timingText = "RimEnsembleStatisticsCase::calculate" + QString::fromStdString( inputAddress.toEclipseTextAddress() );
         RiaLogging::logElapsedTime( timingText, startTime );
     }
 }

[Suggestion processed]

Suggestion importance[1-10]: 7

__

Why: The suggestion provides a valid optimization to avoid redundant percentile calculations by computing all required percentiles in a single pass, which improves performance.

Medium
Use a set for cleaner code

Refactor allPercentiles to use std::set for collecting percentile values. This
will automatically handle uniqueness and sorting, simplifying the
implementation.

ApplicationLibCode/ProjectDataModel/Summary/RimEnsembleStatistics.cpp [170-201]

 std::vector<int> RimEnsembleStatistics::allPercentiles() const
 {
-    std::vector<int> percentiles;
+    std::set<int> percentiles;
 
     // Add standard percentiles from checkboxes
-    if ( m_showP10Curve ) percentiles.push_back( 10 );
-    if ( m_showP50Curve ) percentiles.push_back( 50 );
-    if ( m_showP90Curve ) percentiles.push_back( 90 );
+    if ( m_showP10Curve ) percentiles.insert( 10 );
+    if ( m_showP50Curve ) percentiles.insert( 50 );
+    if ( m_showP90Curve ) percentiles.insert( 90 );
 
     // Parse custom percentiles string
     if ( !m_customPercentiles().isEmpty() )
     {
         std::set<int> customValues = RiaStdStringTools::valuesFromRangeSelection( m_customPercentiles().toStdString() );
         for ( int p : customValues )
         {
             // Only include valid percentiles in range [0, 100]
             if ( p >= 0 && p <= 100 )
             {
-                // Avoid duplicates with standard percentiles
-                if ( std::find( percentiles.begin(), percentiles.end(), p ) == percentiles.end() )
-                {
-                    percentiles.push_back( p );
-                }
+                percentiles.insert( p );
             }
         }
     }
 
-    // Sort for consistent ordering
-    std::sort( percentiles.begin(), percentiles.end() );
-
-    return percentiles;
+    return { percentiles.begin(), percentiles.end() };
 }
  • Apply / Chat
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies an opportunity to simplify the code by using a std::set to handle uniqueness and sorting automatically, which improves readability and maintainability.

Low
  • Update

magnesj and others added 10 commits February 5, 2026 13:33
Refactored calculateStatisticsCurves to use a new static method, calculatePercentiles, for computing percentiles. The new method supports arbitrary percentile queries and centralizes the logic, improving code reuse and maintainability.
Allows users to specify custom percentiles for statistics.

Introduces a `percentile` field to the summary address
and corresponding getter/setter methods.

Updates the UI text generation to display the custom
percentile value when appropriate, using the format "P[percentile]".

Adds validation to ensure the percentile value is within
the valid range of 0-100 or -1 (unset).
@magnesj magnesj force-pushed the custom-percentiles branch from f003a81 to 563e49f Compare February 5, 2026 19:32
@magnesj magnesj changed the title NB! Diff between custom percentile and checkbox percentiles Add support for custom percentile levels Feb 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant