diff --git a/CMakeLists_files.cmake b/CMakeLists_files.cmake index 5b684b52fb9..a807d61a325 100644 --- a/CMakeLists_files.cmake +++ b/CMakeLists_files.cmake @@ -264,6 +264,7 @@ list(APPEND MAIN_SOURCE_FILES opm/input/eclipse/Schedule/Network/Branch.cpp opm/input/eclipse/Schedule/Network/ExtNetwork.cpp opm/input/eclipse/Schedule/Network/NetworkKeywordHandlers.cpp + opm/input/eclipse/Schedule/Network/NetworkValidation.cpp opm/input/eclipse/Schedule/Network/Node.cpp opm/input/eclipse/Schedule/ResCoup/ReservoirCouplingInfo.cpp opm/input/eclipse/Schedule/ResCoup/ReservoirCouplingKeywordHandlers.cpp @@ -1644,6 +1645,7 @@ list(APPEND PRIVATE_HEADER_FILES external/resinsight/cafPdmCore/cafAssert.h external/resinsight/cafPdmCore/cafSignal.h opm/input/eclipse/Schedule/HandlerContext.hpp + opm/input/eclipse/Schedule/Network/NetworkValidation.hpp opm/input/eclipse/Schedule/Well/WellTrajInfo.hpp opm/input/eclipse/Schedule/WellTraj/RigEclipseWellLogExtractor.hpp ) diff --git a/opm/input/eclipse/Parser/ParseContext.cpp b/opm/input/eclipse/Parser/ParseContext.cpp index 4672d92eb76..9310fd451a6 100644 --- a/opm/input/eclipse/Parser/ParseContext.cpp +++ b/opm/input/eclipse/Parser/ParseContext.cpp @@ -160,6 +160,8 @@ namespace Opm { this->addKey(SCHEDULE_MSW_KEYWORD_ON_NON_MSW_WELL, InputErrorAction::THROW_EXCEPTION); this->addKey(SCHEDULE_ICD_INCOMPATIBLE_PDROP_MODEL, InputErrorAction::THROW_EXCEPTION); + this->addKey(SCHEDULE_NETWORK_INVALID, InputErrorAction::DELAYED_EXIT1); + addKey(SCHEDULE_INVALID_NAME, InputErrorAction::THROW_EXCEPTION); this->addKey(SCHEDULE_INVALID_INJPHASE, InputErrorAction::WARN); this->addKey(SCHEDULE_GCONSALE_INVALID_INJECTION, InputErrorAction::THROW_EXCEPTION); @@ -462,4 +464,6 @@ namespace Opm { const std::string ParseContext::SCHEDULE_MSW_KEYWORD_ON_NON_MSW_WELL = "SCHEDULE_MSW_KEYWORD_ON_NON_MSW_WELL"; const std::string ParseContext::SCHEDULE_ICD_INCOMPATIBLE_PDROP_MODEL = "SCHEDULE_ICD_INCOMPATIBLE_PDROP_MODEL"; + const std::string ParseContext::SCHEDULE_NETWORK_INVALID = "SCHEDULE_NETWORK_INVALID"; + } diff --git a/opm/input/eclipse/Parser/ParseContext.hpp b/opm/input/eclipse/Parser/ParseContext.hpp index 25cdf5c771f..bda192bcbdb 100644 --- a/opm/input/eclipse/Parser/ParseContext.hpp +++ b/opm/input/eclipse/Parser/ParseContext.hpp @@ -610,6 +610,14 @@ namespace Opm { /// with the pressure drop model chosen for a particular MSW. const static std::string SCHEDULE_ICD_INCOMPATIBLE_PDROP_MODEL; + /// Inconsistent network topology (BRANPROP, NODEPROP keywords). + /// + /// For instance a node without inlets which is not also a group, or + /// a flow path which does not end in a fixed pressure node. Such a + /// network cannot be balanced, so this category reports an error and + /// schedules termination at the end of schedule loading by default. + const static std::string SCHEDULE_NETWORK_INVALID; + // The SIMULATOR_KEYWORD_ categories are intended to define the // parser behaviour for when the parser itself recognises an input // keyword, but the simulator does not support the intended use of diff --git a/opm/input/eclipse/Schedule/Network/NetworkValidation.cpp b/opm/input/eclipse/Schedule/Network/NetworkValidation.cpp new file mode 100644 index 00000000000..a70625287e9 --- /dev/null +++ b/opm/input/eclipse/Schedule/Network/NetworkValidation.cpp @@ -0,0 +1,242 @@ +/* + Copyright 2026 Equinor ASA. + + This file is part of the Open Porous Media project (OPM). + + OPM 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. + + OPM 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 for more details. + + You should have received a copy of the GNU General Public License + along with OPM. If not, see . +*/ + +#include "NetworkValidation.hpp" + +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + + /// Names of those nodes which take part in the network. + /// + /// A node is part of the network only for as long as it is attached to + /// at least one branch. A node mentioned in NODEPROP alone, or a node + /// whose only branch has been removed by a BRANPROP entry with a zero + /// VFP table number, is therefore not included here. + /// + /// \param[in] network Extended network. + /// + /// \return Names of the network's nodes, in order of first appearance + /// in the input. + std::vector + connectedNodes(const Opm::Network::ExtNetwork& network) + { + auto connected = std::set{}; + for (const auto* branch : network.branches()) { + connected.insert(branch->uptree_node()); + connected.insert(branch->downtree_node()); + } + + auto nodes = std::vector{}; + nodes.reserve(connected.size()); + + std::ranges::copy_if(network.node_names(), std::back_inserter(nodes), + [&connected](const std::string& node) + { return connected.contains(node); }); + + return nodes; + } + + /// Whether or not a node acts as a source for the network. + /// + /// Flow enters a node through its downtree branches, so a node without + /// downtree branches has no inlets and must supply its own flow rate. + /// + /// \param[in] network Extended network. + /// + /// \param[in] node Node name. Must be a node of \p network. + /// + /// \return Whether or not \p node has any inlets. + bool isSource(const Opm::Network::ExtNetwork& network, + const std::string& node) + { + return network.downtree_branches(node).empty(); + } + + /// Report a network inconsistency. + /// + /// \param[in] description Description of the inconsistency. Must not + /// contain any brace characters. + /// + /// \param[in] location Location of the keyword which prompted the + /// check. Included in the diagnostic by the error handling protocol. + /// + /// \param[in] parseContext Error handling controls. + /// + /// \param[in,out] errors Collection of parse errors. + void reportInconsistency(const std::string& description, + const Opm::KeywordLocation& location, + const Opm::ParseContext& parseContext, + Opm::ErrorGuard& errors) + { + parseContext.handleError(Opm::ParseContext::SCHEDULE_NETWORK_INVALID, + description, location, errors); + } + + /// Check that every source node is also a group. + /// + /// A source node gets its flow rate from the wells of the group of the + /// same name. A source node which is not a group has no way of + /// contributing to the network. + /// + /// \param[in] network Extended network. + /// + /// \param[in] nodes Names of the network's nodes. + /// + /// \param[in] isGroup Predicate returning whether or not a name is that + /// of a group. + /// + /// \param[in] location Location of the keyword which prompted this + /// check. + /// + /// \param[in] parseContext Error handling controls. + /// + /// \param[in,out] errors Collection of parse errors. + void checkSourcesAreGroups(const Opm::Network::ExtNetwork& network, + const std::vector& nodes, + const std::function& isGroup, + const Opm::KeywordLocation& location, + const Opm::ParseContext& parseContext, + Opm::ErrorGuard& errors) + { + for (const auto& node : nodes) { + if (!isSource(network, node) || isGroup(node)) { + continue; + } + + reportInconsistency(fmt::format("Network node {0} has no downtree branches, and must " + "therefore supply the network on its own, but no " + "group {0} exists.", node), + location, parseContext, errors); + } + } + + /// Check that every flow path ends in a fixed pressure node. + /// + /// Follows the flow path uptree from each node. The pressure drop along + /// the path can be computed only if the path ends in a node of known + /// pressure. + /// + /// Flow paths merge on their way uptree, so a single problem is shared + /// by every node downtree of it. To report each problem only once we + /// stop tracing a path as soon as it reaches a node which some earlier + /// path already passed through. + /// + /// \param[in] network Extended network. + /// + /// \param[in] nodes Names of the network's nodes. + /// + /// \param[in] location Location of the keyword which prompted this + /// check. + /// + /// \param[in] parseContext Error handling controls. + /// + /// \param[in,out] errors Collection of parse errors. + void checkFlowPathsAreTerminated(const Opm::Network::ExtNetwork& network, + const std::vector& nodes, + const Opm::KeywordLocation& location, + const Opm::ParseContext& parseContext, + Opm::ErrorGuard& errors) + { + // Nodes whose flow path has been traced already, either to a fixed + // pressure node or to a problem which has been reported. + auto traced = std::unordered_set{}; + + for (const auto& start : nodes) { + if (traced.contains(start)) { + continue; + } + + auto path = std::vector { start }; + auto onPath = std::unordered_set { start }; + + auto node = start; + while (! network.node(node).terminal_pressure().has_value()) { + const auto uptree = network.uptree_branch(node); + + if (! uptree.has_value()) { + reportInconsistency(fmt::format("Flow path from network node {} terminates in " + "node {}, which has neither an uptree branch " + "nor a fixed pressure.", start, node), + location, parseContext, errors); + break; + } + + node = uptree->uptree_node(); + + if (traced.contains(node)) { + // Path merges into one which has been traced already. + // Whether that path ends well or not, there is nothing + // new to report here. + break; + } + + if (! onPath.insert(node).second) { + // Cycle in the branch definitions. Stop here to avoid + // looping forever--this path will never reach a fixed + // pressure node. + reportInconsistency(fmt::format("Flow path from network node {} returns to " + "node {}: the branches form a loop.", + start, node), + location, parseContext, errors); + break; + } + + path.push_back(node); + } + + traced.insert(path.begin(), path.end()); + } + } + +} // Anonymous namespace + +void Opm::Network::validateTopology(const ExtNetwork& network, + const std::function& isGroup, + const KeywordLocation& location, + const ParseContext& parseContext, + ErrorGuard& errors) +{ + if (!network.active() || network.is_standard_network()) { + // Nothing to check, or a standard network (GRUPNET) whose nodes are + // groups by construction. + return; + } + + const auto nodes = connectedNodes(network); + + checkSourcesAreGroups(network, nodes, isGroup, location, parseContext, errors); + checkFlowPathsAreTerminated(network, nodes, location, parseContext, errors); +} diff --git a/opm/input/eclipse/Schedule/Network/NetworkValidation.hpp b/opm/input/eclipse/Schedule/Network/NetworkValidation.hpp new file mode 100644 index 00000000000..af607dd609f --- /dev/null +++ b/opm/input/eclipse/Schedule/Network/NetworkValidation.hpp @@ -0,0 +1,72 @@ +/* + Copyright 2026 Equinor ASA. + + This file is part of the Open Porous Media project (OPM). + + OPM 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. + + OPM 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 for more details. + + You should have received a copy of the GNU General Public License + along with OPM. If not, see . +*/ + +#ifndef NETWORK_VALIDATION_HPP +#define NETWORK_VALIDATION_HPP + +#include +#include + +namespace Opm { + class ErrorGuard; + class KeywordLocation; + class ParseContext; +} // namespace Opm + +namespace Opm::Network { + +class ExtNetwork; + +/// Check that the topology of an extended network is internally consistent. +/// +/// The network cannot be balanced unless every source--i.e., every node +/// without inlets--supplies a flow rate, and unless every flow path ends in +/// a node of known pressure. Consequently, this function reports, through +/// the normal error handling protocol, +/// +/// -# any node without inlets which is not also a group, and +/// +/// -# any flow path which does not end in a fixed pressure node. +/// +/// Nodes which are no longer attached to a branch--e.g., because a BRANPROP +/// entry with a zero VFP table number removed the node's only branch--are +/// not part of the network and are therefore not checked. Neither are +/// standard networks (GRUPNET), the nodes of which are groups by +/// construction. +/// +/// \param[in] network Extended network, typically that of the current +/// report step's schedule state. +/// +/// \param[in] isGroup Predicate returning whether or not a name is that of +/// a group at the current report step. +/// +/// \param[in] location Location of the keyword which prompted this check. +/// +/// \param[in] parseContext Error handling controls. +/// +/// \param[in,out] errors Collection of parse errors. +void validateTopology(const ExtNetwork& network, + const std::function& isGroup, + const KeywordLocation& location, + const ParseContext& parseContext, + ErrorGuard& errors); + +} // namespace Opm::Network + +#endif // NETWORK_VALIDATION_HPP diff --git a/opm/input/eclipse/Schedule/Schedule.cpp b/opm/input/eclipse/Schedule/Schedule.cpp index 3157ef0ef24..f9fb365d977 100644 --- a/opm/input/eclipse/Schedule/Schedule.cpp +++ b/opm/input/eclipse/Schedule/Schedule.cpp @@ -97,6 +97,7 @@ #include #include #include +#include #include #include @@ -104,6 +105,7 @@ #include "KeywordHandlers.hpp" #include "MSW/Compsegs.hpp" #include "MSW/WelSegsSet.hpp" +#include "Network/NetworkValidation.hpp" #include "Well/injection.hpp" #include @@ -722,6 +724,12 @@ void Schedule::iterateScheduleSection(std::size_t load_start, std::size_t load_e std::unordered_map wpimult_global_factor; + // Location of the first network keyword of this report step, if + // any. The network is checked for consistency only for those + // report steps in which it is defined or redefined, and any + // problem is reported against that keyword. + std::optional network_location; + while (true) { if (keyword_index == block.size()) break; @@ -768,6 +776,13 @@ void Schedule::iterateScheduleSection(std::size_t load_start, std::size_t load_e continue; } + if (!network_location.has_value() && + (keyword.is() || + keyword.is())) + { + network_location = location; + } + logger(fmt::format("Processing keyword {} at line {}", location.keyword, location.lineno)); this->handleKeyword(report_step, block, @@ -788,6 +803,16 @@ void Schedule::iterateScheduleSection(std::size_t load_start, std::size_t load_e this->updateICDScalingFactors(); check_compsegs_and_comptraj_consistency(welsegs_wells, compsegs_wells, comptraj_wells, this->getWells(report_step)); + + if (network_location.has_value()) { + const auto& state = this->snapshots[report_step]; + + Network::validateTopology(state.network.get(), + [&state](const std::string& name) + { return state.groups.has(name); }, + *network_location, parseContext, errors); + } + this->applyGlobalWPIMULT(wpimult_global_factor); this->end_report(report_step); diff --git a/tests/parser/NetworkTests.cpp b/tests/parser/NetworkTests.cpp index 82e1888e934..71ef30282e4 100644 --- a/tests/parser/NetworkTests.cpp +++ b/tests/parser/NetworkTests.cpp @@ -42,6 +42,9 @@ #include +#include +#include +#include #include #include @@ -72,6 +75,42 @@ Schedule make_schedule(const std::string& schedule_string) runspec, std::make_shared() }; } + +Schedule make_schedule(const std::string& schedule_string, ErrorGuard& errors) +{ + const auto deck = Parser{}.parseString(schedule_string); + EclipseGrid grid(10,10,10); + const TableManager table ( deck ); + const FieldPropsManager fp( deck, Phases{true, true, true}, grid, table); + const Runspec runspec (deck); + + return { + deck, grid, fp, NumericalAquifers{}, + runspec, ParseContext{}, errors, std::make_shared() + }; +} + +// Build a schedule while treating an inconsistent network topology as a +// warning rather than as a fatal input error. Enables inspecting the +// network which the parser builds from an invalid description. +Schedule make_schedule_lenient_network(const std::string& schedule_string) +{ + const auto deck = Parser{}.parseString(schedule_string); + EclipseGrid grid(10,10,10); + const TableManager table ( deck ); + const FieldPropsManager fp( deck, Phases{true, true, true}, grid, table); + const Runspec runspec (deck); + + ParseContext parseContext; + parseContext.update(ParseContext::SCHEDULE_NETWORK_INVALID, InputErrorAction::IGNORE); + + ErrorGuard errors; + + return { + deck, grid, fp, NumericalAquifers{}, + runspec, parseContext, errors, std::make_shared() + }; +} } BOOST_AUTO_TEST_SUITE(Basic_Functionality) @@ -137,7 +176,14 @@ NODEPROP / )"; - const auto& schedule = make_schedule(deck_string); + // B1X is a source node--it has no inlets--but it is not a group, so it + // cannot supply any flow to the network. + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + BOOST_CHECK(errors); + errors.clear(); + + const auto& schedule = make_schedule_lenient_network(deck_string); const auto& network = schedule[0].network.get(); BOOST_CHECK(network.has_node("B1X")); } @@ -177,7 +223,15 @@ NODEPROP / )"; - const auto& schedule = make_schedule(deck_string); + // The flow paths from B1 and C1 both end at PLAT-AX. The fixed + // pressure is assigned to PLAT-A, which is not part of the network, so + // neither path ends in a node of known pressure. + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + BOOST_CHECK(errors); + errors.clear(); + + const auto& schedule = make_schedule_lenient_network(deck_string); const auto& network = schedule[0].network.get(); BOOST_CHECK(network.has_node("PLAT-AX")); } @@ -598,6 +652,213 @@ BOOST_AUTO_TEST_SUITE_END() // Keyword_Consistency // =========================================================================== +BOOST_AUTO_TEST_SUITE(Topology_Consistency) + +namespace { + // Extended network deck with the group tree of model5. The network + // itself--the BRANPROP and NODEPROP keywords, and any subsequent + // updates--is supplied by the caller. + std::string network_deck(const std::string& network) + { + return R"( +RUNSPEC +NETWORK + 5 4 / + +SCHEDULE + +GRUPTREE + 'PROD' 'FIELD' / + + 'M5S' 'PLAT-A' / + 'M5N' 'PLAT-A' / + + 'C1' 'M5N' / + 'F1' 'M5N' / + 'B1' 'M5S' / + 'G1' 'M5S' / + 'BX' 'M5S' / +/ +)" + network; + } + + // The network of model5/5_NETWORK_MODEL5_STDW.DATA, which is consistent. + std::string valid_network() + { + return R"( +BRANPROP +-- Downtree Uptree #VFP ALQ + B1 PLAT-A 5 1* / + C1 PLAT-A 4 1* / +/ + +NODEPROP +-- Node_name Press autoChoke? addGasLift? Group_name + PLAT-A 21.0 NO NO 1* / + B1 1* NO NO 1* / + C1 1* NO NO 1* / +/ +)"; + } +} // Anonymous namespace + +BOOST_AUTO_TEST_CASE(Accept_Consistent_Network) +{ + BOOST_CHECK_NO_THROW(make_schedule(network_deck(valid_network()))); +} + +BOOST_AUTO_TEST_CASE(Reject_Source_Node_Without_Group) +{ + // Branch N1 -> N2 added to an otherwise consistent network. N1 has no + // inlets and is not a group, so it cannot act as a source. + const auto deck_string = network_deck(valid_network() + R"( +BRANPROP +-- Downtree Uptree #VFP ALQ + N1 N2 4 1* / +/ +)"); + + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + BOOST_CHECK(errors); + errors.clear(); +} + +BOOST_AUTO_TEST_CASE(Reject_Flow_Path_Without_Fixed_Pressure) +{ + // Branch BX -> N2 added to an otherwise consistent network. BX is a + // group, so it may act as a source, but the flow path from BX ends at + // N2, which has no pressure of its own and no uptree branch. + const auto deck_string = network_deck(valid_network() + R"( +BRANPROP +-- Downtree Uptree #VFP ALQ + BX N2 4 1* / +/ +)"); + + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + BOOST_CHECK(errors); + errors.clear(); + + // The offending nodes are still added to the network when the + // inconsistency is not treated as an error. + const auto schedule = make_schedule_lenient_network(deck_string); + const auto& network = schedule[0].network.get(); + BOOST_CHECK(network.has_node("BX")); + BOOST_CHECK(network.has_node("N2")); +} + +BOOST_AUTO_TEST_CASE(Reject_Cyclic_Network) +{ + // No node of the B1 -> N1 -> N2 -> B1 loop has any inlet outside the + // loop, and none of them has a fixed pressure. + const auto deck_string = network_deck(R"( +BRANPROP +-- Downtree Uptree #VFP ALQ + B1 N1 5 1* / + N1 N2 4 1* / + N2 B1 4 1* / +/ + +NODEPROP +-- Node_name Press autoChoke? addGasLift? Group_name + B1 1* NO NO 1* / +/ +)"); + + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + BOOST_CHECK(errors); + errors.clear(); +} + +BOOST_AUTO_TEST_CASE(Accept_Detached_Node) +{ + // Dropping the only branch of node C1 leaves C1 outside the network. + // The remaining network, B1 -> PLAT-A, is consistent. + const auto deck_string = network_deck(valid_network() + R"( +TSTEP + 10 / + +BRANPROP +-- Downtree Uptree #VFP ALQ + C1 PLAT-A 0 1* / +/ +)"); + + BOOST_CHECK_NO_THROW(make_schedule(deck_string)); +} + +BOOST_AUTO_TEST_CASE(Reject_Inconsistent_Network_Update) +{ + // The network is consistent at the first report step and is made + // inconsistent at the second. + const auto deck_string = network_deck(valid_network() + R"( +TSTEP + 10 / + +BRANPROP +-- Downtree Uptree #VFP ALQ + N1 N2 4 1* / +/ +)"); + + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + BOOST_CHECK(errors); + errors.clear(); +} + +BOOST_AUTO_TEST_CASE(Report_Multiple_Topology_Errors) +{ + // Two disconnected branches N1 -> N2 and N3 -> N4 added. + // Both N1 and N3 are source nodes without groups. + // All topology errors should be reported in a single pass. + const auto deck_string = network_deck(valid_network() + R"( +BRANPROP +-- Downtree Uptree #VFP ALQ + N1 N2 4 1* / + N3 N4 4 1* / +/ +)"); + + auto errors = ErrorGuard{}; + make_schedule(deck_string, errors); + + const auto diagnostic = errors.formattedErrors(); + errors.clear(); + + BOOST_CHECK(diagnostic.find("N1") != std::string::npos); + BOOST_CHECK(diagnostic.find("N3") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(Throw_On_Inconsistent_Network_When_Configured) +{ + const auto deck_string = network_deck(valid_network() + R"( +BRANPROP +-- Downtree Uptree #VFP ALQ + N1 N2 4 1* / +/ +)"); + + const auto deck = Parser{}.parseString(deck_string); + EclipseGrid grid(10, 10, 10); + const TableManager table(deck); + const FieldPropsManager fp(deck, Phases{true, true, true}, grid, table); + const Runspec runspec(deck); + + ParseContext parseContext; + parseContext.update(ParseContext::SCHEDULE_NETWORK_INVALID, InputErrorAction::THROW_EXCEPTION); + ErrorGuard errors; + + BOOST_CHECK_THROW((Schedule{deck, grid, fp, NumericalAquifers{}, runspec, parseContext, errors, std::make_shared()}), OpmInputError); +} + +BOOST_AUTO_TEST_SUITE_END() // Topology_Consistency + +// =========================================================================== + BOOST_AUTO_TEST_SUITE(Injection_Networks) namespace {