diff --git a/docs/user_manual/components.md b/docs/user_manual/components.md index b3fd62ad8f..5968351858 100644 --- a/docs/user_manual/components.md +++ b/docs/user_manual/components.md @@ -792,6 +792,9 @@ The state estimator uses the data to evaluate the state of the grid with the hig A sensor only has output for state estimation. For other calculation types, sensor output is undefined. +The sensor output attribute `energized` is derived from whether the measured object is energized at the measured +terminal, regardless of whether the measurement is included in state estimation. +If the measured object is not energized at the measured terminal, all residual values are zero. ### Generic Voltage Sensor diff --git a/power_grid_model_c/power_grid_model/include/power_grid_model/main_core/output.hpp b/power_grid_model_c/power_grid_model/include/power_grid_model/main_core/output.hpp index 32cc803bf1..c4a7004b94 100644 --- a/power_grid_model_c/power_grid_model/include/power_grid_model/main_core/output.hpp +++ b/power_grid_model_c/power_grid_model/include/power_grid_model/main_core/output.hpp @@ -40,6 +40,49 @@ namespace detail { template concept assignable_to = std::assignable_from; +template +constexpr bool measured_component_active(MainModelState const& state, Idx const obj_seq, + StatusArgs... status_args) { + if constexpr (common::component_container_c) { + return get_component_by_sequence(state.components, obj_seq).status(status_args...); + } else { + // A missing component type makes its terminal type unreachable in a valid model. Keep reduced containers + // compatible with the previous topology-only behavior. + return true; + } +} + +template +constexpr bool measured_terminal_active(MeasuredTerminalType const terminal_type, + MainModelState const& state, Idx const obj_seq) { + switch (terminal_type) { + using enum MeasuredTerminalType; + + case branch_from: + return measured_component_active(state, obj_seq, BranchSide::from); + case branch_to: + return measured_component_active(state, obj_seq, BranchSide::to); + case source: + return measured_component_active(state, obj_seq); + case shunt: + return measured_component_active(state, obj_seq); + case load: + [[fallthrough]]; + case generator: + return measured_component_active(state, obj_seq); + case branch3_1: + return measured_component_active(state, obj_seq, Branch3Side::side_1); + case branch3_2: + return measured_component_active(state, obj_seq, Branch3Side::side_2); + case branch3_3: + return measured_component_active(state, obj_seq, Branch3Side::side_3); + case node: + return true; + default: + throw MissingCaseForEnumError{"measured_terminal_active()", terminal_type}; + } +} + template requires model_component_state_c && @@ -247,7 +290,7 @@ constexpr auto output_result(Component const& power_sensor, MainModelState(); } @@ -317,7 +360,7 @@ constexpr auto output_result(Component const& current_sensor, MainModelState(); } diff --git a/tests/cpp_unit_tests/main_core/test_main_core_output.cpp b/tests/cpp_unit_tests/main_core/test_main_core_output.cpp index be4564b24e..30d7d1e0eb 100644 --- a/tests/cpp_unit_tests/main_core/test_main_core_output.cpp +++ b/tests/cpp_unit_tests/main_core/test_main_core_output.cpp @@ -12,18 +12,215 @@ #include #include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include #include +#include #include +#include #include #include #include namespace power_grid_model::main_core { +namespace { + +using PowerSensorOutputComponents = + Container, + GenericBranch, ThreeWindingTransformer, Source, Shunt, SymGenerator, SymLoad, SymPowerSensor>; +using PowerSensorOutputState = MainModelState; + +auto make_partially_connected_branch3_input() -> ThreeWindingTransformerInput { + constexpr double u_rated = 10e3; + constexpr double rated_power = 10e6; + + return {.id = 14, + .node_1 = 100, + .node_2 = 101, + .node_3 = 102, + .status_1 = 1, + .status_2 = 0, + .status_3 = 1, + .u1 = u_rated, + .u2 = u_rated, + .u3 = u_rated, + .sn_1 = rated_power, + .sn_2 = rated_power, + .sn_3 = rated_power, + .uk_12 = 0.1, + .uk_13 = 0.1, + .uk_23 = 0.1, + .pk_12 = 10e3, + .pk_13 = 10e3, + .pk_23 = 10e3, + .i0 = 0.0, + .p0 = 0.0, + .winding_1 = WindingType::wye_n, + .winding_2 = WindingType::wye_n, + .winding_3 = WindingType::wye_n, + .clock_12 = 0, + .clock_13 = 0, + .tap_side = Branch3Side::side_1, + .tap_pos = 0, + .tap_min = 0, + .tap_max = 0, + .tap_nom = 0, + .tap_size = 0.0}; +} + +auto make_power_sensor_output_state() -> PowerSensorOutputState { + constexpr double u_rated = 10e3; + PowerSensorOutputState state; + + // These branches remain in the energized topology while one measured terminal is disconnected. + emplace_component(state.components, 9, + GenericBranchInput{.id = 9, + .from_node = 100, + .to_node = 101, + .from_status = 0, + .to_status = 1, + .r1 = 1.0, + .x1 = 1.0, + .g1 = 0.0, + .b1 = 0.0}, + u_rated, u_rated); + emplace_component(state.components, 14, make_partially_connected_branch3_input(), u_rated, + u_rated, u_rated); + + emplace_component(state.components, 10, SourceInput{.id = 10, .node = 100, .status = 1, .u_ref = 1.0}, + u_rated); + emplace_component(state.components, 11, + ShuntInput{.id = 11, .node = 100, .status = 1, .g1 = 0.0, .b1 = 0.0, .g0 = 0.0, .b0 = 0.0}, + u_rated); + emplace_component( + state.components, 12, + SymLoadGenInput{ + .id = 12, .node = 100, .status = 1, .type = LoadGenType::const_pq, .p_specified = 0.0, .q_specified = 0.0}, + u_rated); + emplace_component( + state.components, 13, + SymLoadGenInput{ + .id = 13, .node = 100, .status = 1, .type = LoadGenType::const_pq, .p_specified = 0.0, .q_specified = 0.0}, + u_rated); + + state.components.set_construction_complete(); + + auto coupling = std::make_shared(); + coupling->branch = {{.group = 0, .pos = 0}}; + coupling->branch3 = {{.group = 0, .pos = {1, 2, 3}}}; + coupling->source = {{.group = 0, .pos = 0}}; + coupling->shunt = {{.group = 0, .pos = 0}}; + coupling->load_gen = {{.group = 0, .pos = 0}, {.group = 0, .pos = 1}}; + coupling->node = {{.group = 0, .pos = 0}}; + state.topo_comp_coup = std::move(coupling); + + return state; +} + +template auto make_power_sensor_solver_output() { + std::vector> output(1); + output[0].u.resize(1); + output[0].bus_injection.resize(1); + output[0].branch.resize(4); + output[0].source.resize(1); + output[0].shunt.resize(1); + output[0].load_gen.resize(2); + return output; +} + +template void check_zero(RealValue const& value) { + if constexpr (is_symmetric_v) { + CHECK(value == 0.0); + } else { + for (Idx phase = 0; phase != 3; ++phase) { + CAPTURE(phase); + CHECK(value[phase] == 0.0); + } + } +} + +template void check_null_power_sensor_output(PowerSensorOutput const& output) { + CHECK(output.energized == 0); + check_zero(output.p_residual); + check_zero(output.q_residual); +} + +using CurrentSensorOutputComponents = + Container, GenericBranch, + ThreeWindingTransformer, Node, SymCurrentSensor>; +using CurrentSensorOutputState = MainModelState; + +auto make_current_sensor_output_state() -> CurrentSensorOutputState { + constexpr double u_rated = 10e3; + CurrentSensorOutputState state; + + emplace_component(state.components, 9, + GenericBranchInput{.id = 9, + .from_node = 100, + .to_node = 101, + .from_status = 0, + .to_status = 1, + .r1 = 1.0, + .x1 = 1.0, + .g1 = 0.0, + .b1 = 0.0}, + u_rated, u_rated); + emplace_component(state.components, 14, make_partially_connected_branch3_input(), u_rated, + u_rated, u_rated); + emplace_component(state.components, 100, NodeInput{.id = 100, .u_rated = u_rated}); + emplace_component(state.components, 101, NodeInput{.id = 101, .u_rated = u_rated}); + emplace_component(state.components, 102, NodeInput{.id = 102, .u_rated = u_rated}); + state.components.set_construction_complete(); + + auto topology = std::make_shared(); + topology->branch_node_idx = {{0, 1}}; + topology->branch3_node_idx = {{0, 1, 2}}; + state.comp_topo = std::move(topology); + + auto coupling = std::make_shared(); + coupling->branch = {{.group = 0, .pos = 0}}; + // The branch position is not consumed when the disconnected-terminal guard returns. Reusing position 0 makes + // the regression fail with an energized output, rather than invalid indexing, if that guard is removed. + coupling->branch3 = {{.group = 0, .pos = {0, 0, 0}}}; + coupling->node = {{.group = 0, .pos = 0}, {.group = 0, .pos = 1}, {.group = 0, .pos = 2}}; + state.topo_comp_coup = std::move(coupling); + + return state; +} + +template auto make_current_sensor_solver_output() { + std::vector> output(1); + output[0].u = {ComplexValue{1.0}, ComplexValue{1.0}}; + output[0].branch.resize(1); + output[0].branch[0].i_f = ComplexValue{1.0}; + output[0].branch[0].i_t = ComplexValue{1.0}; + return output; +} + +template void check_null_current_sensor_output(CurrentSensorOutput const& output) { + CHECK(output.energized == 0); + check_zero(output.i_residual); + check_zero(output.i_angle_residual); +} + +} // namespace + TEST_CASE("Test main core output") { SUBCASE("TransformerTapRegulator") { using ComponentContainer = Container, TransformerTapRegulator>; @@ -106,4 +303,193 @@ TEST_CASE("Test main core output") { } } } + +TEST_CASE_TEMPLATE("Test main core power sensor output energized state", sym, symmetric_t, asymmetric_t) { + auto state = make_power_sensor_output_state(); + auto const solver_output = make_power_sensor_solver_output(); + + auto check_appliance = [&](MeasuredTerminalType terminal_type, ID object_id, Idx object_sequence, + Appliance& appliance) { + SymPowerSensor const sensor{{.id = 20, + .measured_object = object_id, + .measured_terminal_type = terminal_type, + .power_sigma = std::numeric_limits::infinity(), + .p_measured = 1.0, + .q_measured = 2.0}}; + + // An excluded measurement remains energized while its measured object is active. + CHECK(output_result(sensor, state, solver_output, object_sequence).energized == 1); + + appliance.set_status(0); + + check_null_power_sensor_output(output_result(sensor, state, solver_output, object_sequence)); + }; + + check_appliance(MeasuredTerminalType::source, 10, 0, get_component(state.components, ID{10})); + check_appliance(MeasuredTerminalType::shunt, 11, 0, get_component(state.components, ID{11})); + check_appliance(MeasuredTerminalType::generator, 12, + get_component_sequence_idx(state.components, ID{12}), + get_component(state.components, ID{12})); + check_appliance(MeasuredTerminalType::load, 13, + get_component_sequence_idx(state.components, ID{13}), + get_component(state.components, ID{13})); + + auto check_branch_terminal = [&](MeasuredTerminalType const terminal_type, ID const measured_object, + bool const expected_energized) { + SymPowerSensor const sensor{{.id = 21, + .measured_object = measured_object, + .measured_terminal_type = terminal_type, + .power_sigma = 1.0, + .p_measured = 1.0, + .q_measured = 2.0}}; + + auto const output = output_result(sensor, state, solver_output, 0); + if (expected_energized) { + CHECK(output.energized == 1); + } else { + check_null_power_sensor_output(output); + } + }; + + // Topology construction has already resolved the measured object to obj_seq. The component remains in an + // energized topology group, but output on a disconnected measured terminal is null. + check_branch_terminal(MeasuredTerminalType::branch_from, 9, false); + check_branch_terminal(MeasuredTerminalType::branch_to, 9, true); + check_branch_terminal(MeasuredTerminalType::branch3_1, 14, true); + check_branch_terminal(MeasuredTerminalType::branch3_2, 14, false); + check_branch_terminal(MeasuredTerminalType::branch3_3, 14, true); + + auto disconnected_coupling = std::make_shared(*state.topo_comp_coup); + disconnected_coupling->branch = {{.group = disconnected, .pos = disconnected}}; + disconnected_coupling->branch3 = {{.group = disconnected, .pos = {disconnected, disconnected, disconnected}}}; + state.topo_comp_coup = std::move(disconnected_coupling); + + for (auto const terminal_type : + {MeasuredTerminalType::branch_from, MeasuredTerminalType::branch_to, MeasuredTerminalType::branch3_1, + MeasuredTerminalType::branch3_2, MeasuredTerminalType::branch3_3}) { + CAPTURE(terminal_type); + + auto const measured_object = + terminal_type == MeasuredTerminalType::branch_from || terminal_type == MeasuredTerminalType::branch_to + ? ID{9} + : ID{14}; + + SymPowerSensor const sensor{{.id = 22, + .measured_object = measured_object, + .measured_terminal_type = terminal_type, + .power_sigma = 1.0, + .p_measured = 1.0, + .q_measured = 2.0}}; + + check_null_power_sensor_output(output_result(sensor, state, solver_output, 0)); + } +} + +TEST_CASE_TEMPLATE("Test main core power sensor output with reduced component container", sym, symmetric_t, + asymmetric_t) { + // A reduced main-core state must not instantiate lookups for appliance types that its container omits. + using ComponentContainer = + Container, GenericBranch, SymPowerSensor>; + static_assert(!common::component_container_c); + static_assert(!common::component_container_c); + static_assert(!common::component_container_c); + static_assert(!common::component_container_c); + + MainModelState state; + emplace_component(state.components, 9, + GenericBranchInput{.id = 9, + .from_node = 100, + .to_node = 101, + .from_status = 1, + .to_status = 1, + .r1 = 1.0, + .x1 = 1.0, + .g1 = 0.0, + .b1 = 0.0}, + 10e3, 10e3); + state.components.set_construction_complete(); + + auto coupling = std::make_shared(); + coupling->branch = {{.group = 0, .pos = 0}}; + state.topo_comp_coup = std::move(coupling); + + std::vector> solver_output(1); + solver_output[0].branch.resize(1); + + SymPowerSensor const sensor{{.id = 23, + .measured_object = 9, + .measured_terminal_type = MeasuredTerminalType::branch_from, + .power_sigma = 1.0, + .p_measured = 1.0, + .q_measured = 2.0}}; + + CHECK(output_result(sensor, state, solver_output, 0).energized == 1); +} + +TEST_CASE_TEMPLATE("Test main core current sensor output energized state", sym, symmetric_t, asymmetric_t) { + constexpr double u_rated = 10e3; + + auto state = make_current_sensor_output_state(); + auto const solver_output = make_current_sensor_solver_output(); + + for (auto const terminal_type : {MeasuredTerminalType::branch_from, MeasuredTerminalType::branch_to}) { + CAPTURE(terminal_type); + + SymCurrentSensor const sensor{{.id = 30, + .measured_object = 9, + .measured_terminal_type = terminal_type, + .angle_measurement_type = AngleMeasurementType::local_angle, + .i_sigma = 1.0, + .i_angle_sigma = 1.0, + .i_measured = 1.0, + .i_angle_measured = 0.0}, + u_rated}; + + auto const output = output_result(sensor, state, solver_output, 0); + if (terminal_type == MeasuredTerminalType::branch_from) { + check_null_current_sensor_output(output); + } else { + CHECK(output.energized == 1); + } + } + + // The branch3 remains connected through sides 1 and 3, but a sensor on disconnected side 2 has null output. + SymCurrentSensor const branch3_sensor{{.id = 31, + .measured_object = 14, + .measured_terminal_type = MeasuredTerminalType::branch3_2, + .angle_measurement_type = AngleMeasurementType::local_angle, + .i_sigma = 1.0, + .i_angle_sigma = 1.0, + .i_measured = 1.0, + .i_angle_measured = 0.0}, + u_rated}; + check_null_current_sensor_output(output_result(branch3_sensor, state, solver_output, 0)); + + auto disconnected_coupling = std::make_shared(*state.topo_comp_coup); + disconnected_coupling->branch = {{.group = disconnected, .pos = disconnected}}; + disconnected_coupling->branch3 = {{.group = disconnected, .pos = {disconnected, disconnected, disconnected}}}; + state.topo_comp_coup = std::move(disconnected_coupling); + + for (auto const terminal_type : + {MeasuredTerminalType::branch_from, MeasuredTerminalType::branch_to, MeasuredTerminalType::branch3_1, + MeasuredTerminalType::branch3_2, MeasuredTerminalType::branch3_3}) { + CAPTURE(terminal_type); + + auto const measured_object = + terminal_type == MeasuredTerminalType::branch_from || terminal_type == MeasuredTerminalType::branch_to + ? ID{9} + : ID{14}; + SymCurrentSensor const sensor{{.id = 32, + .measured_object = measured_object, + .measured_terminal_type = terminal_type, + .angle_measurement_type = AngleMeasurementType::local_angle, + .i_sigma = 1.0, + .i_angle_sigma = 1.0, + .i_measured = 1.0, + .i_angle_measured = 0.0}, + u_rated}; + + check_null_current_sensor_output(output_result(sensor, state, solver_output, 0)); + } +} } // namespace power_grid_model::main_core diff --git a/tests/data/state_estimation/dummy-test-line-into-itself-minimal/params.json b/tests/data/state_estimation/dummy-test-line-into-itself-minimal/params.json index ee1be87236..5937acfff8 100644 --- a/tests/data/state_estimation/dummy-test-line-into-itself-minimal/params.json +++ b/tests/data/state_estimation/dummy-test-line-into-itself-minimal/params.json @@ -22,9 +22,5 @@ "p_residual": 2e-3, "q_residual": 1e-2, "u_residual": 1e-3 - }, - "xfail": { - "raises": "AssertionError", - "reason": "Energized behaves differently between sensors on disabled shunts and on disabled branches (https://github.com/PowerGridModel/power-grid-model/issues/1464)" } } diff --git a/tests/data/state_estimation/dummy-test-line-into-itself-minimal/sym_output_batch.json b/tests/data/state_estimation/dummy-test-line-into-itself-minimal/sym_output_batch.json index 514a890724..f2d42166b5 100644 --- a/tests/data/state_estimation/dummy-test-line-into-itself-minimal/sym_output_batch.json +++ b/tests/data/state_estimation/dummy-test-line-into-itself-minimal/sym_output_batch.json @@ -40,11 +40,11 @@ ], "asym_power_sensor": [ {"id": 16, "energized": 1, "p_residual": -7200.0068024819684, "q_residual": 71980.068024819659}, - {"id": 17, "energized": 0, "p_residual": 6924, "q_residual": -69280} + {"id": 17, "energized": 0, "p_residual": 0, "q_residual": 0} ], "sym_voltage_sensor": [ {"id": 13, "energized": 1, "u_residual": 236.54115191760306, "u_angle_residual": -2.1175823681357508e-22} ] } ] -} \ No newline at end of file +} diff --git a/tests/data/state_estimation/dummy-test-line-into-itself/asym_output_batch.json b/tests/data/state_estimation/dummy-test-line-into-itself/asym_output_batch.json index bbfc643553..26266a8abd 100644 --- a/tests/data/state_estimation/dummy-test-line-into-itself/asym_output_batch.json +++ b/tests/data/state_estimation/dummy-test-line-into-itself/asym_output_batch.json @@ -71,12 +71,12 @@ {"id": 13, "energized": 1, "p_residual": [-51770.4106748376, -51847.591438489362, -51839.393964931936], "q_residual": [-25635.054141311452, -25643.885638187403, -25585.132207542603]}, {"id": 15, "energized": 1, "p_residual": [-10156.957454722986, -10157.477366083891, -10155.854711926075], "q_residual": [101562.71307273903, 101562.20801004104, 101562.97424445581]}, {"id": 16, "energized": 1, "p_residual": [-10110.025940894742, -10194.044413720736, -10080.765281441463], "q_residual": [101307.65023838131, 101349.52127187414, 101201.17907509189]}, - {"id": 17, "energized": 0, "p_residual": [2308, 2306, 2310], "q_residual": [-23090, -23090, -23090]}, - {"id": 18, "energized": 0, "p_residual": [2304, 2310, 2296], "q_residual": [-23040, -23040, -23000]} + {"id": 17, "energized": 0, "p_residual": [0, 0, 0], "q_residual": [0, 0, 0]}, + {"id": 18, "energized": 0, "p_residual": [0, 0, 0], "q_residual": [0, 0, 0]} ], "asym_voltage_sensor": [ {"id": 11, "energized": 1, "u_residual": [61.921998446203276, 61.934057956168168, 61.904051592784221], "u_angle_residual": [-6.0767855306912206e-07, 3.0867399622991343e-06, -2.4826691844559434e-06]} ] } ] -} \ No newline at end of file +} diff --git a/tests/data/state_estimation/dummy-test-line-into-itself/params.json b/tests/data/state_estimation/dummy-test-line-into-itself/params.json index c710a35720..7fc8533ac8 100644 --- a/tests/data/state_estimation/dummy-test-line-into-itself/params.json +++ b/tests/data/state_estimation/dummy-test-line-into-itself/params.json @@ -23,9 +23,5 @@ "q_residual": 2e1, "u_residual": 1e-2, "u_angle_residual": 1e-5 - }, - "xfail": { - "raises": "AssertionError", - "reason": "Energized behaves differently between sensors on disabled shunts and on disabled branches (https://github.com/PowerGridModel/power-grid-model/issues/1464)" } }