From 73e39110393340f6e6514fb046998f394256055a Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Mon, 1 Jun 2026 10:11:05 +0200 Subject: [PATCH 01/17] allow line into itself - components Signed-off-by: Martijn Govers --- .../power_grid_model/component/branch.hpp | 6 +- tests/cpp_unit_tests/test_line.cpp | 61 +++++++++++++++++-- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch.hpp index 761407d709..6c87faac87 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch.hpp @@ -41,11 +41,7 @@ class Branch : public Base { from_node_{branch_input.from_node}, to_node_{branch_input.to_node}, from_status_{static_cast(branch_input.from_status)}, - to_status_{static_cast(branch_input.to_status)} { - if (from_node_ == to_node_) { - throw InvalidBranch{id(), from_node_}; - } - } + to_status_{static_cast(branch_input.to_status)} {} // getter constexpr ID from_node() const { return from_node_; } diff --git a/tests/cpp_unit_tests/test_line.cpp b/tests/cpp_unit_tests/test_line.cpp index 36c300a2b4..28c0f5d455 100644 --- a/tests/cpp_unit_tests/test_line.cpp +++ b/tests/cpp_unit_tests/test_line.cpp @@ -61,6 +61,10 @@ TEST_CASE("Test line") { ComplexValue const uaf{1.0}; ComplexValue const uat{0.9}; + // regular result + BranchSolverOutput const branch_solver_output{ + .s_f = 1.0 - 1.5i, .s_t = 1.5 - 1.5i, .i_f = 1.0 - 2.0i, .i_t = 2.0 - 1.0i}; + // Short circuit results DoubleComplex const if_sc{1.0, 1.0}; DoubleComplex const it_sc{2.0, 2.0 * sqrt3}; @@ -69,7 +73,7 @@ TEST_CASE("Test line") { CHECK(line.math_model_type() == ComponentType::branch); - SUBCASE("Voltge error") { CHECK_THROWS_AS(Line(input, 50.0, 10.0e3, 50.0e3), ConflictVoltage); } + SUBCASE("Voltage error") { CHECK_THROWS_AS(Line(input, 50.0, 10.0e3, 50.0e3), ConflictVoltage); } SUBCASE("General") { CHECK(branch.from_node() == 2); @@ -140,11 +144,6 @@ TEST_CASE("Test line") { } SUBCASE("Symmetric results with direct power and current output") { - BranchSolverOutput branch_solver_output{}; - branch_solver_output.i_f = 1.0 - 2.0i; - branch_solver_output.i_t = 2.0 - 1.0i; - branch_solver_output.s_f = 1.0 - 1.5i; - branch_solver_output.s_t = 1.5 - 1.5i; BranchOutput const output = branch.get_output(branch_solver_output); CHECK(output.id == 1); CHECK(output.energized); @@ -246,6 +245,56 @@ TEST_CASE("Test line") { CHECK(inv.from_status == expected.from_status); CHECK(inv.to_status == expected.to_status); } + + SUBCASE("Lines into itself") { + auto line_into_itself_input = input; + line_into_itself_input.to_node = 2; + Line line_into_itself{line_into_itself_input, 50.0, 10.0e3, 10.0e3}; + Branch& branch_into_itself = line_into_itself; + + CHECK(branch_into_itself.from_node() == branch.from_node()); + CHECK(branch_into_itself.to_node() == branch_into_itself.from_node()); + + CHECK(branch_into_itself.from_status() == branch.from_status()); + CHECK(branch_into_itself.to_status() == branch.to_status()); + CHECK(branch_into_itself.branch_status() == branch.branch_status()); + CHECK(branch_into_itself.status(BranchSide::from) == branch.status(BranchSide::from)); + CHECK(branch_into_itself.status(BranchSide::to) == branch.status(BranchSide::to)); + CHECK(branch_into_itself.base_i_from() == branch.base_i_from()); + CHECK(branch_into_itself.base_i_to() == branch.base_i_to()); + CHECK(branch_into_itself.phase_shift() == branch.phase_shift()); + CHECK(branch_into_itself.is_param_mutable() == branch.is_param_mutable()); + + SUBCASE("Symmetric parameters") { + auto const params = branch_into_itself.calc_param(); + auto const ref_params = branch.calc_param(); + CHECK(params.yff() == ref_params.yff()); + CHECK(params.ytt() == ref_params.ytt()); + CHECK(params.ytf() == ref_params.ytf()); + CHECK(params.yft() == ref_params.yft()); + } + SUBCASE("Asymmetric parameters") { + auto const params = branch_into_itself.calc_param(); + auto const ref_params = branch.calc_param(); + CHECK((cabs(params.yff() - ref_params.yff()) < numerical_tolerance).all()); + CHECK((cabs(params.ytt() - ref_params.ytt()) < numerical_tolerance).all()); + CHECK((cabs(params.ytf() - ref_params.ytf()) < numerical_tolerance).all()); + CHECK((cabs(params.yft() - ref_params.yft()) < numerical_tolerance).all()); + } + SUBCASE("Sym output") { + auto const branch_into_itself_solver_output = + BranchSolverOutput{.s_f = branch_solver_output.s_f, + .s_t = branch_solver_output.s_f, // same node, so should be s_f + .i_f = branch_solver_output.i_f, + .i_t = branch_solver_output.i_f}; + auto const branch_into_itself_output = + branch_into_itself.get_output(branch_into_itself_solver_output); + CHECK(branch_into_itself_output.s_from == branch.get_output(branch_solver_output).s_from); + CHECK(branch_into_itself_output.s_to == branch_into_itself_output.s_from); + CHECK(branch_into_itself_output.i_from == branch.get_output(branch_solver_output).i_from); + CHECK(branch_into_itself_output.i_to == branch_into_itself_output.i_from); + } + } } } // namespace power_grid_model From 97d44b4bcce1f3c6e71ca027d05138d08e5f198f Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Mon, 1 Jun 2026 15:26:51 +0200 Subject: [PATCH 02/17] ybus lines into itself tests Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_y_bus.cpp | 97 ++++++++++++++++++----------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/tests/cpp_unit_tests/test_y_bus.cpp b/tests/cpp_unit_tests/test_y_bus.cpp index f1167b104c..53831a3555 100644 --- a/tests/cpp_unit_tests/test_y_bus.cpp +++ b/tests/cpp_unit_tests/test_y_bus.cpp @@ -41,14 +41,14 @@ TEST_CASE("Test y bus") { // Topology: - // --- 4 --- ----- 3 ----- - // | | | | - // | v v | + // --- 4 --- /-3-\ } + // | | | | } loop from 2 to 2 + // | v v | } // [0] [1] --- 1 --> [2] --- 2 --> [3] - // ^ | | - // | | 5 - // --- 0 --- | - // X + // ^ | / ^ | + // | | 5 | | + // --- 0 --- | \--- 6 ----/ + // X MathModelTopology topo{}; MathModelParam param_sym; topo.phase_shift.resize(4, 0.0); @@ -56,13 +56,16 @@ TEST_CASE("Test y bus") { {1, 0}, // branch 0 from node 1 to 0 {1, 2}, // branch 1 from node 1 to 2 {2, 3}, // branch 2 from node 2 to 3 + {2, 2}, // branch 6 from node 2 to 2 (loop into itself) {3, 2}, // branch 3 from node 3 to 2 {0, 1}, // branch 4 from node 0 to 1 {2, -1} // branch 5 from node 2 to "not connected" }; - param_sym.branch_param = {// ff, ft, tf, tt - {1.0i, 2.0i, 3.0i, 4.0i}, {5.0, 6.0, 7.0, 8.0}, {9.0i, 10.0i, 11.0i, 12.0i}, - {13.0, 14.0, 15.0, 16.0}, {17.0, 18.0, 19.0, 20.0}, {1000i, 0.0, 0.0, 0.0}}; + param_sym.branch_param = { + // ff, ft, tf, tt + {1.0i, 2.0i, 3.0i, 4.0i}, {5.0, 6.0, 7.0, 8.0}, {9.0i, 10.0i, 11.0i, 12.0i}, {21.0i, 22.0i, 22.0i, 21.0i}, + {13.0, 14.0, 15.0, 16.0}, {17.0, 18.0, 19.0, 20.0}, {1000i, 0.0, 0.0, 0.0}, + }; topo.shunts_per_bus = {from_sparse, {0, 1, 1, 1, 2}}; // 4 buses, 2 shunts -> shunt connected to bus 0 and bus 3 param_sym.shunt_param = {100.0i, 200.0i}; @@ -75,7 +78,7 @@ TEST_CASE("Test y bus") { // x, x // x, 0 // ] - IdxVector const col_indices = {// Culumn col_indices for each non-zero element in Y bus. + IdxVector const col_indices = {// Column col_indices for each non-zero element in Y bus. 0, 1, 0, 1, 2, 1, 2, 3, 2, 3}; Idx nnz = 10; // Number of non-zero elements in Y bus IdxVector const bus_entry = {0, 3, 6, 9}; @@ -83,21 +86,23 @@ TEST_CASE("Test y bus") { 0, 2, 1, 3, 5, 4, 6, 8, 7, 9}; IdxVector const y_bus_entry_indptr = {0, 3, // 0, 1, 2 belong to element [0,0] in Ybus / 3,4 to element [0,1] 5, 7, 10, // 5,6 to [1,0] / 7, 8, 9 to [1,1] / 10 to [1,2] - 11, 12, 16, // 11 to [2,1] / 12, 13, 14, 15 to [2,2] / 16, 17 to [2,3] - 18, 20, // 18, 19 to [3,2] / 20, 21, 22 to [3,3] - 23}; + 11, 12, // 11 to [2,1] / 12, 13, 14, 15, 16, 17, 18, 19 to [2,2] + 20, 22, // 20, 21 to [2,3] / 22, 23 to [3, 2] + 24, 27}; // 24, 25, 26 to [3,3] IdxVector map_lu_y_bus = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; ComplexTensorVector admittance_sym = { - 17.0 + 104.0i, // 0, 0 -> {1, 0}tt + {0, 1}ff + shunt(0) = 4.0i + 17.0 + 100.0i - 18.0 + 3.0i, // 0, 1 -> {0, 1}ft + {1, 0}tf = 18.0 + 3.0i - 19.0 + 2.0i, // 1, 0 -> {0, 1}tf + {1, 0}ft = 19.0 + 2.0i - 25.0 + 1.0i, // 1, 1 -> {0, 1}tt + {1, 0}ff + {1,2}ff = 20.0 + 1.0i + 5.0 - 6.0, // 1, 2 -> {1,2}ft = 6.0 - 7.0, // 2, 1 -> {1,2}tf = 7.0 - 24.0 + 1009.0i, // 2, 2 -> {1,2}tt + {2,3}ff + {3, 2}tt + {2,-1}ff = 8.0 + 9.0i + 16.0 + 1000.0i = 24.0 + 1009i - 15.0 + 10.0i, // 2, 3 -> {2,3}ft + {3,2}tf = 10.0i + 15.0 - 14.0 + 11.0i, // 3, 2 -> {2,3}tf + {3,2}ft = 11.0i + 14.0 - 13.0 + 212.0i // 3, 3 -> {2,3}tt + {3,2}ff + shunt(1) = 12.0i + 13.0 + 200.0i + 4.0i + 17.0 + 100.0i, // 0, 0 -> {1, 0}tt + {0, 1}ff + shunt(0) + 18.0 + 3.0i, // 0, 1 -> {0, 1}ft + {1, 0}tf + 19.0 + 2.0i, // 1, 0 -> {0, 1}tf + {1, 0}ft + 20.0 + 1.0i + 5.0, // 1, 1 -> {0, 1}tt + {1, 0}ff + {1,2}ff + 6.0, // 1, 2 -> {1,2}ft + 7.0, // 2, 1 -> {1,2}tf + 8.0 + (21.0i + 22.0i + 22.0i + 21.0i) + 9.0i + 16.0 + 1000.0i, + // 2, 2 -> {1,2}tt + ({3,3}ff + {3,3}ft + {3,3}{tf} + {3,3}tt) + // + {2,3}ff + {3, 2}tt + {2,-1}ff + 10.0i + 15.0, // 2, 3 -> {2,3}ft + {3,2}tf + 11.0i + 14.0, // 3, 2 -> {2,3}tf + {3,2}ft + 12.0i + 13.0 + 200.0i // 3, 3 -> {2,3}tt + {3,2}ff + shunt(1) }; // asym input @@ -135,6 +140,7 @@ TEST_CASE("Test y bus") { CHECK(y_bus_entry_indptr == ybus.y_bus_entry_indptr()); CHECK(ybus.admittance().size() == admittance_sym.size()); for (size_t i = 0; i < admittance_sym.size(); i++) { + CAPTURE(i); CHECK(cabs(ybus.admittance()[i] - admittance_sym[i]) < numerical_tolerance); } @@ -220,26 +226,47 @@ TEST_CASE("Test y bus") { TEST_CASE("Test one bus system") { MathModelTopology topo{}; + MathModelParam param_sym; topo.phase_shift = {0.0}; - topo.shunts_per_bus = {from_sparse, {0, 0}}; // output IdxVector const indptr = {0, 1}; IdxVector const col_indices = {0}; - Idx nnz = 1; + Idx const nnz = 1; IdxVector const bus_entry = {0}; IdxVector const lu_transpose_entry = {0}; - IdxVector const y_bus_entry_indptr = {0, 0}; - YBus const ybus{topo, {}}; + SUBCASE("One shunt") { + topo.shunts_per_bus = {from_sparse, {0, 0}}; + IdxVector const y_bus_entry_indptr = {0, 0}; + + YBus const ybus{topo, param_sym}; + + CHECK(ybus.size() == 1); + CHECK(ybus.nnz() == nnz); + CHECK(indptr == ybus.row_indptr()); + CHECK(col_indices == ybus.col_indices()); + CHECK(bus_entry == ybus.bus_entry()); + CHECK(lu_transpose_entry == ybus.lu_transpose_entry()); + CHECK(y_bus_entry_indptr == ybus.y_bus_entry_indptr()); + } + SUBCASE("Branch into itself") { + topo.branch_bus_idx = {{0, 0}}; + param_sym.branch_param = {// ff, ft, tf, tt + {1.0i, 2.0i, 2.0i, 1.0i}}; - CHECK(ybus.size() == 1); - CHECK(ybus.nnz() == nnz); - CHECK(indptr == ybus.row_indptr()); - CHECK(col_indices == ybus.col_indices()); - CHECK(bus_entry == ybus.bus_entry()); - CHECK(lu_transpose_entry == ybus.lu_transpose_entry()); - CHECK(y_bus_entry_indptr == ybus.y_bus_entry_indptr()); + IdxVector const y_bus_entry_indptr = {0, 4}; + + YBus const ybus{topo, param_sym}; + + CHECK(ybus.size() == 1); + CHECK(ybus.nnz() == nnz); + CHECK(indptr == ybus.row_indptr()); + CHECK(col_indices == ybus.col_indices()); + CHECK(bus_entry == ybus.bus_entry()); + CHECK(lu_transpose_entry == ybus.lu_transpose_entry()); + CHECK(y_bus_entry_indptr == ybus.y_bus_entry_indptr()); + } } TEST_CASE("Test fill-in y bus") { From 4c09eb813442c9c3790a2855081362be4ace5dbb Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Tue, 2 Jun 2026 10:03:59 +0200 Subject: [PATCH 03/17] topo branch into itself Signed-off-by: Martijn Govers --- .../include/power_grid_model/topology.hpp | 17 ++++++++------- tests/cpp_unit_tests/test_topology.cpp | 21 +++++++++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/topology.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/topology.hpp index 824f5a9643..71e109126a 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/topology.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/topology.hpp @@ -4,6 +4,7 @@ #pragma once +#include "common/typing.hpp" #include "sparse_ordering.hpp" #include "calculation_parameters.hpp" @@ -129,7 +130,7 @@ class Topology { phase_shift_(comp_topo_.n_node_total(), 0.0), predecessors_( boost::counting_iterator{0}, // Predecessors is initialized as 0, 1, 2, ..., n_node_total() - 1 - boost::counting_iterator{(GraphIdx)comp_topo_.n_node_total()}), + boost::counting_iterator{narrow_cast(comp_topo_.n_node_total())}), node_status_(comp_topo_.n_node_total(), -1) {} // build topology @@ -202,12 +203,12 @@ class Topology { auto const [i, j] = branch_node_idx; auto const [i_status, j_status] = branch_connected; // node_i - node_j - if (i_status != 0 && j_status != 0) { + if (i_status != 0 && j_status != 0 && i != j) { // node_j - node_i - edges.emplace_back((GraphIdx)i, (GraphIdx)j); + edges.emplace_back(narrow_cast(i), narrow_cast(j)); edge_props.push_back({-phase_shift}); // node_i - node_j - edges.emplace_back((GraphIdx)j, (GraphIdx)i); + edges.emplace_back(narrow_cast(j), narrow_cast(i)); edge_props.push_back({phase_shift}); } } @@ -219,17 +220,17 @@ class Topology { for (Idx m = 0; m != 3; ++m) { if (i_status[m] != 0) { // node_internal - node_i - edges.emplace_back((GraphIdx)i[m], (GraphIdx)j_internal); + edges.emplace_back(narrow_cast(i[m]), narrow_cast(j_internal)); edge_props.push_back({-phase_shift[m]}); // node_i - node_internal - edges.emplace_back((GraphIdx)j_internal, (GraphIdx)i[m]); + edges.emplace_back(narrow_cast(j_internal), narrow_cast(i[m])); edge_props.push_back({phase_shift[m]}); } } } // build graph global_graph_ = GlobalGraph{boost::edges_are_unsorted_multi_pass, edges.cbegin(), edges.cend(), - edge_props.cbegin(), (GraphIdx)comp_topo_.n_node_total()}; + edge_props.cbegin(), narrow_cast(comp_topo_.n_node_total())}; for_all_vertices(global_graph_, [this](boost::graph_traits::vertex_descriptor const& v) { global_graph_[v].color = boost::default_color_type::white_color; }); @@ -256,7 +257,7 @@ class Topology { std::vector> back_edges; // start dfs search boost::depth_first_visit( - global_graph_, (GraphIdx)source_node, + global_graph_, narrow_cast(source_node), GlobalDFSVisitor{math_solver_idx, comp_coup_.node, phase_shift_, dfs_node, predecessors_, back_edges}, boost::get(&GlobalVertex::color, global_graph_)); diff --git a/tests/cpp_unit_tests/test_topology.cpp b/tests/cpp_unit_tests/test_topology.cpp index 40059adad7..0d9b5834f7 100644 --- a/tests/cpp_unit_tests/test_topology.cpp +++ b/tests/cpp_unit_tests/test_topology.cpp @@ -43,6 +43,9 @@ * \ $1 / [9:s2X+p3,h2] X | * \ +p16/ / [10] [11:lg1+p6] * 1 -->+p2+p10 [3+v3:s3X,h0] -- 2 + * | | + * | | + * \----8----/ * * * Math model #0: Math model #1: @@ -56,6 +59,9 @@ * \ 5 2 * \ +pf3/ X * 1 ->+pt1+pt2 [1+v2:h0] -- 2 --X + * | | + * | | + * \--7--/ * * Extra fill-in: * (3, 4) by removing node 1 @@ -122,7 +128,8 @@ TEST_CASE("Test topology") { {6, 7}, // 4 {4, 2}, // 5 {5, 4}, // 6 - {4, 5} // 7 + {4, 5}, // 7 + {1, 1} // 8 }; comp_topo.branch3_node_idx = { {1, 3, 2}, // b0 @@ -169,13 +176,14 @@ TEST_CASE("Test topology") { {0, 1}, // 5 {1, 1}, // 6 {1, 1}, // 7 + {1, 1}, // 8 }; comp_conn.branch3_connected = { {1, 1, 1}, // b0 {1, 1, 1}, // b1 {0, 1, 1}, // b2 }; - comp_conn.branch_phase_shift = {0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + comp_conn.branch_phase_shift = {0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; comp_conn.branch3_phase_shift = { {0.0, -1.0, 0.0}, {0.0, 0.0, 0.0}, @@ -221,9 +229,10 @@ TEST_CASE("Test topology") { {.group = 0, .pos = 3}, // 5 {.group = 1, .pos = 0}, // 6 {.group = 1, .pos = 1}, // 7 + {.group = 0, .pos = 4}, // 8 }; comp_coup_ref.branch3 = { - {.group = 0, .pos = {4, 5, 6}}, // b0 + {.group = 0, .pos = {5, 6, 7}}, // b0 {.group = -1, .pos = {-1, -1, -1}}, // b1 {.group = 1, .pos = {2, 3, 4}}, // b2 }; @@ -257,7 +266,7 @@ TEST_CASE("Test topology") { MathModelTopology math0; math0.slack_bus = 1; math0.sources_per_bus = {from_dense, {1}, 5}; - math0.branch_bus_idx = {{1, 2}, {1, 4}, {4, -1}, {-1, 0}, {2, 3}, {4, 3}, {0, 3}}; + math0.branch_bus_idx = {{1, 2}, {1, 4}, {4, -1}, {-1, 0}, {2, 2}, {2, 3}, {4, 3}, {0, 3}}; math0.phase_shift = {0.0, 0.0, 0.0, 0.0, -1.0}; math0.load_gens_per_bus = {from_dense, {1, 2}, 5}; math0.load_gen_type = {LoadGenType::const_pq, LoadGenType::const_y}; @@ -267,11 +276,11 @@ TEST_CASE("Test topology") { math0.power_sensors_per_source = {from_dense, {}, 1}; math0.power_sensors_per_shunt = {from_dense, {}, 1}; math0.power_sensors_per_load_gen = {from_dense, {1}, 2}; - math0.power_sensors_per_branch_from = {from_dense, {1, 1, 4, 5, 6}, 7}; + math0.power_sensors_per_branch_from = {from_dense, {1, 1, 5, 6, 7}, 8}; // 7 branches, 3 branch-to power sensors // sensor 0 is connected to branch 0 // sensor 1 and 2 are connected to branch 1 - math0.power_sensors_per_branch_to = {from_dense, {0, 1, 1}, 7}; + math0.power_sensors_per_branch_to = {from_dense, {0, 1, 1}, 8}; math0.fill_in = {{2, 4}}; // Sub graph / math model 1 From 68bfe156ed6884f1f11db67f44a0178412d2faef Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Tue, 2 Jun 2026 10:55:46 +0200 Subject: [PATCH 04/17] add branch3 into itself to topo tests Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_topology.cpp | 83 ++++++++++++++------------ 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/tests/cpp_unit_tests/test_topology.cpp b/tests/cpp_unit_tests/test_topology.cpp index 0d9b5834f7..0fe316b861 100644 --- a/tests/cpp_unit_tests/test_topology.cpp +++ b/tests/cpp_unit_tests/test_topology.cpp @@ -32,36 +32,37 @@ * (f = branch_from, t = branch_to, s = source, h = shunt, l = load, g = generator, b = bus) * * Topology: + * * 7 -> [5+v4+p17: ] * / [s1+p1+p12,lg2+p4+p8] [6:h1+p5+p9] -X-4-> [7] -3-> [8+v5] * 0 ----->+p13 [1+v1:lg3+p7] / / \ / \ / - * / +p14\ 5 ---X--- [4] <- 6 $2 $1 $1 $2 - * / $0 / ^ \ / \ / - * / \ v / (b2) (b1) - * [0:s0,lg0] (b0)-$2- [2+v0+v2] / | | - * +p0+p11 / +p15 X $0 $0 - * \ $1 / [9:s2X+p3,h2] X | - * \ +p16/ / [10] [11:lg1+p6] - * 1 -->+p2+p10 [3+v3:s3X,h0] -- 2 - * | | + * / +p14\ 5 ---X--- [4] <- 6 $2 $1 $1 $2 + * / $0 / ^ \ \ / \ / + * / \ v / $0 (b2) (b1) + * [0:s0,lg0] (b0)-$2- [2+v0+v2] / \ | | + * +p0+p11 / +p15 X (b3) $0 $0 + * \ $1 / \ \ X | + * \ +p16/ / $1 $2 [10] [11:lg1+p6] + * 1 -->+p2+p10 [3+v3:s3X,h0] -- 2 \ \ + * | | [12] * | | - * \----8----/ + * \----8----/ [9:s2X+p3,h2] * * * Math model #0: Math model #1: * - * 0 ----->+pt0 [2+v3:lg0+pg0] 1 -> [3+v0+pb0:s0+ps0+ps1,lg0+pl0+pl1] [0:h1+ps0+ps1] + * 0 ----->+pt0 [2+v3:lg0+pg0] 1 -> [5+v0+pb0:s0+ps0+ps1,lg0+pl0+pl1] [0:h1+ps0+ps1] * / +pf2\ 3 --X / / \ / - * / 4 / [2] <- 0 3 4 - * / v v v v - * [4:s0,lg1] [3] <-6- [0+v0+v1] [1] - * +pf0+pf1 ^ +pf4 ^ - * \ 5 2 - * \ +pf3/ X - * 1 ->+pt1+pt2 [1+v2:h0] -- 2 --X - * | | - * | | - * \--7--/ + * / 5 / [4] <- 0 4 3 + * / v v \ v v + * [4:s0,lg1] [3] <-7- [0+v0+v1] 5 [1] + * +pf0+pf1 ^ +pf4 v ^ + * \ 6 [3] 2 + * \ +pf3/ ^ ^ X + * 1 ->+pt1+pt2 [1+v2:h0] -- 2 --X 6 7 + * | | \ \ + * | | [2] + * \--4--/ * * Extra fill-in: * (3, 4) by removing node 1 @@ -118,7 +119,7 @@ template void check_equal(T const& first, T const& s TEST_CASE("Test topology") { // component topology ComponentTopology comp_topo{}; - comp_topo.n_node = 12; + comp_topo.n_node = 13; comp_topo.branch_node_idx = { {0, 1}, // 0 @@ -129,12 +130,13 @@ TEST_CASE("Test topology") { {4, 2}, // 5 {5, 4}, // 6 {4, 5}, // 7 - {1, 1} // 8 + {1, 1} // 8 (branch into itself) }; comp_topo.branch3_node_idx = { {1, 3, 2}, // b0 {11, 7, 8}, // b1 - {10, 6, 5} // b2 + {10, 6, 5}, // b2 + {4, 12, 12} // b3 (branch3 into itself) }; comp_topo.source_node_idx = {0, 5, 9, 3}; comp_topo.load_gen_node_idx = {0, 11, 5, 1}; @@ -182,12 +184,14 @@ TEST_CASE("Test topology") { {1, 1, 1}, // b0 {1, 1, 1}, // b1 {0, 1, 1}, // b2 + {1, 1, 1}, // b3 }; comp_conn.branch_phase_shift = {0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; comp_conn.branch3_phase_shift = { {0.0, -1.0, 0.0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, + {0.0, 0.0, 0.0}, }; comp_conn.source_connected = {1, 1, 0, 0}; @@ -200,8 +204,8 @@ TEST_CASE("Test topology") { {.group = 0, .pos = 0}, {.group = 0, .pos = 4}, // 4 5 6 - {.group = 1, .pos = 2}, // Topological node 4 has become node 2 in mathematical model (group) 1 - {.group = 1, .pos = 3}, + {.group = 1, .pos = 4}, // Topological node 4 has become node 2 in mathematical model (group) 1 + {.group = 1, .pos = 5}, {.group = 1, .pos = 0}, // 7, 8, 9, 10, 11 {.group = -1, .pos = -1}, // Topological node 7 is not included in the mathematical model, because it was not @@ -210,10 +214,12 @@ TEST_CASE("Test topology") { {.group = -1, .pos = -1}, {.group = -1, .pos = -1}, {.group = -1, .pos = -1}, - // b0, b1, b2 + {.group = 1, .pos = 2}, + // b0, b1, b2, b3 {.group = 0, .pos = 3}, // Branch3 b0 is replaced by a virtual node 3, in mathematical model 0 {.group = -1, .pos = -1}, - {.group = 1, .pos = 1}}; + {.group = 1, .pos = 1}, + {.group = 1, .pos = 3}}; comp_coup_ref.source = { {.group = 0, .pos = 0}, // 0 {.group = 1, .pos = 0}, // 1 @@ -235,6 +241,7 @@ TEST_CASE("Test topology") { {.group = 0, .pos = {5, 6, 7}}, // b0 {.group = -1, .pos = {-1, -1, -1}}, // b1 {.group = 1, .pos = {2, 3, 4}}, // b2 + {.group = 1, .pos = {5, 6, 7}}, // b3 }; comp_coup_ref.load_gen = { {.group = 0, .pos = 0}, {.group = -1, .pos = -1}, {.group = 1, .pos = 0}, {.group = 0, .pos = 1}}; @@ -285,20 +292,20 @@ TEST_CASE("Test topology") { // Sub graph / math model 1 MathModelTopology math1; - math1.slack_bus = 3; - math1.sources_per_bus = {from_dense, {3}, 4}; - math1.branch_bus_idx = {{3, 2}, {2, 3}, {-1, 1}, {0, 1}, {3, 1}}; - math1.phase_shift = {0, 0, 0, 0}; - math1.load_gens_per_bus = {from_dense, {3}, 4}; + math1.slack_bus = 5; + math1.sources_per_bus = {from_dense, {5}, 6}; + math1.branch_bus_idx = {{5, 4}, {4, 5}, {-1, 1}, {0, 1}, {5, 1}, {4, 3}, {2, 3}, {2, 3}}; + math1.phase_shift = {0, 0, 0, 0, 0, 0}; + math1.load_gens_per_bus = {from_dense, {5}, 6}; math1.load_gen_type = {LoadGenType::const_i}; - math1.shunts_per_bus = {from_dense, {0}, 4}; - math1.voltage_sensors_per_bus = {from_dense, {3}, 4}; - math1.power_sensors_per_bus = {from_dense, {3}, 4}; + math1.shunts_per_bus = {from_dense, {0}, 6}; + math1.voltage_sensors_per_bus = {from_dense, {5}, 6}; + math1.power_sensors_per_bus = {from_dense, {5}, 6}; math1.power_sensors_per_source = {from_dense, {0, 0}, 1}; math1.power_sensors_per_shunt = {from_dense, {0, 0}, 1}; math1.power_sensors_per_load_gen = {from_dense, {0, 0}, 1}; - math1.power_sensors_per_branch_from = {from_dense, {}, 5}; - math1.power_sensors_per_branch_to = {from_dense, {}, 5}; + math1.power_sensors_per_branch_from = {from_dense, {}, 8}; + math1.power_sensors_per_branch_to = {from_dense, {}, 8}; std::vector math_topology_ref = {math0, math1}; From f3f461af22246b95a9be420d9aa678b3b26a7e4d Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Tue, 2 Jun 2026 11:09:06 +0200 Subject: [PATCH 05/17] test branch3 fully into itself topo Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_topology.cpp | 64 ++++++++++++++++++-------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/tests/cpp_unit_tests/test_topology.cpp b/tests/cpp_unit_tests/test_topology.cpp index 0fe316b861..768f981d6c 100644 --- a/tests/cpp_unit_tests/test_topology.cpp +++ b/tests/cpp_unit_tests/test_topology.cpp @@ -45,8 +45,9 @@ * \ +p16/ / $1 $2 [10] [11:lg1+p6] * 1 -->+p2+p10 [3+v3:s3X,h0] -- 2 \ \ * | | [12] - * | | - * \----8----/ [9:s2X+p3,h2] + * | | /--$0--\ + * \----8----/ [9:s2X+p3,h2] [13:s3]---$1---(b4) + * \--$2--/ * * * Math model #0: Math model #1: @@ -64,6 +65,11 @@ * | | [2] * \--4--/ * + * Math model #2: + * /--0--> + * [1:s0]---1-->[0] + * \--2--> + * * Extra fill-in: * (3, 4) by removing node 1 * @@ -119,7 +125,7 @@ template void check_equal(T const& first, T const& s TEST_CASE("Test topology") { // component topology ComponentTopology comp_topo{}; - comp_topo.n_node = 13; + comp_topo.n_node = 14; comp_topo.branch_node_idx = { {0, 1}, // 0 @@ -133,12 +139,13 @@ TEST_CASE("Test topology") { {1, 1} // 8 (branch into itself) }; comp_topo.branch3_node_idx = { - {1, 3, 2}, // b0 - {11, 7, 8}, // b1 - {10, 6, 5}, // b2 - {4, 12, 12} // b3 (branch3 into itself) + {1, 3, 2}, // b0 + {11, 7, 8}, // b1 + {10, 6, 5}, // b2 + {4, 12, 12}, // b3 (branch3 with 2 branches into itself) + {13, 13, 13} // b4 (branch3 with 3 branches into itself) }; - comp_topo.source_node_idx = {0, 5, 9, 3}; + comp_topo.source_node_idx = {0, 5, 9, 3, 13}; comp_topo.load_gen_node_idx = {0, 11, 5, 1}; comp_topo.load_gen_type = {LoadGenType::const_pq, LoadGenType::const_pq, LoadGenType::const_i, LoadGenType::const_y}; @@ -185,15 +192,13 @@ TEST_CASE("Test topology") { {1, 1, 1}, // b1 {0, 1, 1}, // b2 {1, 1, 1}, // b3 + {1, 1, 1}, // b4 }; comp_conn.branch_phase_shift = {0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; comp_conn.branch3_phase_shift = { - {0.0, -1.0, 0.0}, - {0.0, 0.0, 0.0}, - {0.0, 0.0, 0.0}, - {0.0, 0.0, 0.0}, + {0.0, -1.0, 0.0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, }; - comp_conn.source_connected = {1, 1, 0, 0}; + comp_conn.source_connected = {1, 1, 0, 0, 1}; // result TopologicalComponentToMathCoupling comp_coup_ref{}; @@ -207,7 +212,7 @@ TEST_CASE("Test topology") { {.group = 1, .pos = 4}, // Topological node 4 has become node 2 in mathematical model (group) 1 {.group = 1, .pos = 5}, {.group = 1, .pos = 0}, - // 7, 8, 9, 10, 11 + // 7, 8, 9, 10, 11, 13 {.group = -1, .pos = -1}, // Topological node 7 is not included in the mathematical model, because it was not // connected to any power source {.group = -1, .pos = -1}, @@ -215,16 +220,21 @@ TEST_CASE("Test topology") { {.group = -1, .pos = -1}, {.group = -1, .pos = -1}, {.group = 1, .pos = 2}, - // b0, b1, b2, b3 + // 13 + {.group = 2, .pos = 1}, + // b0, b1, b2, b3, b4 {.group = 0, .pos = 3}, // Branch3 b0 is replaced by a virtual node 3, in mathematical model 0 {.group = -1, .pos = -1}, {.group = 1, .pos = 1}, - {.group = 1, .pos = 3}}; + {.group = 1, .pos = 3}, + {.group = 2, .pos = 0}, + }; comp_coup_ref.source = { {.group = 0, .pos = 0}, // 0 {.group = 1, .pos = 0}, // 1 {.group = -1, .pos = -1}, // 2 {.group = -1, .pos = -1}, // 3 + {.group = 2, .pos = 0}, // 4 }; comp_coup_ref.branch = { {.group = 0, .pos = 0}, // 0 @@ -242,6 +252,7 @@ TEST_CASE("Test topology") { {.group = -1, .pos = {-1, -1, -1}}, // b1 {.group = 1, .pos = {2, 3, 4}}, // b2 {.group = 1, .pos = {5, 6, 7}}, // b3 + {.group = 2, .pos = {0, 1, 2}}, // b4 }; comp_coup_ref.load_gen = { {.group = 0, .pos = 0}, {.group = -1, .pos = -1}, {.group = 1, .pos = 0}, {.group = 0, .pos = 1}}; @@ -307,7 +318,24 @@ TEST_CASE("Test topology") { math1.power_sensors_per_branch_from = {from_dense, {}, 8}; math1.power_sensors_per_branch_to = {from_dense, {}, 8}; - std::vector math_topology_ref = {math0, math1}; + // Sub graph / math model 2 + MathModelTopology math2; + math2.slack_bus = 1; + math2.sources_per_bus = {from_dense, {1}, 2}; + math2.branch_bus_idx = {{1, 0}, {1, 0}, {1, 0}}; + math2.phase_shift = {0, 0}; + math2.load_gens_per_bus = {from_dense, {}, 2}; + math2.load_gen_type = {}; + math2.shunts_per_bus = {from_dense, {}, 2}; + math2.voltage_sensors_per_bus = {from_dense, {}, 2}; + math2.power_sensors_per_bus = {from_dense, {}, 2}; + math2.power_sensors_per_source = {from_dense, {}, 1}; + math2.power_sensors_per_shunt = {from_dense, {}, 0}; + math2.power_sensors_per_load_gen = {from_dense, {}, 0}; + math2.power_sensors_per_branch_from = {from_dense, {}, 3}; + math2.power_sensors_per_branch_to = {from_dense, {}, 3}; + + std::vector math_topology_ref = {math0, math1, math2}; SUBCASE("Test topology result") { Topology topo{comp_topo, comp_conn}; @@ -316,7 +344,7 @@ TEST_CASE("Test topology") { REQUIRE(topo_comp_coup_ptr != nullptr); auto const& topo_comp_coup = *topo_comp_coup_ptr; - CHECK(math_topology.size() == 2); + CHECK(math_topology.size() == math_topology_ref.size()); // test component coupling CHECK(topo_comp_coup.node == comp_coup_ref.node); CHECK(topo_comp_coup.source == comp_coup_ref.source); From fcf19a8560df154644ca9afd073760c4175f4303 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Tue, 2 Jun 2026 11:45:30 +0200 Subject: [PATCH 06/17] branch3 into itself + deprecate python errors Signed-off-by: Martijn Govers --- .../power_grid_model/common/exception.hpp | 15 -------------- .../power_grid_model/component/branch3.hpp | 6 +----- src/power_grid_model/_core/error_handling.py | 9 --------- src/power_grid_model/_core/errors.py | 20 +++++++++++++++++-- src/power_grid_model/errors.py | 2 -- tests/cpp_unit_tests/test_exceptions.cpp | 13 ------------ tests/cpp_unit_tests/test_link.cpp | 2 +- .../test_three_winding_transformer.cpp | 12 +++++++++-- .../cpp_validation_tests/test_validation.cpp | 5 ----- tests/unit/test_error_handling.py | 18 +++++++---------- tests/unit/utils.py | 4 ---- 11 files changed, 37 insertions(+), 69 deletions(-) diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/common/exception.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/common/exception.hpp index f84cb5872d..a3b4cc90bb 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/common/exception.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/common/exception.hpp @@ -73,21 +73,6 @@ class ConflictVoltage : public PowerGridError { id1, u1, id2, u2)} {} }; -class InvalidBranch : public PowerGridError { - public: - InvalidBranch(ID branch_id, ID node_id) - : PowerGridError{std::format("Branch {} has the same from- and to-node {},\n This is not allowed!\n", branch_id, - node_id)} {} -}; - -class InvalidBranch3 : public PowerGridError { - public: - InvalidBranch3(ID branch3_id, ID node_1_id, ID node_2_id, ID node_3_id) - : PowerGridError{std::format( - "Branch3 {} is connected to the same node at least twice. Node 1/2/3: {}/{}/{},\n This is not allowed!\n", - branch3_id, node_1_id, node_2_id, node_3_id)} {} -}; - class InvalidTransformerClock : public PowerGridError { public: InvalidTransformerClock(ID id, IntS clock) diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch3.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch3.hpp index 29e4797d75..be156a168a 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch3.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/component/branch3.hpp @@ -41,11 +41,7 @@ class Branch3 : public Base { node_3_{branch3_input.node_3}, status_1_{static_cast(branch3_input.status_1)}, status_2_{static_cast(branch3_input.status_2)}, - status_3_{static_cast(branch3_input.status_3)} { - if (node_1_ == node_2_ || node_1_ == node_3_ || node_2_ == node_3_) { - throw InvalidBranch3{id(), node_1_, node_2_, node_3_}; - } - } + status_3_{static_cast(branch3_input.status_3)} {} // getter constexpr ID node_1() const { return node_1_; } diff --git a/src/power_grid_model/_core/error_handling.py b/src/power_grid_model/_core/error_handling.py index 9e018b744a..6f9f82d301 100644 --- a/src/power_grid_model/_core/error_handling.py +++ b/src/power_grid_model/_core/error_handling.py @@ -21,8 +21,6 @@ IDNotFound, IDWrongType, InvalidArguments, - InvalidBranch, - InvalidBranch3, InvalidCalculationMethod, InvalidMeasuredObject, InvalidRegulatedObject, @@ -63,11 +61,6 @@ class _PgmCErrorCode(IntEnum): r"Conflicting voltage for line (-?\d+)\n voltage at from node (-?\d+) is (.*)\n" r" voltage at to node (-?\d+) is (.*)\n" ) -_INVALID_BRANCH_RE = re.compile(r"Branch (-?\d+) has the same from- and to-node (-?\d+),\n This is not allowed!\n") -_INVALID_BRANCH3_RE = re.compile( - r"Branch3 (-?\d+) is connected to the same node at least twice. Node 1\/2\/3: (-?\d+)\/(-?\d+)\/(-?\d+),\n" - r" This is not allowed!\n" -) _INVALID_TRANSFORMER_CLOCK_RE = re.compile(r"Invalid clock for transformer (-?\d+), clock (-?\d+)\n") _SPARSE_MATRIX_ERROR_RE = re.compile(r"Sparse matrix error") # multiple different flavors _NOT_OBSERVABLE_ERROR_RE = re.compile(r"Not enough measurements available for state estimation.\n") @@ -107,8 +100,6 @@ class _PgmCErrorCode(IntEnum): _MISSING_CASE_FOR_ENUM_RE: MissingCaseForEnumError, _INVALID_ARGUMENTS_RE: InvalidArguments, _CONFLICT_VOLTAGE_RE: ConflictVoltage, - _INVALID_BRANCH_RE: InvalidBranch, - _INVALID_BRANCH3_RE: InvalidBranch3, _INVALID_TRANSFORMER_CLOCK_RE: InvalidTransformerClock, _SPARSE_MATRIX_ERROR_RE: SparseMatrixError, _NOT_OBSERVABLE_ERROR_RE: NotObservableError, diff --git a/src/power_grid_model/_core/errors.py b/src/power_grid_model/_core/errors.py index e08058ed8b..19837d9714 100644 --- a/src/power_grid_model/_core/errors.py +++ b/src/power_grid_model/_core/errors.py @@ -6,8 +6,12 @@ This file contains error classes for library-internal use. """ +import warnings + import numpy as np +_DEPRECATED_ERROR_MSG = "This error type is deprecated and may be reduced in a future release." + class PowerGridError(RuntimeError): """Generic power grid error.""" @@ -37,11 +41,23 @@ class ConflictVoltage(PowerGridError): class InvalidBranch(PowerGridError): - """A branch is invalid.""" + """A branch is invalid. + + [DEPRECATED] This error is no longer relevant and may be reduced in a future release.""" + + def __init__(self, *args, **kwargs): + warnings.warn(_DEPRECATED_ERROR_MSG, DeprecationWarning) + super().__init__(*args, **kwargs) class InvalidBranch3(PowerGridError): - """A branch3 is invalid.""" + """A branch3 is invalid. + + [DEPRECATED] This error is no longer relevant and may be reduced in a future release.""" + + def __init__(self, *args, **kwargs): + warnings.warn(_DEPRECATED_ERROR_MSG, DeprecationWarning) + super().__init__(*args, **kwargs) class InvalidTransformerClock(PowerGridError): diff --git a/src/power_grid_model/errors.py b/src/power_grid_model/errors.py index b9a3fc64c9..ad56a2ced5 100644 --- a/src/power_grid_model/errors.py +++ b/src/power_grid_model/errors.py @@ -16,8 +16,6 @@ IDNotFound, IDWrongType, InvalidArguments, - InvalidBranch, - InvalidBranch3, InvalidCalculationMethod, InvalidID, InvalidMeasuredObject, diff --git a/tests/cpp_unit_tests/test_exceptions.cpp b/tests/cpp_unit_tests/test_exceptions.cpp index 4f49d5af79..b04fb8eb82 100644 --- a/tests/cpp_unit_tests/test_exceptions.cpp +++ b/tests/cpp_unit_tests/test_exceptions.cpp @@ -105,19 +105,6 @@ TEST_CASE("Exceptions") { .what()} == "Conflicting voltage for line 0\n voltage at from node 0 is inf\n voltage at to node 0 is -inf\n"); } - SUBCASE("InvalidBranch") { - CHECK(std::string{InvalidBranch{ID{0}, ID{1}}.what()} == - "Branch 0 has the same from- and to-node 1,\n This is not allowed!\n"); - CHECK(std::string{InvalidBranch{na_IntID, na_IntID}.what()} == - "Branch -2147483648 has the same from- and to-node -2147483648,\n This is not allowed!\n"); - } - SUBCASE("InvalidBranch3") { - CHECK(std::string{InvalidBranch3{ID{0}, ID{4}, ID{5}, ID{6}}.what()} == - "Branch3 0 is connected to the same node at least twice. Node 1/2/3: 4/5/6,\n This is not allowed!\n"); - CHECK(std::string{InvalidBranch3{na_IntID, na_IntID, na_IntID, na_IntID}.what()} == - "Branch3 -2147483648 is connected to the same node at least twice. Node 1/2/3: " - "-2147483648/-2147483648/-2147483648,\n This is not allowed!\n"); - } SUBCASE("InvalidTransformerClock") { CHECK(std::string{InvalidTransformerClock{ID{0}, IntS{1}}.what()} == "Invalid clock for transformer 0, clock 1\n"); diff --git a/tests/cpp_unit_tests/test_link.cpp b/tests/cpp_unit_tests/test_link.cpp index dab8ced03b..8fef105af9 100644 --- a/tests/cpp_unit_tests/test_link.cpp +++ b/tests/cpp_unit_tests/test_link.cpp @@ -52,7 +52,7 @@ TEST_CASE("Test link") { SUBCASE("Invalid branch") { input.to_node = 2; - CHECK_THROWS_AS(Link(input, 10e3, 50e3), InvalidBranch); + CHECK_NOTHROW(Link(input, 10e3, 50e3)); } SUBCASE("Symmetric parameters") { diff --git a/tests/cpp_unit_tests/test_three_winding_transformer.cpp b/tests/cpp_unit_tests/test_three_winding_transformer.cpp index 577202aefa..27f2042d06 100644 --- a/tests/cpp_unit_tests/test_three_winding_transformer.cpp +++ b/tests/cpp_unit_tests/test_three_winding_transformer.cpp @@ -502,12 +502,20 @@ TEST_CASE("Test three winding transformer") { CHECK(output.i_3_angle(1) == 0); } - SUBCASE("invalid input") { + SUBCASE("Branch3 partially into itself") { input.node_2 = 2; - CHECK_THROWS_AS(ThreeWindingTransformer(input, 138e3, 69e3, 13.8e3), InvalidBranch3); + CHECK_NOTHROW(ThreeWindingTransformer(input, 138e3, 69e3, 13.8e3)); input.node_2 = 3; } + SUBCASE("Branch3 fully into itself") { + input.node_2 = 2; + input.node_3 = 2; + CHECK_NOTHROW(ThreeWindingTransformer(input, 138e3, 69e3, 13.8e3)); + input.node_2 = 3; + input.node_3 = 4; + } + SUBCASE("Periodic clock input") { input.clock_12 = 24; input.clock_13 = 37; diff --git a/tests/cpp_validation_tests/test_validation.cpp b/tests/cpp_validation_tests/test_validation.cpp index c493258249..d1b5d5c24a 100644 --- a/tests/cpp_validation_tests/test_validation.cpp +++ b/tests/cpp_validation_tests/test_validation.cpp @@ -150,11 +150,6 @@ class Subcase { {"ConflictVoltage", std::regex{"Conflicting voltage for line (-?\\d+)\n voltage at from node (-?\\d+) is (.*)\n " "voltage at to node (-?\\d+) is (.*)\n"}}, - {"InvalidBranch", - std::regex{"Branch (-?\\d+) has the same from- and to-node (-?\\d+),\n This is not allowed!\n"}}, - {"InvalidBranch3", - std::regex{"Branch3 (-?\\d+) is connected to the same node at least twice. Node 1\\/2\\/3: " - "(-?\\d+)\\/(-?\\d+)\\/(-?\\d+),\n This is not allowed!\n"}}, {"InvalidTransformerClock", std::regex{"Invalid clock for transformer (-?\\d+), clock (-?\\d+)\n"}}, {"SparseMatrixError", std::regex{"Sparse matrix error"}}, // multiple different flavors {"NotObservableError", std::regex{"Not enough measurements available for state estimation.\n"}}, diff --git a/tests/unit/test_error_handling.py b/tests/unit/test_error_handling.py index bbb5ed9ab0..fcc683b2d1 100644 --- a/tests/unit/test_error_handling.py +++ b/tests/unit/test_error_handling.py @@ -23,8 +23,6 @@ ConflictVoltage, IDNotFound, IDWrongType, - InvalidBranch, - InvalidBranch3, InvalidCalculationMethod, InvalidMeasuredObject, InvalidRegulatedObject, @@ -118,8 +116,7 @@ def test_handle_invalid_branch_error(): line_input[AT.from_node] = [0] line_input[AT.to_node] = [0] - with pytest.raises(InvalidBranch): - PowerGridModel(input_data={CT.node: node_input, CT.line: line_input}) + PowerGridModel(input_data={CT.node: node_input, CT.line: line_input}) def test_handle_invalid_branch3_error(): @@ -133,13 +130,12 @@ def test_handle_invalid_branch3_error(): three_winding_transformer_input[AT.node_2] = [0] three_winding_transformer_input[AT.node_3] = [0] - with pytest.raises(InvalidBranch3): - PowerGridModel( - input_data={ - CT.node: node_input, - CT.three_winding_transformer: three_winding_transformer_input, - } - ) + PowerGridModel( + input_data={ + CT.node: node_input, + CT.three_winding_transformer: three_winding_transformer_input, + } + ) def test_handle_invalid_transformer_clock_error(): diff --git a/tests/unit/utils.py b/tests/unit/utils.py index 09d4083eff..fd0564b3ae 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -22,8 +22,6 @@ ExperimentalFeature, IDNotFound, IDWrongType, - InvalidBranch, - InvalidBranch3, InvalidCalculationMethod, InvalidMeasuredObject, InvalidRegulatedObject, @@ -71,8 +69,6 @@ ConflictVoltage, IDNotFound, IDWrongType, - InvalidBranch, - InvalidBranch3, InvalidCalculationMethod, InvalidMeasuredObject, InvalidRegulatedObject, From 37af8edcfa4aa85dff774563d93f46a5a5cd53ac Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 08:06:04 +0200 Subject: [PATCH 07/17] fix additional python + c++ tests Signed-off-by: Martijn Govers --- .../power_grid_model/common/three_phase_tensor.hpp | 10 ++++++++++ tests/cpp_unit_tests/test_line.cpp | 4 ++-- tests/cpp_unit_tests/test_link.cpp | 2 -- tests/cpp_unit_tests/test_shunt.cpp | 1 - .../cpp_unit_tests/test_three_winding_transformer.cpp | 1 - tests/unit/test_error_handling.py | 11 +++++++++++ 6 files changed, 23 insertions(+), 6 deletions(-) diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/common/three_phase_tensor.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/common/three_phase_tensor.hpp index d5ba7d4ab0..4029c59ca4 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/common/three_phase_tensor.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/common/three_phase_tensor.hpp @@ -187,6 +187,16 @@ inline ComplexValue piecewise_complex_value(T const& x) { // piecewise factory construction for complex vector inline ComplexValue piecewise_complex_value(ComplexValue const& x) { return x; } +// basic operations +using Eigen::arg; +using Eigen::conj; +using Eigen::imag; +using Eigen::real; +using std::arg; +using std::conj; +using std::imag; +using std::real; + // abs template inline Float cabs(Float x) { return std::abs(x); } template inline Float abs2(Float x) { return x * x; } diff --git a/tests/cpp_unit_tests/test_line.cpp b/tests/cpp_unit_tests/test_line.cpp index 28c0f5d455..9f47cfdb50 100644 --- a/tests/cpp_unit_tests/test_line.cpp +++ b/tests/cpp_unit_tests/test_line.cpp @@ -249,8 +249,8 @@ TEST_CASE("Test line") { SUBCASE("Lines into itself") { auto line_into_itself_input = input; line_into_itself_input.to_node = 2; - Line line_into_itself{line_into_itself_input, 50.0, 10.0e3, 10.0e3}; - Branch& branch_into_itself = line_into_itself; + Line const line_into_itself{line_into_itself_input, 50.0, 10.0e3, 10.0e3}; + Branch const& branch_into_itself = line_into_itself; CHECK(branch_into_itself.from_node() == branch.from_node()); CHECK(branch_into_itself.to_node() == branch_into_itself.from_node()); diff --git a/tests/cpp_unit_tests/test_link.cpp b/tests/cpp_unit_tests/test_link.cpp index 8fef105af9..76ed15c0fe 100644 --- a/tests/cpp_unit_tests/test_link.cpp +++ b/tests/cpp_unit_tests/test_link.cpp @@ -11,13 +11,11 @@ #include #include #include -#include #include #include #include -#include #include namespace power_grid_model { diff --git a/tests/cpp_unit_tests/test_shunt.cpp b/tests/cpp_unit_tests/test_shunt.cpp index 7d5c4c01bd..d288807946 100644 --- a/tests/cpp_unit_tests/test_shunt.cpp +++ b/tests/cpp_unit_tests/test_shunt.cpp @@ -13,7 +13,6 @@ #include #include -#include #include #include diff --git a/tests/cpp_unit_tests/test_three_winding_transformer.cpp b/tests/cpp_unit_tests/test_three_winding_transformer.cpp index 27f2042d06..17db4e7322 100644 --- a/tests/cpp_unit_tests/test_three_winding_transformer.cpp +++ b/tests/cpp_unit_tests/test_three_winding_transformer.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include diff --git a/tests/unit/test_error_handling.py b/tests/unit/test_error_handling.py index fcc683b2d1..f81cd83e22 100644 --- a/tests/unit/test_error_handling.py +++ b/tests/unit/test_error_handling.py @@ -8,6 +8,7 @@ from power_grid_model import PowerGridModel from power_grid_model._core.dataset_definitions import AttributeType as AT, ComponentType as CT, DatasetType +from power_grid_model._core.errors import InvalidBranch, InvalidBranch3 from power_grid_model._core.power_grid_meta import initialize_array from power_grid_model.enum import ( AngleMeasurementType, @@ -498,3 +499,13 @@ def test_handle_power_grid_dataset_error(): @pytest.mark.skip(reason="TODO") def test_handle_power_grid_unreachable_error(): pass + + +def test_deprecated_invalid_branch_error(): + with pytest.deprecated_call(): + InvalidBranch() + + +def test_deprecated_invalid_branch3_error(): + with pytest.deprecated_call(): + InvalidBranch3() From 363a9659fa1988dac4e3e92f2df96f96fb27a2ff Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 08:16:24 +0200 Subject: [PATCH 08/17] resolve comments Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_link.cpp | 2 +- tests/cpp_unit_tests/test_topology.cpp | 2 +- tests/cpp_unit_tests/test_y_bus.cpp | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/cpp_unit_tests/test_link.cpp b/tests/cpp_unit_tests/test_link.cpp index 76ed15c0fe..ff353bc2e8 100644 --- a/tests/cpp_unit_tests/test_link.cpp +++ b/tests/cpp_unit_tests/test_link.cpp @@ -48,7 +48,7 @@ TEST_CASE("Test link") { CHECK(branch.phase_shift() == 0.0); } - SUBCASE("Invalid branch") { + SUBCASE("Branch into itself is supported") { input.to_node = 2; CHECK_NOTHROW(Link(input, 10e3, 50e3)); } diff --git a/tests/cpp_unit_tests/test_topology.cpp b/tests/cpp_unit_tests/test_topology.cpp index 768f981d6c..62f0af42f5 100644 --- a/tests/cpp_unit_tests/test_topology.cpp +++ b/tests/cpp_unit_tests/test_topology.cpp @@ -209,7 +209,7 @@ TEST_CASE("Test topology") { {.group = 0, .pos = 0}, {.group = 0, .pos = 4}, // 4 5 6 - {.group = 1, .pos = 4}, // Topological node 4 has become node 2 in mathematical model (group) 1 + {.group = 1, .pos = 4}, // Topological node 4 has become node 4 in mathematical model (group) 1 {.group = 1, .pos = 5}, {.group = 1, .pos = 0}, // 7, 8, 9, 10, 11, 13 diff --git a/tests/cpp_unit_tests/test_y_bus.cpp b/tests/cpp_unit_tests/test_y_bus.cpp index 53831a3555..f580d0d8fc 100644 --- a/tests/cpp_unit_tests/test_y_bus.cpp +++ b/tests/cpp_unit_tests/test_y_bus.cpp @@ -56,10 +56,10 @@ TEST_CASE("Test y bus") { {1, 0}, // branch 0 from node 1 to 0 {1, 2}, // branch 1 from node 1 to 2 {2, 3}, // branch 2 from node 2 to 3 - {2, 2}, // branch 6 from node 2 to 2 (loop into itself) - {3, 2}, // branch 3 from node 3 to 2 - {0, 1}, // branch 4 from node 0 to 1 - {2, -1} // branch 5 from node 2 to "not connected" + {2, 2}, // branch 3 from node 2 to 2 (loop into itself) + {3, 2}, // branch 4 from node 3 to 2 + {0, 1}, // branch 5 from node 0 to 1 + {2, -1} // branch 6 from node 2 to "not connected" }; param_sym.branch_param = { // ff, ft, tf, tt From 43028f39dd6c29e8fab385cba2096ccf84102400 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 10:01:25 +0200 Subject: [PATCH 09/17] add cyclic 3-node ybus test with branch into itself Signed-off-by: Martijn Govers --- .../cpp_unit_tests/test_sparse_lu_solver.cpp | 2 +- tests/cpp_unit_tests/test_y_bus.cpp | 157 +++++++++++++++--- 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/tests/cpp_unit_tests/test_sparse_lu_solver.cpp b/tests/cpp_unit_tests/test_sparse_lu_solver.cpp index e70d9efd7b..10b361e9e5 100644 --- a/tests/cpp_unit_tests/test_sparse_lu_solver.cpp +++ b/tests/cpp_unit_tests/test_sparse_lu_solver.cpp @@ -8,9 +8,9 @@ #include #include +#include #include -#include #include #include #include diff --git a/tests/cpp_unit_tests/test_y_bus.cpp b/tests/cpp_unit_tests/test_y_bus.cpp index f580d0d8fc..8ccc097c3b 100644 --- a/tests/cpp_unit_tests/test_y_bus.cpp +++ b/tests/cpp_unit_tests/test_y_bus.cpp @@ -24,6 +24,37 @@ namespace power_grid_model { namespace { using math_solver::YBusStructure; + +// asym input +// Symmetrical parameters and admittances are converted to asymmetrical tensors, +// i.e. each parameter/admittance x is converted to: +// x 0 0 +// 0 x 0 +// 0 0 x +auto convert_to_asymmetrical(MathModelParam const& param_sym, + ComplexTensorVector const& admittance_sym) { + std::pair, ComplexTensorVector> result{ + {}, ComplexTensorVector(admittance_sym.size())}; + + auto& [param_asym, admittance_asym] = result; + + // value + param_asym.branch_param.resize(param_sym.branch_param.size()); + for (size_t i = 0; i < param_sym.branch_param.size(); i++) { + for (size_t j = 0; j < 4; j++) { + param_asym.branch_param[i].value[j] = ComplexTensor{param_sym.branch_param[i].value[j]}; + } + } + param_asym.shunt_param.resize(param_sym.shunt_param.size()); + for (size_t i = 0; i < param_sym.shunt_param.size(); i++) { + param_asym.shunt_param[i] = ComplexTensor{param_sym.shunt_param[i]}; + } + // admittance_sym + for (size_t i = 0; i < admittance_sym.size(); i++) { + admittance_asym[i] = ComplexTensor{admittance_sym[i]}; + } + return result; +} } // namespace TEST_CASE("Test y bus") { @@ -105,29 +136,7 @@ TEST_CASE("Test y bus") { 12.0i + 13.0 + 200.0i // 3, 3 -> {2,3}tt + {3,2}ff + shunt(1) }; - // asym input - // Symmetrical parameters and admittances are converted to asymmetrical tensors, - // i.e. each parameter/admittance x is converted to: - // x 0 0 - // 0 x 0 - // 0 0 x - MathModelParam param_asym; - // value - param_asym.branch_param.resize(param_sym.branch_param.size()); - for (size_t i = 0; i < param_sym.branch_param.size(); i++) { - for (size_t j = 0; j < 4; j++) { - param_asym.branch_param[i].value[j] = ComplexTensor{param_sym.branch_param[i].value[j]}; - } - } - param_asym.shunt_param.resize(param_sym.shunt_param.size()); - for (size_t i = 0; i < param_sym.shunt_param.size(); i++) { - param_asym.shunt_param[i] = ComplexTensor{param_sym.shunt_param[i]}; - } - // admittance_sym - ComplexTensorVector admittance_asym(admittance_sym.size()); - for (size_t i = 0; i < admittance_sym.size(); i++) { - admittance_asym[i] = ComplexTensor{admittance_sym[i]}; - } + auto const [param_asym, admittance_asym] = convert_to_asymmetrical(param_sym, admittance_sym); SUBCASE("Test y bus construction (symmetrical)") { YBus const ybus{topo, param_sym}; @@ -269,6 +278,108 @@ TEST_CASE("Test one bus system") { } } +TEST_CASE("Test cyclic three bus system with branch into itself") { + + // test Y bus struct + // [ + // x, x, x + // x, x, x + // x, x, x + // ] + + // [0] = Node + // --0--> = Branch (from --id--> to) + // -X- = Open switch / not connected + + // Topology: + + // /- 3 -\ } + // | | } loop from 1 to 1 + // /--- 0 --> [1] <--/ } + // | | + // [0] 1 + // ^ v + // \--- 2 --- [2] <--\ } + // | | } loop from 2 to 2 + // \- 4 -/ } + MathModelTopology topo{}; + MathModelParam param_sym; + topo.phase_shift.resize(3, 0.0); + topo.branch_bus_idx = { + {0, 1}, // branch 0 from node 0 to 1 + {1, 2}, // branch 1 from node 1 to 2 + {2, 0}, // branch 2 from node 2 to 0 + {1, 1}, // branch 3 from node 1 to 1 (loop into itself) + {2, 2}, // branch 4 from node 2 to 2 (loop into itself) + }; + param_sym.branch_param = { + // ff, ft, tf, tt + {1.0i, 2.0i, 3.0i, 4.0i}, // 0 -> 1 + {5.0, 6.0, 7.0, 8.0}, // 1 -> 2 + {9.0i, 10.0i, 11.0i, 12.0i}, // 2 -> 0 + {21.0i, 22.0i, 22.0i, 21.0i}, // 1 -> 1 + {13.0, 14.0, 15.0, 16.0}, // 2 -> 2 + }; + topo.shunts_per_bus = {from_dense, {}, 3}; + param_sym.shunt_param = {}; + + // output + IdxVector const row_indptr = {0, 3, 6, 9}; + + // Use col_indices to find the location in Y bus + // e.g. col_indices = {0, 1, 0} results in Y bus: + // [ + // x, x + // x, 0 + // ] + IdxVector const col_indices = {// Column col_indices for each non-zero element in Y bus. + 0, 1, 2, 0, 1, 2, 0, 1, 2}; + Idx const nnz = 9; // Number of non-zero elements in Y bus + IdxVector const bus_entry = {0, 4, 8}; + IdxVector const lu_transpose_entry = {// Flip the id's of non-diagonal elements + 0, 3, 6, 1, 4, 7, 2, 5, 8}; + IdxVector const y_bus_entry_indptr = { + 0, 2, // 0, 1 belong to element [0,0] in Ybus + 3, // 2 to element [0,1] + 4, // 3 to [0, 2] + 5, // 4 to [1, 0] + 11, // 5, 6, (7, 8, 9, 10) to [1,1] + 12, // 11 to [1,2] + 13, // 12 to [2,0] + 14, // 13 to [2,1] + 20, // 14, 15, (16, 17, 18, 19) to [2,2] + }; + IdxVector const map_lu_y_bus = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + ComplexTensorVector const admittance_sym = { + 1.0i + 12.0i, // 0, 0 -> {0, 1}ff + {2, 0}tt + 2.0i, // 0, 1 -> {0, 1}ft + 11.0i, // 0, 2 -> {2, 0}tf + 3.0i, // 1, 0 -> {0, 1}tf + 4.0i + 5.0 + + (21.0i + 22.0i + 22.0i + 21.0i), // 1, 1 -> {0, 1}tt + {1, 2}ff + ({1,1}ff + {1,1}ft + {1,1}tf + {1,1}tt) + 6.0, // 1, 2 -> {1,2}ft + 10.0i, // 2, 0 -> {2, 0}ft + 7.0, // 2, 1 -> {1,2}tf + 8.0 + 9.0i + + (13.0 + 14.0 + 15.0 + 16.0), // 2, 2 -> {1, 2}tt + {2, 0}ff + ({2,2}ff + {2,2}ft + {2,2}tf + {2,2}tt) + }; + + YBus const ybus{topo, param_sym}; + + CHECK(ybus.size() == 3); + CHECK(ybus.nnz() == nnz); + CHECK(row_indptr == ybus.row_indptr()); + CHECK(col_indices == ybus.col_indices()); + CHECK(bus_entry == ybus.bus_entry()); + CHECK(lu_transpose_entry == ybus.lu_transpose_entry()); + CHECK(y_bus_entry_indptr == ybus.y_bus_entry_indptr()); + CHECK(ybus.admittance().size() == admittance_sym.size()); + for (size_t i = 0; i < admittance_sym.size(); i++) { + CAPTURE(i); + CHECK(cabs(ybus.admittance()[i] - admittance_sym[i]) < numerical_tolerance); + } +} + TEST_CASE("Test fill-in y bus") { // [1] --0--> [0] --1--> [2] // extra fill-in: (1, 2) by removing node 0 From 48aa93ee82d2ef383bac54ddff501a035aafc350 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 10:15:10 +0200 Subject: [PATCH 10/17] Update tests/cpp_unit_tests/test_y_bus.cpp Signed-off-by: Martijn Govers Co-authored-by: Santiago Figueroa Manrique Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_y_bus.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cpp_unit_tests/test_y_bus.cpp b/tests/cpp_unit_tests/test_y_bus.cpp index 8ccc097c3b..cff12d9bab 100644 --- a/tests/cpp_unit_tests/test_y_bus.cpp +++ b/tests/cpp_unit_tests/test_y_bus.cpp @@ -88,9 +88,9 @@ TEST_CASE("Test y bus") { {1, 2}, // branch 1 from node 1 to 2 {2, 3}, // branch 2 from node 2 to 3 {2, 2}, // branch 3 from node 2 to 2 (loop into itself) - {3, 2}, // branch 4 from node 3 to 2 - {0, 1}, // branch 5 from node 0 to 1 - {2, -1} // branch 6 from node 2 to "not connected" + {3, 2}, // branch 6 from node 3 to 2 + {0, 1}, // branch 4 from node 0 to 1 + {2, -1} // branch 5 from node 2 to "not connected" }; param_sym.branch_param = { // ff, ft, tf, tt From b7541c7af3ff590dfd380755810d89b70156e686 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 10:18:23 +0200 Subject: [PATCH 11/17] resolve comments Signed-off-by: Martijn Govers --- src/power_grid_model/errors.py | 2 ++ tests/unit/test_error_handling.py | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/power_grid_model/errors.py b/src/power_grid_model/errors.py index ad56a2ced5..b9a3fc64c9 100644 --- a/src/power_grid_model/errors.py +++ b/src/power_grid_model/errors.py @@ -16,6 +16,8 @@ IDNotFound, IDWrongType, InvalidArguments, + InvalidBranch, + InvalidBranch3, InvalidCalculationMethod, InvalidID, InvalidMeasuredObject, diff --git a/tests/unit/test_error_handling.py b/tests/unit/test_error_handling.py index f81cd83e22..f4e643db10 100644 --- a/tests/unit/test_error_handling.py +++ b/tests/unit/test_error_handling.py @@ -8,7 +8,6 @@ from power_grid_model import PowerGridModel from power_grid_model._core.dataset_definitions import AttributeType as AT, ComponentType as CT, DatasetType -from power_grid_model._core.errors import InvalidBranch, InvalidBranch3 from power_grid_model._core.power_grid_meta import initialize_array from power_grid_model.enum import ( AngleMeasurementType, @@ -24,6 +23,8 @@ ConflictVoltage, IDNotFound, IDWrongType, + InvalidBranch, + InvalidBranch3, InvalidCalculationMethod, InvalidMeasuredObject, InvalidRegulatedObject, @@ -107,7 +108,7 @@ def test_handle_missing_case_for_enum_error(): ) -def test_handle_invalid_branch_error(): +def test_handle_branch_into_itself_is_valid(): node_input = initialize_array(DatasetType.input, CT.node, 1) node_input[AT.id] = [0] node_input[AT.u_rated] = [0.0] @@ -120,7 +121,7 @@ def test_handle_invalid_branch_error(): PowerGridModel(input_data={CT.node: node_input, CT.line: line_input}) -def test_handle_invalid_branch3_error(): +def test_handle_branch3_into_itself_is_valid(): node_input = initialize_array(DatasetType.input, CT.node, 1) node_input[AT.id] = [0] node_input[AT.u_rated] = [0.0] From 4b1796d1363f868a45a016e38db8d87c5c28504b Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 10:44:29 +0200 Subject: [PATCH 12/17] add unit test to observability with branch into itself Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_observability.cpp | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index 8f808b0360..d924f43b0f 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -57,23 +57,23 @@ TEST_CASE("Observable voltage sensor - basic integration test") { MathModelTopology topo; topo.slack_bus = 0; topo.phase_shift = {0.0, 0.0, 0.0}; - topo.branch_bus_idx = {{0, 1}, {1, 2}}; - topo.sources_per_bus = {from_sparse, {0, 1, 1, 1}}; - topo.shunts_per_bus = {from_sparse, {0, 0, 0, 0}}; - topo.load_gens_per_bus = {from_sparse, {0, 0, 0, 0}}; - topo.power_sensors_per_bus = {from_sparse, {0, 0, 0, 0}}; - topo.power_sensors_per_source = {from_sparse, {0, 0}}; - topo.power_sensors_per_load_gen = {from_sparse, {0}}; - topo.power_sensors_per_shunt = {from_sparse, {0}}; - topo.power_sensors_per_branch_from = {from_sparse, {0, 1, 2}}; - topo.power_sensors_per_branch_to = {from_sparse, {0, 0, 0}}; - topo.current_sensors_per_branch_from = {from_sparse, {0, 0, 0}}; - topo.current_sensors_per_branch_to = {from_sparse, {0, 0, 0}}; - topo.voltage_sensors_per_bus = {from_sparse, {0, 1, 1, 1}}; + topo.branch_bus_idx = {{0, 1}, {1, 2}, {1, 1}}; + topo.sources_per_bus = {from_dense, {0}, 3}; + topo.shunts_per_bus = {from_dense, {}, 3}; + topo.load_gens_per_bus = {from_dense, {}, 3}; + topo.power_sensors_per_bus = {from_dense, {}, 3}; + topo.power_sensors_per_source = {from_dense, {}, 2}; + topo.power_sensors_per_load_gen = {from_dense, {}, 1}; + topo.power_sensors_per_shunt = {from_dense, {}, 1}; + topo.power_sensors_per_branch_from = {from_dense, {0, 1}, 3}; + topo.power_sensors_per_branch_to = {from_dense, {}, 3}; + topo.current_sensors_per_branch_from = {from_dense, {}, 3}; + topo.current_sensors_per_branch_to = {from_dense, {}, 3}; + topo.voltage_sensors_per_bus = {from_dense, {0}, 3}; MathModelParam param; param.source_param = {SourceCalcParam{.y1 = 1.0, .y0 = 1.0}}; - param.branch_param = {{1.0, -1.0, -1.0, 1.0}, {1.0, -1.0, -1.0, 1.0}}; + param.branch_param = {{1.0, -1.0, -1.0, 1.0}, {1.0, -1.0, -1.0, 1.0}, {2.0, 4.0, 4.0, 2.0}}; StateEstimationInput se_input; se_input.source_status = {1}; From f3edb7f9b1831777f4e362bc16ce886659283db9 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 11:32:07 +0200 Subject: [PATCH 13/17] sonar-cloud Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_y_bus.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/cpp_unit_tests/test_y_bus.cpp b/tests/cpp_unit_tests/test_y_bus.cpp index cff12d9bab..111247d678 100644 --- a/tests/cpp_unit_tests/test_y_bus.cpp +++ b/tests/cpp_unit_tests/test_y_bus.cpp @@ -122,18 +122,18 @@ TEST_CASE("Test y bus") { 24, 27}; // 24, 25, 26 to [3,3] IdxVector map_lu_y_bus = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; ComplexTensorVector admittance_sym = { - 4.0i + 17.0 + 100.0i, // 0, 0 -> {1, 0}tt + {0, 1}ff + shunt(0) - 18.0 + 3.0i, // 0, 1 -> {0, 1}ft + {1, 0}tf - 19.0 + 2.0i, // 1, 0 -> {0, 1}tf + {1, 0}ft - 20.0 + 1.0i + 5.0, // 1, 1 -> {0, 1}tt + {1, 0}ff + {1,2}ff - 6.0, // 1, 2 -> {1,2}ft - 7.0, // 2, 1 -> {1,2}tf - 8.0 + (21.0i + 22.0i + 22.0i + 21.0i) + 9.0i + 16.0 + 1000.0i, - // 2, 2 -> {1,2}tt + ({3,3}ff + {3,3}ft + {3,3}{tf} + {3,3}tt) - // + {2,3}ff + {3, 2}tt + {2,-1}ff - 10.0i + 15.0, // 2, 3 -> {2,3}ft + {3,2}tf - 11.0i + 14.0, // 3, 2 -> {2,3}tf + {3,2}ft - 12.0i + 13.0 + 200.0i // 3, 3 -> {2,3}tt + {3,2}ff + shunt(1) + 4.0i + 17.0 + 100.0i, // 0, 0 -> {1, 0}tt + {0, 1}ff + shunt(0) + 18.0 + 3.0i, // 0, 1 -> {0, 1}ft + {1, 0}tf + 19.0 + 2.0i, // 1, 0 -> {0, 1}tf + {1, 0}ft + 20.0 + 1.0i + 5.0, // 1, 1 -> {0, 1}tt + {1, 0}ff + {1,2}ff + 6.0, // 1, 2 -> {1,2}ft + 7.0, // 2, 1 -> {1,2}tf + 8.0 // 2, 2 -> {1,2}tt + + (21.0i + 22.0i + 22.0i + 21.0i) // + ({3,3}ff + {3,3}ft + {3,3}{tf} + {3,3}tt) + + 9.0i + 16.0 + 1000.0i, // + {2,3}ff + {3, 2}tt + {2,-1}ff + 10.0i + 15.0, // 2, 3 -> {2,3}ft + {3,2}tf + 11.0i + 14.0, // 3, 2 -> {2,3}tf + {3,2}ft + 12.0i + 13.0 + 200.0i // 3, 3 -> {2,3}tt + {3,2}ff + shunt(1) }; auto const [param_asym, admittance_asym] = convert_to_asymmetrical(param_sym, admittance_sym); From 02244166a0708a3219877717b8a51e1521898f54 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 11:38:31 +0200 Subject: [PATCH 14/17] add observability test case with one node, one source, one branch into itself, one voltage sensor and one power sensor on the branch Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_observability.cpp | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index d924f43b0f..688545af4b 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -1989,6 +1989,37 @@ TEST_CASE("Test Observability - Necessary check end to end test") { } } +TEST_CASE("Test Observability - one node, one source, one load, one branch into itself") { + MathModelTopology topo; + topo.slack_bus = 0; + topo.phase_shift = {0.0}; + topo.branch_bus_idx = {{0, 0}}; + topo.sources_per_bus = {from_dense, {0}, 1}; + topo.shunts_per_bus = {from_dense, {}, 1}; + topo.load_gens_per_bus = {from_dense, {1}, 1}; + topo.power_sensors_per_bus = {from_dense, {}, 1}; + topo.power_sensors_per_source = {from_dense, {}, 1}; + topo.power_sensors_per_load_gen = {from_dense, {}, 1}; + topo.power_sensors_per_shunt = {from_dense, {}, 1}; + topo.power_sensors_per_branch_from = {from_dense, {0}, 1}; + topo.power_sensors_per_branch_to = {from_dense, {}, 1}; + topo.current_sensors_per_branch_from = {from_dense, {}, 1}; + topo.current_sensors_per_branch_to = {from_dense, {}, 1}; + topo.voltage_sensors_per_bus = {from_dense, {0}, 1}; + + MathModelParam param; + param.source_param = {SourceCalcParam{.y1 = 1.0, .y0 = 1.0}}; + param.branch_param = {{2.0, 4.0, 4.0, 2.0}}; + + StateEstimationInput se_input; + se_input.source_status = {1}; + se_input.measured_voltage = {{.value = 1.0, .variance = 1.0}}; + se_input.measured_branch_from_power = { + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; + + check_observable(topo, std::move(param), se_input); +} + TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable network") { using power_grid_model::math_solver::observability::ObservabilityResult; From 077ed713f805973e1539b3e8672aad51c435664f Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 13:10:33 +0200 Subject: [PATCH 15/17] more extensive testing regarding branches into itself Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_observability.cpp | 89 +++++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index 688545af4b..52be92aea7 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -1989,19 +1989,19 @@ TEST_CASE("Test Observability - Necessary check end to end test") { } } -TEST_CASE("Test Observability - one node, one source, one load, one branch into itself") { +TEST_CASE("Test Observability - one node, one source, one load") { MathModelTopology topo; topo.slack_bus = 0; topo.phase_shift = {0.0}; topo.branch_bus_idx = {{0, 0}}; topo.sources_per_bus = {from_dense, {0}, 1}; topo.shunts_per_bus = {from_dense, {}, 1}; - topo.load_gens_per_bus = {from_dense, {1}, 1}; + topo.load_gens_per_bus = {from_dense, {0}, 1}; topo.power_sensors_per_bus = {from_dense, {}, 1}; topo.power_sensors_per_source = {from_dense, {}, 1}; topo.power_sensors_per_load_gen = {from_dense, {}, 1}; topo.power_sensors_per_shunt = {from_dense, {}, 1}; - topo.power_sensors_per_branch_from = {from_dense, {0}, 1}; + topo.power_sensors_per_branch_from = {from_dense, {}, 1}; topo.power_sensors_per_branch_to = {from_dense, {}, 1}; topo.current_sensors_per_branch_from = {from_dense, {}, 1}; topo.current_sensors_per_branch_to = {from_dense, {}, 1}; @@ -2013,11 +2013,88 @@ TEST_CASE("Test Observability - one node, one source, one load, one branch into StateEstimationInput se_input; se_input.source_status = {1}; + se_input.load_gen_status = {1}; se_input.measured_voltage = {{.value = 1.0, .variance = 1.0}}; - se_input.measured_branch_from_power = { - {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; - check_observable(topo, std::move(param), se_input); + SUBCASE("No power sensor or current sensor, no branch into itself") { + topo.branch_bus_idx.clear(); + topo.power_sensors_per_branch_from = {from_dense, {}, 0}; + topo.current_sensors_per_branch_from = {from_dense, {}, 0}; + check_observable(topo, std::move(param), se_input); + } + SUBCASE("No power sensor or current sensor, with branch into itself") { + check_observable(topo, std::move(param), se_input); + } + SUBCASE("With branch into itself with power sensor") { + topo.power_sensors_per_branch_from = {from_dense, {0}, 1}; + se_input.measured_branch_from_power = { + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; + check_observable(topo, param, se_input); + } + SUBCASE("With branch into itself with current sensor") { + topo.current_sensors_per_branch_from = {from_dense, {0}, 1}; + se_input.measured_branch_from_current = { + {.angle_measurement_type = AngleMeasurementType::local_angle, + .measurement = {.real_component = {.value = 10.0, .variance = 100.0}, + .imag_component = {.value = 0.0, .variance = 200.0}}}}; + check_observable(topo, std::move(param), se_input); + } +} + +TEST_CASE("Test Observability - two nodes, one source, one load, one branch") { + MathModelTopology topo; + topo.slack_bus = 0; + topo.phase_shift = {0.0, 0.0}; + topo.branch_bus_idx = {{0, 1}}; + topo.sources_per_bus = {from_dense, {0}, 2}; + topo.shunts_per_bus = {from_dense, {}, 2}; + topo.load_gens_per_bus = {from_dense, {1}, 2}; + topo.power_sensors_per_bus = {from_dense, {}, 2}; + topo.power_sensors_per_source = {from_dense, {}, 2}; + topo.power_sensors_per_load_gen = {from_dense, {}, 2}; + topo.power_sensors_per_shunt = {from_dense, {}, 2}; + topo.power_sensors_per_branch_from = {from_dense, {}, 1}; + topo.power_sensors_per_branch_to = {from_dense, {}, 1}; + topo.current_sensors_per_branch_from = {from_dense, {}, 1}; + topo.current_sensors_per_branch_to = {from_dense, {}, 1}; + topo.voltage_sensors_per_bus = {from_dense, {0}, 2}; + + MathModelParam param; + param.source_param = {SourceCalcParam{.y1 = 1.0, .y0 = 1.0}}; + param.branch_param = {{1.0, 2.0, 2.0, 1.0}}; + + StateEstimationInput se_input; + se_input.source_status = {1}; + se_input.load_gen_status = {1}; + se_input.measured_voltage = {{.value = 1.0, .variance = 1.0}}; + + SUBCASE("No power sensor or current sensor") { check_not_observable(topo, std::move(param), se_input); } + SUBCASE("With branch power sensor") { + topo.power_sensors_per_branch_from = {from_dense, {0}, 1}; + se_input.measured_branch_from_power = { + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; + check_observable(topo, param, se_input); + } + SUBCASE("With current sensor") { + topo.current_sensors_per_branch_from = {from_dense, {0}, 1}; + se_input.measured_branch_from_current = { + {.angle_measurement_type = AngleMeasurementType::local_angle, + .measurement = {.real_component = {.value = 10.0, .variance = 100.0}, + .imag_component = {.value = 0.0, .variance = 200.0}}}}; + check_observable(topo, std::move(param), se_input); + } + SUBCASE("Power or current sensor only on branch into itself does not 'fix' the unobservability of the network") { + topo.branch_bus_idx = {{0, 1}, {1, 1}}; + param.branch_param = {{1.0, 2.0, 2.0, 1.0}, {2.0, 4.0, 4.0, 2.0}}; + topo.power_sensors_per_branch_from = {from_dense, {1}, 2}; + topo.power_sensors_per_branch_to = {from_dense, {}, 2}; + topo.current_sensors_per_branch_from = {from_dense, {}, 2}; + topo.current_sensors_per_branch_to = {from_dense, {}, 2}; + + se_input.measured_branch_from_power = { + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; + check_not_observable(topo, param, se_input); + } } TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable network") { From 16d9800d9018b3756868326a9a59868fa49fc93d Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Wed, 3 Jun 2026 14:03:30 +0200 Subject: [PATCH 16/17] also test auto tap regulator with transformer into itself ==> lowest ranking Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_observability.cpp | 12 ++--- .../test_tap_position_optimizer.cpp | 47 +++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index 52be92aea7..0a3e5d3827 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -2020,11 +2020,9 @@ TEST_CASE("Test Observability - one node, one source, one load") { topo.branch_bus_idx.clear(); topo.power_sensors_per_branch_from = {from_dense, {}, 0}; topo.current_sensors_per_branch_from = {from_dense, {}, 0}; - check_observable(topo, std::move(param), se_input); - } - SUBCASE("No power sensor or current sensor, with branch into itself") { - check_observable(topo, std::move(param), se_input); + check_observable(topo, param, se_input); } + SUBCASE("No power sensor or current sensor, with branch into itself") { check_observable(topo, param, se_input); } SUBCASE("With branch into itself with power sensor") { topo.power_sensors_per_branch_from = {from_dense, {0}, 1}; se_input.measured_branch_from_power = { @@ -2037,7 +2035,7 @@ TEST_CASE("Test Observability - one node, one source, one load") { {.angle_measurement_type = AngleMeasurementType::local_angle, .measurement = {.real_component = {.value = 10.0, .variance = 100.0}, .imag_component = {.value = 0.0, .variance = 200.0}}}}; - check_observable(topo, std::move(param), se_input); + check_observable(topo, param, se_input); } } @@ -2068,7 +2066,7 @@ TEST_CASE("Test Observability - two nodes, one source, one load, one branch") { se_input.load_gen_status = {1}; se_input.measured_voltage = {{.value = 1.0, .variance = 1.0}}; - SUBCASE("No power sensor or current sensor") { check_not_observable(topo, std::move(param), se_input); } + SUBCASE("No power sensor or current sensor") { check_not_observable(topo, param, se_input); } SUBCASE("With branch power sensor") { topo.power_sensors_per_branch_from = {from_dense, {0}, 1}; se_input.measured_branch_from_power = { @@ -2081,7 +2079,7 @@ TEST_CASE("Test Observability - two nodes, one source, one load, one branch") { {.angle_measurement_type = AngleMeasurementType::local_angle, .measurement = {.real_component = {.value = 10.0, .variance = 100.0}, .imag_component = {.value = 0.0, .variance = 200.0}}}}; - check_observable(topo, std::move(param), se_input); + check_observable(topo, param, se_input); } SUBCASE("Power or current sensor only on branch into itself does not 'fix' the unobservability of the network") { topo.branch_bus_idx = {{0, 1}, {1, 1}}; diff --git a/tests/cpp_unit_tests/test_tap_position_optimizer.cpp b/tests/cpp_unit_tests/test_tap_position_optimizer.cpp index 4ae54d50f1..583a35d935 100644 --- a/tests/cpp_unit_tests/test_tap_position_optimizer.cpp +++ b/tests/cpp_unit_tests/test_tap_position_optimizer.cpp @@ -491,7 +491,7 @@ TEST_CASE("Test Transformer ranking") { {{Idx2D{.group = 3, .pos = 0}, Idx2D{.group = 3, .pos = 1}, Idx2D{.group = 4, .pos = 0}}, {Idx2D{.group = 3, .pos = 2}}, {Idx2D{.group = 3, .pos = 3}, Idx2D{.group = 3, .pos = 4}}}}; - CHECK(order == ref_order); + CHECK(std::ranges::equal(order, ref_order)); } } @@ -566,7 +566,7 @@ TEST_CASE("Test Transformer ranking") { {{Idx2D{.group = 3, .pos = 0}, Idx2D{.group = 3, .pos = 1}, Idx2D{.group = 4, .pos = 0}, Idx2D{.group = 3, .pos = 4}}, {Idx2D{.group = 3, .pos = 2}, Idx2D{.group = 3, .pos = 3}, Idx2D{.group = 3, .pos = 5}}}}; - CHECK(order == ref_order); + CHECK(std::ranges::equal(order, ref_order)); } SUBCASE("Full grid 3 - For Meshed grid with low priority ranks") { @@ -614,7 +614,7 @@ TEST_CASE("Test Transformer ranking") { pgm_tap::RankedTransformerGroups const order = pgm_tap::rank_transformers(state); pgm_tap::RankedTransformerGroups const ref_order{{{Idx2D{.group = 3, .pos = 0}}, {Idx2D{.group = 3, .pos = 2}}}, {{Idx2D{.group = 3, .pos = 1}}}}; - CHECK(order == ref_order); + CHECK(std::ranges::equal(order, ref_order)); } SUBCASE("Controlling from non source to source transformer") { @@ -744,6 +744,47 @@ TEST_CASE("Test Transformer ranking") { "source side:\n Transformer IDs: 10"); } } + + SUBCASE("Transformer into itself") { + // Test that a transformer with an edge to itself is handled correctly + // ========Test Grid======== + // [source 0] + // | + // [trafo 10] (WRONG: has an edge to itself) + // | + // [node 1] ==== [trafo 11] (into itself) + // | + // [trafo 12] + // | + // [node 2] + + TestState state; + std::vector const nodes{ + {.id = 0, .u_rated = 150e3}, {.id = 1, .u_rated = 10e3}, {.id = 2, .u_rated = 10e3}}; + main_core::add_component(state.components, nodes, 50.0); + + std::vector const transformers{get_transformer(10, 0, 1, BranchSide::from) + // Transformer with an edge to itself (wrong) + , + get_transformer(11, 1, 1, BranchSide::from), + get_transformer(12, 1, 2, BranchSide::from)}; + main_core::add_component(state.components, transformers, 50.0); + + std::vector const sources{SourceInput{.id = 20, .node = 0, .status = IntS{1}, .u_ref = 1.0}}; + main_core::add_component(state.components, sources, 50.0); + + std::vector const regulators{get_regulator(30, 10, ControlSide::to), + get_regulator(31, 11, ControlSide::to), + get_regulator(32, 12, ControlSide::to)}; + main_core::add_component(state.components, regulators, 50.0); + + state.components.set_construction_complete(); + + pgm_tap::RankedTransformerGroups const order = pgm_tap::rank_transformers(state); + pgm_tap::RankedTransformerGroups const ref_order{ + {Idx2D{.group = 3, .pos = 0}}, {Idx2D{.group = 3, .pos = 2}}, {{Idx2D{.group = 3, .pos = 1}}}}; + CHECK(std::ranges::equal(order, ref_order)); + } } namespace optimizer::tap_position_optimizer::test { From 45a257df92c554cdf626b459d69b0407ab73c417 Mon Sep 17 00:00:00 2001 From: Martijn Govers Date: Thu, 4 Jun 2026 08:14:59 +0200 Subject: [PATCH 17/17] Update tests/cpp_unit_tests/test_tap_position_optimizer.cpp Signed-off-by: Martijn Govers --- tests/cpp_unit_tests/test_tap_position_optimizer.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/cpp_unit_tests/test_tap_position_optimizer.cpp b/tests/cpp_unit_tests/test_tap_position_optimizer.cpp index 583a35d935..f4a755b42e 100644 --- a/tests/cpp_unit_tests/test_tap_position_optimizer.cpp +++ b/tests/cpp_unit_tests/test_tap_position_optimizer.cpp @@ -750,7 +750,7 @@ TEST_CASE("Test Transformer ranking") { // ========Test Grid======== // [source 0] // | - // [trafo 10] (WRONG: has an edge to itself) + // [trafo 10] // | // [node 1] ==== [trafo 11] (into itself) // | @@ -763,9 +763,7 @@ TEST_CASE("Test Transformer ranking") { {.id = 0, .u_rated = 150e3}, {.id = 1, .u_rated = 10e3}, {.id = 2, .u_rated = 10e3}}; main_core::add_component(state.components, nodes, 50.0); - std::vector const transformers{get_transformer(10, 0, 1, BranchSide::from) - // Transformer with an edge to itself (wrong) - , + std::vector const transformers{get_transformer(10, 0, 1, BranchSide::from), get_transformer(11, 1, 1, BranchSide::from), get_transformer(12, 1, 2, BranchSide::from)}; main_core::add_component(state.components, transformers, 50.0);