From 557815f5e18b57f02d52a598f90a97e522c3c0b3 Mon Sep 17 00:00:00 2001 From: Jerry Jinfeng Guo Date: Fri, 6 Mar 2026 19:42:19 +0100 Subject: [PATCH 1/7] branch init Signed-off-by: Jerry Jinfeng Guo From a6dba9316450ba9038641121ac179882ccd5fbba Mon Sep 17 00:00:00 2001 From: Jerry Jinfeng Guo Date: Fri, 6 Mar 2026 20:11:05 +0100 Subject: [PATCH 2/7] add observability result and use perturbation check Signed-off-by: Jerry Jinfeng Guo --- tests/cpp_unit_tests/test_observability.cpp | 141 ++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index 3163745439..ded690f5b5 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -2012,4 +2012,145 @@ TEST_CASE("Test Observability - Necessary check end to end test") { } } +TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable network") { + using power_grid_model::math_solver::observability::ObservabilityResult; + + SUBCASE("Non-observable network returns false for use_perturbation") { + // Create a meshed network with multiple voltage phasor sensors + // This triggers the early return at line 674-677: n_voltage_phasor_sensors > 1 && !topo.is_radial + // where is_observable = false but no exception is thrown + MathModelTopology topo; + topo.slack_bus = 0; + topo.is_radial = false; // Meshed network + topo.phase_shift = {0.0, 0.0, 0.0, 0.0}; + // Create a meshed network: bus0--bus1, bus1--bus2, bus2--bus3, bus3--bus0 + topo.branch_bus_idx = {{0, 1}, {1, 2}, {2, 3}, {3, 0}}; + topo.sources_per_bus = {from_sparse, {0, 1, 1, 1, 1}}; + topo.shunts_per_bus = {from_sparse, {0, 0, 0, 0, 0}}; + topo.load_gens_per_bus = {from_sparse, {0, 0, 0, 0, 0}}; + topo.power_sensors_per_bus = {from_sparse, {0, 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}}; + // Sufficient branch sensors to pass necessary condition + topo.power_sensors_per_branch_from = {from_sparse, {0, 1, 2, 3, 4}}; + topo.power_sensors_per_branch_to = {from_sparse, {0, 0, 0, 0, 0}}; + topo.current_sensors_per_branch_from = {from_sparse, {0, 0, 0, 0, 0}}; + topo.current_sensors_per_branch_to = {from_sparse, {0, 0, 0, 0, 0}}; + // TWO voltage phasor sensors (complex measurements with both magnitude and angle) + // This triggers the condition: n_voltage_phasor_sensors > 1 && !topo.is_radial + topo.voltage_sensors_per_bus = {from_sparse, {0, 1, 2, 2, 2}}; + + 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}, {1.0, -1.0, -1.0, 1.0}, {1.0, -1.0, -1.0, 1.0}}; + + StateEstimationInput se_input; + se_input.source_status = {1}; + // Two voltage PHASOR sensors (complex with both magnitude and angle) + se_input.measured_voltage = { + {.value = 1.0 + 0.1i, .variance = 1.0}, // Phasor at bus 0 + {.value = 0.95 + 0.05i, .variance = 1.0} // Phasor at bus 1 + }; + // Branch power measurements + se_input.measured_branch_from_power = { + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}, + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}, + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}, + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; + + auto topo_ptr = std::make_shared(topo); + auto param_ptr = std::make_shared const>(param); + YBus const y_bus{topo_ptr, param_ptr}; + math_solver::MeasuredValues const measured_values{*y_bus.shared_topology(), se_input}; + + // Verify that we have exactly 2 voltage phasor sensors (both with angle measurements) + Idx phasor_count = 0; + for (Idx bus = 0; bus < 4; ++bus) { + if (measured_values.has_voltage(bus) && measured_values.has_angle_measurement(bus)) { + ++phasor_count; + } + } + CHECK(phasor_count == 2); // Verify we have 2 voltage phasor sensors + + // Verify this is a meshed network (not radial) + CHECK(topo.is_radial == false); + + // Get the observability result - should return is_observable = false without throwing + // because of early return: n_voltage_phasor_sensors > 1 && !topo.is_radial + auto result = math_solver::observability::observability_check(measured_values, y_bus.math_topology(), + y_bus.y_bus_structure()); + + // Verify that is_observable is false (due to multiple voltage phasors in meshed network) + CHECK(result.is_observable == false); + + // Verify that use_perturbation() returns false when not observable + CHECK(result.use_perturbation() == false); + } + + SUBCASE("Observable but ill-conditioned network returns true for use_perturbation") { + // Create a simple 3-bus network with sufficient sensors (observable but possibly ill-conditioned) + 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}}; + // Add branch sensors to make it observable + 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}}; + + 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}}; + + 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}}, + {.real_component = {.value = 1.0, .variance = 1.0}, .imag_component = {.value = 0.0, .variance = 1.0}}}; + + auto topo_ptr = std::make_shared(topo); + auto param_ptr = std::make_shared const>(param); + YBus const y_bus{topo_ptr, param_ptr}; + math_solver::MeasuredValues const measured_values{*y_bus.shared_topology(), se_input}; + + auto result = math_solver::observability::observability_check(measured_values, y_bus.math_topology(), + y_bus.y_bus_structure()); + + // Verify that is_observable is true + CHECK(result.is_observable == true); + + // When observable and possibly ill-conditioned, use_perturbation should return true + if (result.is_possibly_ill_conditioned) { + CHECK(result.use_perturbation() == true); + } + } + + SUBCASE("Test use_perturbation logic directly") { + // Test the logic of use_perturbation() method directly + ObservabilityResult result1{.is_observable = false, .is_possibly_ill_conditioned = false}; + CHECK(result1.use_perturbation() == false); + + ObservabilityResult result2{.is_observable = false, .is_possibly_ill_conditioned = true}; + CHECK(result2.use_perturbation() == false); + + ObservabilityResult result3{.is_observable = true, .is_possibly_ill_conditioned = false}; + CHECK(result3.use_perturbation() == false); + + ObservabilityResult result4{.is_observable = true, .is_possibly_ill_conditioned = true}; + CHECK(result4.use_perturbation() == true); + } +} + } // namespace power_grid_model From 0d30494f4af5ff59a24543516138c11edb609a5a Mon Sep 17 00:00:00 2001 From: Jerry Guo <6221579+Jerry-Jinfeng-Guo@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:15:35 +0100 Subject: [PATCH 3/7] Update tests/cpp_unit_tests/test_observability.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jerry Guo <6221579+Jerry-Jinfeng-Guo@users.noreply.github.com> --- tests/cpp_unit_tests/test_observability.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index ded690f5b5..a2cd9b68de 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -2131,10 +2131,8 @@ TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable netwo // Verify that is_observable is true CHECK(result.is_observable == true); - // When observable and possibly ill-conditioned, use_perturbation should return true - if (result.is_possibly_ill_conditioned) { - CHECK(result.use_perturbation() == true); - } + // use_perturbation() should follow the invariant: is_observable && is_possibly_ill_conditioned + CHECK(result.use_perturbation() == (result.is_observable && result.is_possibly_ill_conditioned)); } SUBCASE("Test use_perturbation logic directly") { From 799997da9763dc1d82e47268c59f856ce44ea699 Mon Sep 17 00:00:00 2001 From: Jerry Guo <6221579+Jerry-Jinfeng-Guo@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:16:04 +0100 Subject: [PATCH 4/7] Update tests/cpp_unit_tests/test_observability.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jerry Guo <6221579+Jerry-Jinfeng-Guo@users.noreply.github.com> --- tests/cpp_unit_tests/test_observability.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index a2cd9b68de..37acc51d82 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -2017,7 +2017,7 @@ TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable netwo SUBCASE("Non-observable network returns false for use_perturbation") { // Create a meshed network with multiple voltage phasor sensors - // This triggers the early return at line 674-677: n_voltage_phasor_sensors > 1 && !topo.is_radial + // This triggers the early return for the condition n_voltage_phasor_sensors > 1 && !topo.is_radial, // where is_observable = false but no exception is thrown MathModelTopology topo; topo.slack_bus = 0; From 90f611d01bebec05342bd0116c13cdad886aa0a8 Mon Sep 17 00:00:00 2001 From: Jerry Jinfeng Guo Date: Fri, 6 Mar 2026 20:20:30 +0100 Subject: [PATCH 5/7] review comments Signed-off-by: Jerry Jinfeng Guo --- tests/cpp_unit_tests/test_observability.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index 37acc51d82..f1744779d3 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -2067,16 +2067,13 @@ TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable netwo // Verify that we have exactly 2 voltage phasor sensors (both with angle measurements) Idx phasor_count = 0; - for (Idx bus = 0; bus < 4; ++bus) { + for (Idx bus = 0; bus < topo.n_bus(); ++bus) { if (measured_values.has_voltage(bus) && measured_values.has_angle_measurement(bus)) { ++phasor_count; } } CHECK(phasor_count == 2); // Verify we have 2 voltage phasor sensors - // Verify this is a meshed network (not radial) - CHECK(topo.is_radial == false); - // Get the observability result - should return is_observable = false without throwing // because of early return: n_voltage_phasor_sensors > 1 && !topo.is_radial auto result = math_solver::observability::observability_check(measured_values, y_bus.math_topology(), @@ -2093,6 +2090,7 @@ TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable netwo // Create a simple 3-bus network with sufficient sensors (observable but possibly ill-conditioned) MathModelTopology topo; topo.slack_bus = 0; + topo.is_radial = true; 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}}; From 23bdb88959bde173c1bdb7a34a5e9c5e56038504 Mon Sep 17 00:00:00 2001 From: Jerry Jinfeng Guo Date: Fri, 6 Mar 2026 20:29:18 +0100 Subject: [PATCH 6/7] notes in readme Signed-off-by: Jerry Jinfeng Guo --- .../meshed-network-observability/README.md | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/tests/data/state_estimation/meshed-network-observability/README.md b/tests/data/state_estimation/meshed-network-observability/README.md index 2b55223330..482299ae32 100644 --- a/tests/data/state_estimation/meshed-network-observability/README.md +++ b/tests/data/state_estimation/meshed-network-observability/README.md @@ -5,14 +5,55 @@ SPDX-License-Identifier: MPL-2.0 # Observability check cases for N-node system -This module provides test cases for an N-node system with N-1 measurements. +This module provides test cases for meshed network observability checks. The grids in tests are illustrated in the `test_network_diagrams.svg` -Test cases in this directory demonstrate the only three possible scenarios: +## Test Cases -1. Observable system with branch measurement. -2. Observable system without branch measurement (using only nodal measurements). -3. Unobservable system with branch measurement (x2). +The following test cases are included: + +1. **01-observable-with-branch-measurement**: Observable system with branch power measurement +2. **02-unobservable-with-branch-measurement**: Unobservable system with branch power measurement +3. **03-observable-without-branch-measurement**: Observable system using only nodal measurements +4. **04-observable-without-branch-measurement**: Another observable system using only nodal measurements +5. **05-observable-with-branch-measurement-disconnected**: Observable system with disconnected components +and branch measurement +6. **06-unobservable-with-branch-measurement-disconnected**: Unobservable system with disconnected components +and branch measurement +7. **07-observable-2-voltage-sensors**: Meshed network with two voltage phasor sensors (see note below) Note: In theory, it is impossible to construct an unobservable case with only nodal measurements (i.e., without branch measurement). + +## Multiple Voltage Phasor Sensors in Meshed Networks + +Test case `07-observable-2-voltage-sensors` represents a meshed network with two voltage phasor sensors +(voltage measurements with both magnitude and angle). This configuration is currently treated as +**non-observable** due to a known limitation: the current meshed network sufficient-condition +implementation cannot handle multiple voltage phasor sensors. + +### Unit Test Coverage + +This edge case is comprehensively covered by unit tests in `tests/cpp_unit_tests/test_observability.cpp` +(specifically the test case "Test ObservabilityResult - use_perturbation with non-observable network"). +The unit tests verify: + +- Networks with `n_voltage_phasor_sensors > 1 && !is_radial` are correctly identified as non-observable +- The `ObservabilityResult.is_observable` flag is set to `false` +- The `use_perturbation()` method returns `false` (no perturbation applied to non-observable networks) +- The specific code path in `observability_check()` that triggers early return for this condition + +### Why No "Expected to Fail" Integration Test? + +An explicit integration test marked as "expected to fail" is **not necessary** because: + +1. **Unit test coverage is sufficient**: The unit tests provide precise, maintainable verification at the + appropriate level of granularity. +2. **Maintenance burden**: "Expected to fail" tests require special infrastructure and documentation, + and risk being forgotten when the limitation is eventually addressed. +3. **Rare use case**: Multiple voltage phasor sensors (with precise angle measurements) in meshed + networks are uncommon in real-world applications. + +When support for multiple voltage phasors in meshed networks is implemented, the unit tests can be +updated accordingly, and test case `07-observable-2-voltage-sensors` can be populated with expected +output values. From 33dc55d19f1b95823248de40532ef03c326306ff Mon Sep 17 00:00:00 2001 From: Jerry Jinfeng Guo Date: Mon, 9 Mar 2026 09:59:35 +0100 Subject: [PATCH 7/7] const Signed-off-by: Jerry Jinfeng Guo --- tests/cpp_unit_tests/test_observability.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/cpp_unit_tests/test_observability.cpp b/tests/cpp_unit_tests/test_observability.cpp index f1744779d3..5dab9f3426 100644 --- a/tests/cpp_unit_tests/test_observability.cpp +++ b/tests/cpp_unit_tests/test_observability.cpp @@ -2135,16 +2135,16 @@ TEST_CASE("Test ObservabilityResult - use_perturbation with non-observable netwo SUBCASE("Test use_perturbation logic directly") { // Test the logic of use_perturbation() method directly - ObservabilityResult result1{.is_observable = false, .is_possibly_ill_conditioned = false}; + const ObservabilityResult result1{.is_observable = false, .is_possibly_ill_conditioned = false}; CHECK(result1.use_perturbation() == false); - ObservabilityResult result2{.is_observable = false, .is_possibly_ill_conditioned = true}; + const ObservabilityResult result2{.is_observable = false, .is_possibly_ill_conditioned = true}; CHECK(result2.use_perturbation() == false); - ObservabilityResult result3{.is_observable = true, .is_possibly_ill_conditioned = false}; + const ObservabilityResult result3{.is_observable = true, .is_possibly_ill_conditioned = false}; CHECK(result3.use_perturbation() == false); - ObservabilityResult result4{.is_observable = true, .is_possibly_ill_conditioned = true}; + const ObservabilityResult result4{.is_observable = true, .is_possibly_ill_conditioned = true}; CHECK(result4.use_perturbation() == true); } }