diff --git a/.pre-commit-hooks/sensitive-files-whitelist.txt b/.pre-commit-hooks/sensitive-files-whitelist.txt index c4b6e77d1..ba65d602b 100644 --- a/.pre-commit-hooks/sensitive-files-whitelist.txt +++ b/.pre-commit-hooks/sensitive-files-whitelist.txt @@ -48,6 +48,14 @@ packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.zip packages/dc_solver_pkg/tests/files/test_grid_node_breaker/nminus1_definition.json packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/test_ucte_powsybl_example.xiidm packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/asset_topology.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information_stats.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/unsplit_ac_metrics.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/orao_summary.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/modified_network.xiidm +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_metrics.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/res.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json +packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set_diffs.hdf5 # Grid helpers package test files packages/grid_helpers_pkg/tests/files/test_ucte_powsybl_example.uct diff --git a/data/test_grid_node_breaker/initial_topology/asset_topology.json b/data/test_grid_node_breaker/initial_topology/asset_topology.json index 1083d710e..855e3e2a8 100644 --- a/data/test_grid_node_breaker/initial_topology/asset_topology.json +++ b/data/test_grid_node_breaker/initial_topology/asset_topology.json @@ -1,420 +1,487 @@ -{ - "topology_id": "test_ucte_powsybl_example.xiidm", - "grid_model_file": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/data/Test_Grid_asset_topo/test_ucte_powsybl_example.xiidm", - "name": null, - "stations": [ - { - "grid_model_id": "VL2_0", - "name": "VLevel2", - "type": null, - "region": "BE", - "voltage_level": 225.0, - "busbars": [ - { - "grid_model_id": "BBS2_1", - "type": "busbar", - "name": "bus1", - "int_id": 0, - "in_service": true - }, - { - "grid_model_id": "BBS2_2", - "type": "busbar", - "name": "bus2", - "int_id": 1, - "in_service": true - }, - { - "grid_model_id": "BBS2_3", - "type": "busbar", - "name": "bus3", - "int_id": 2, - "in_service": true - } - ], - "couplers": [ - { - "grid_model_id": "VL2_BREAKER", - "type": "BREAKER", - "name": "VL2_BREAKER", - "busbar_from_id": 0, - "busbar_to_id": 1, - "open": false, - "in_service": true, - "asset_bay": null - }, - { - "grid_model_id": "VL2_BREAKER#0", - "type": "BREAKER", - "name": "VL2_BREAKER#0", - "busbar_from_id": 1, - "busbar_to_id": 2, - "open": false, - "in_service": true, - "asset_bay": null - } - ], - "assets": [ - { - "grid_model_id": "L1", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L12_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L12_DISCONNECTOR_4_0", - "BBS2_2": "L12_DISCONNECTOR_4_1", - "BBS2_3": "L12_DISCONNECTOR_4_2" - } - } - }, - { - "grid_model_id": "L2", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L22_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L22_DISCONNECTOR_6_0", - "BBS2_2": "L22_DISCONNECTOR_6_1", - "BBS2_3": "L22_DISCONNECTOR_6_2" - } - } - }, - { - "grid_model_id": "L6", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L61_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L61_DISCONNECTOR_8_0", - "BBS2_2": "L61_DISCONNECTOR_8_1", - "BBS2_3": "L61_DISCONNECTOR_8_2" - } - } - }, - { - "grid_model_id": "L7", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L71_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L71_DISCONNECTOR_10_0", - "BBS2_2": "L71_DISCONNECTOR_10_1", - "BBS2_3": "L71_DISCONNECTOR_10_2" - } - } - }, - { - "grid_model_id": "L8", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L81_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L81_DISCONNECTOR_12_0", - "BBS2_2": "L81_DISCONNECTOR_12_1", - "BBS2_3": "L81_DISCONNECTOR_12_2" - } - } - }, - { - "grid_model_id": "load1", - "type": "LOAD", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "load1_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "load1_DISCONNECTOR_18_0", - "BBS2_2": "load1_DISCONNECTOR_18_1", - "BBS2_3": "load1_DISCONNECTOR_18_2" - } - } - } - ], - "asset_switching_table": [ - [ - true, - false, - true, - false, - true, - true - ], - [ - false, - true, - false, - true, - false, - false - ], - [ - false, - false, - false, - false, - false, - false - ] - ], - "asset_connectivity": [ - [ - true, - true, - true, - true, - true, - true - ], - [ - true, - true, - true, - true, - true, - true - ], - [ - true, - true, - true, - true, - true, - true - ] - ], - "model_log": [] - }, - { - "grid_model_id": "VL3_0", - "name": "VLevel3", - "type": null, - "region": "BE", - "voltage_level": 225.0, - "busbars": [ - { - "grid_model_id": "BBS3_1", - "type": "busbar", - "name": "bus1", - "int_id": 0, - "in_service": true - }, - { - "grid_model_id": "BBS3_2", - "type": "busbar", - "name": "bus2", - "int_id": 1, - "in_service": true - }, - { - "grid_model_id": "BBS3_3", - "type": "busbar", - "name": "bus3", - "int_id": 2, - "in_service": true - }, - { - "grid_model_id": "BBS3_4", - "type": "busbar", - "name": "bus4", - "int_id": 3, - "in_service": true - } - ], - "couplers": [ - { - "grid_model_id": "VL3_BREAKER", - "type": "BREAKER", - "name": "VL3_BREAKER", - "busbar_from_id": 0, - "busbar_to_id": 1, - "open": false, - "in_service": true, - "asset_bay": null - }, - { - "grid_model_id": "VL3_BREAKER#0", - "type": "BREAKER", - "name": "VL3_BREAKER#0", - "busbar_from_id": 0, - "busbar_to_id": 2, - "open": false, - "in_service": true, - "asset_bay": null - }, - { - "grid_model_id": "VL3_BREAKER#1", - "type": "BREAKER", - "name": "VL3_BREAKER#1", - "busbar_from_id": 1, - "busbar_to_id": 3, - "open": false, - "in_service": true, - "asset_bay": null - } - ], - "assets": [ - { - "grid_model_id": "L3", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L32_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L32_DISCONNECTOR_3_0", - "BBS3_2": "L32_DISCONNECTOR_3_1" - } - } - }, - { - "grid_model_id": "L6", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L62_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L62_DISCONNECTOR_5_0", - "BBS3_2": "L62_DISCONNECTOR_5_1" - } - } - }, - { - "grid_model_id": "L7", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L72_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L72_DISCONNECTOR_7_0", - "BBS3_2": "L72_DISCONNECTOR_7_1" - } - } - }, - { - "grid_model_id": "L9", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L91_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L91_DISCONNECTOR_9_0", - "BBS3_2": "L91_DISCONNECTOR_9_1" - } - } - }, - { - "grid_model_id": "load2", - "type": "LOAD", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "load2_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "load2_DISCONNECTOR_13_0", - "BBS3_2": "load2_DISCONNECTOR_13_1" - } - } - } - ], - "asset_switching_table": [ - [ - true, - true, - false, - true, - false - ], - [ - false, - false, - true, - false, - true - ], - [ - false, - false, - false, - false, - false - ], - [ - false, - false, - false, - false, - false - ] - ], - "asset_connectivity": [ - [ - true, - true, - true, - true, - true - ], - [ - true, - true, - true, - true, - true - ], - [ - false, - false, - false, - false, - false - ], - [ - false, - false, - false, - false, - false - ] - ], - "model_log": [] - } - ], - "asset_setpoints": null, - "timestamp": "2025-05-16T15:25:10.814573", - "metrics": null -} +{ + "topology_id": "test_ucte_powsybl_example.xiidm", + "grid_model_file": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/data/Test_Grid_asset_topo/test_ucte_powsybl_example.xiidm", + "name": null, + "raw_stations": [ + { + "grid_model_id": "VL2_0", + "name": "VLevel2", + "type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS2_1", + "type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": null + }, + { + "grid_model_id": "BBS2_2", + "type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": null + }, + { + "grid_model_id": "BBS2_3", + "type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": null + } + ], + "couplers": [ + { + "grid_model_id": "VL2_BREAKER", + "type": "BREAKER", + "name": "VL2_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL2_BREAKER#0", + "type": "BREAKER", + "name": "VL2_BREAKER#0", + "busbar_from_id": 1, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "asset_switching_table": [ + [ + true, + false, + true, + false, + true, + true + ], + [ + false, + true, + false, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false + ] + ], + "asset_connectivity": [ + [ + true, + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true, + true + ] + ], + "model_log": [], + "asset_ids": [ + "L1", + "L2", + "L6", + "L7", + "L8", + "load1" + ], + "asset_branch_ends": [ + null, + null, + null, + null, + null, + null + ], + "asset_bay_ids": [ + "VL2_0::L1::bay", + "VL2_0::L2::bay", + "VL2_0::L6::bay", + "VL2_0::L7::bay", + "VL2_0::L8::bay", + "VL2_0::load1::bay" + ] + }, + { + "grid_model_id": "VL3_0", + "name": "VLevel3", + "type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS3_1", + "type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": null + }, + { + "grid_model_id": "BBS3_2", + "type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": null + }, + { + "grid_model_id": "BBS3_3", + "type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": null + }, + { + "grid_model_id": "BBS3_4", + "type": "busbar", + "name": "bus4", + "int_id": 3, + "in_service": true, + "bus_branch_bus_id": null + } + ], + "couplers": [ + { + "grid_model_id": "VL3_BREAKER", + "type": "BREAKER", + "name": "VL3_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL3_BREAKER#0", + "type": "BREAKER", + "name": "VL3_BREAKER#0", + "busbar_from_id": 0, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL3_BREAKER#1", + "type": "BREAKER", + "name": "VL3_BREAKER#1", + "busbar_from_id": 1, + "busbar_to_id": 3, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "asset_switching_table": [ + [ + true, + true, + false, + true, + false + ], + [ + false, + false, + true, + false, + true + ], + [ + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false + ] + ], + "asset_connectivity": [ + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ], + [ + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false + ] + ], + "model_log": [], + "asset_ids": [ + "L3", + "L6", + "L7", + "L9", + "load2" + ], + "asset_branch_ends": [ + null, + null, + null, + null, + null + ], + "asset_bay_ids": [ + "VL3_0::L3::bay", + "VL3_0::L6::bay", + "VL3_0::L7::bay", + "VL3_0::L9::bay", + "VL3_0::load2::bay" + ] + } + ], + "assets": [ + { + "grid_model_id": "L1", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "L2", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "L6", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "L7", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "L8", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "load1", + "type": "LOAD", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "L3", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "L9", + "type": "LINE", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + }, + { + "grid_model_id": "load2", + "type": "LOAD", + "name": "", + "in_service": true, + "branch_end": null, + "asset_bay": null, + "asset_bay_id": null + } + ], + "asset_bays": [ + { + "asset_bay_id": "VL2_0::L1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L12_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L12_DISCONNECTOR_4_0", + "BBS2_2": "L12_DISCONNECTOR_4_1", + "BBS2_3": "L12_DISCONNECTOR_4_2" + } + }, + { + "asset_bay_id": "VL2_0::L2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L22_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L22_DISCONNECTOR_6_0", + "BBS2_2": "L22_DISCONNECTOR_6_1", + "BBS2_3": "L22_DISCONNECTOR_6_2" + } + }, + { + "asset_bay_id": "VL2_0::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L61_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L61_DISCONNECTOR_8_0", + "BBS2_2": "L61_DISCONNECTOR_8_1", + "BBS2_3": "L61_DISCONNECTOR_8_2" + } + }, + { + "asset_bay_id": "VL2_0::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L71_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L71_DISCONNECTOR_10_0", + "BBS2_2": "L71_DISCONNECTOR_10_1", + "BBS2_3": "L71_DISCONNECTOR_10_2" + } + }, + { + "asset_bay_id": "VL2_0::L8::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L81_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L81_DISCONNECTOR_12_0", + "BBS2_2": "L81_DISCONNECTOR_12_1", + "BBS2_3": "L81_DISCONNECTOR_12_2" + } + }, + { + "asset_bay_id": "VL2_0::load1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load1_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "load1_DISCONNECTOR_18_0", + "BBS2_2": "load1_DISCONNECTOR_18_1", + "BBS2_3": "load1_DISCONNECTOR_18_2" + } + }, + { + "asset_bay_id": "VL3_0::L3::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L32_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L32_DISCONNECTOR_3_0", + "BBS3_2": "L32_DISCONNECTOR_3_1" + } + }, + { + "asset_bay_id": "VL3_0::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L62_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L62_DISCONNECTOR_5_0", + "BBS3_2": "L62_DISCONNECTOR_5_1" + } + }, + { + "asset_bay_id": "VL3_0::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L72_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L72_DISCONNECTOR_7_0", + "BBS3_2": "L72_DISCONNECTOR_7_1" + } + }, + { + "asset_bay_id": "VL3_0::L9::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L91_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L91_DISCONNECTOR_9_0", + "BBS3_2": "L91_DISCONNECTOR_9_1" + } + }, + { + "asset_bay_id": "VL3_0::load2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load2_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "load2_DISCONNECTOR_13_0", + "BBS3_2": "load2_DISCONNECTOR_13_1" + } + } + ], + "asset_setpoints": null, + "timestamp": "2025-05-16T15:25:10.814573", + "metrics": null +} diff --git a/docs/interfaces/asset_topology.md b/docs/interfaces/asset_topology.md index 12ad1337d..8f8f7ef7d 100644 --- a/docs/interfaces/asset_topology.md +++ b/docs/interfaces/asset_topology.md @@ -10,11 +10,16 @@ Asset Topology is essential when stations do not allow free assignment of lines Collection of time steps, each represented by a [`Topology`][toop_engine_interfaces.asset_topology.Topology]. - [`Topology`][toop_engine_interfaces.asset_topology.Topology] - Contains multiple [`Station`][toop_engine_interfaces.asset_topology.Station] objects and optional [`AssetSetpoint`][toop_engine_interfaces.asset_topology.AssetSetpoint] objects. + Stores lean [`RawStation`][toop_engine_interfaces.asset_topology.RawStation] records in `raw_stations`, topology-owned canonical branch and injection assets in `branch_assets` and `injection_assets`, and optional [`AssetSetpoint`][toop_engine_interfaces.asset_topology.AssetSetpoint] objects. Rich [`MaterializedStation`][toop_engine_interfaces.asset_topology.MaterializedStation] objects are reconstructed with [`Topology.materialize_stations()`][toop_engine_interfaces.asset_topology.Topology.materialize_stations]. -- [`Station`][toop_engine_interfaces.asset_topology.Station] +- [`MaterializedStation`][toop_engine_interfaces.asset_topology.MaterializedStation] Contains lists of [`Busbar`][toop_engine_interfaces.asset_topology.Busbar], [`BusbarCoupler`][toop_engine_interfaces.asset_topology.BusbarCoupler], and [`SwitchableAsset`][toop_engine_interfaces.asset_topology.SwitchableAsset]. - Includes asset_switching_table, the current switch connection layout and asset_connectivity, all possible selections. + Includes `branch_switching_table` and `injection_switching_table` for the current switch connection layout, plus `branch_connectivity` and `injection_connectivity` for physically allowed selections. + The `grid_model_id` refers to the bus-branch bus id of the splitable station view, not to the full voltage level id. + +- [`RawStation`][toop_engine_interfaces.asset_topology.RawStation] + Stores the lean station representation used inside [`Topology`][toop_engine_interfaces.asset_topology.Topology]. + Instead of embedded asset payloads it stores aligned station-local `branch_connections` and `injection_connections` arrays referencing topology-owned assets and asset bays. - [`Busbar`][toop_engine_interfaces.asset_topology.Busbar] Represents a single busbar in a station. @@ -24,7 +29,7 @@ Asset Topology is essential when stations do not allow free assignment of lines Note: the current implementation only supports a busbar connection between two busbars. It is planned to add an asset bay for both connection sides. - [`SwitchableAsset`][toop_engine_interfaces.asset_topology.SwitchableAsset] - Represents an asset (line, transformer, generator, etc.) that can be switched. You may leave out non-switchable assets or assign them to a single busbar to have a complete representation of the physical [`Station`][toop_engine_interfaces.asset_topology.Station]. + Represents an asset (line, transformer, generator, etc.) that can be switched. You may leave out non-switchable assets or assign them to a single busbar to have a complete representation of the physical [`MaterializedStation`][toop_engine_interfaces.asset_topology.MaterializedStation]. - [`AssetBay`][toop_engine_interfaces.asset_topology.AssetBay] Describes the physical connection (switches) between an asset and busbars. It may contain breaker and disconnector switches. @@ -49,7 +54,7 @@ Asset Topology is essential when stations do not allow free assignment of lines - [`AssetSetpoint`][toop_engine_interfaces.asset_topology.AssetSetpoint] Represents an asset with a setpoint (e.g., PST or HVDC). -- [`RealizedStation`][toop_engine_interfaces.asset_topology.RealizedStation] +- [`AppliedStation`][toop_engine_interfaces.asset_topology.AppliedStation] Contains a station and the changes made to it. - [`RealizedTopology`][toop_engine_interfaces.asset_topology.RealizedTopology] @@ -63,6 +68,18 @@ See the [`Asset Topology Reference`][toop_engine_interfaces.asset_topology] for ## How to use / Implementation +## Station Identity And Asset Scope + +The intended station contract is bus-view based: + +- `MaterializedStation.grid_model_id` and `RawStation.grid_model_id` identify the bus-branch bus id of the splitable station view. +- The station busbars belong to that bus view and therefore must carry matching `bus_branch_bus_id` values. +- The station-local asset arrays and switching tables describe which assets are visible in that station view and how they attach locally. + +The current importer implementations are not fully uniform yet: + +- The bus-breaker powsybl helper narrows busbars and assets to the selected `bus_id` before building the station view. +- The node-breaker powsybl importer currently assigns a bus-specific `MaterializedStation.grid_model_id` but still derives the asset list from the full substation graph. This is broader than the intended "single bus-branch bus" scope and should be treated as current implementation behavior, not as the desired long-term contract. To populate Asset Topology data from grid models, use the [`Network Graph module`][toop_engine_importer.network_graph]. A Pandapower to Asset Topology implementation is found in the importer: [`get_list_of_stations_ids`][toop_engine_importer.pandapower_import.asset_topology.get_list_of_stations_ids] diff --git a/docs/references/reference_interfaces.md b/docs/references/reference_interfaces.md index 74dcf3f09..1bbff5037 100644 --- a/docs/references/reference_interfaces.md +++ b/docs/references/reference_interfaces.md @@ -26,4 +26,4 @@ ::: toop_engine_interfaces.asset_topology ## Asset Topology Helper -::: toop_engine_interfaces.asset_topology_helpers +::: toop_engine_interfaces.asset_topology.asset_topology_helpers diff --git a/notebooks/example2_small_grid_toop.ipynb b/notebooks/example2_small_grid_toop.ipynb index 55191fc3d..f6eec07f8 100644 --- a/notebooks/example2_small_grid_toop.ipynb +++ b/notebooks/example2_small_grid_toop.ipynb @@ -279,12 +279,13 @@ "import pypowsybl as pp\n", "from IPython.display import SVG, display\n", "\n", - "# Adjust run directory\n", - "run_dir = 'run_0'\n", + "# Reuse the topology directory generated by the earlier pipeline cell.\n", + "run_path = topo_path[0]\n", "# Find name of the voltage level (substation) that was adjusted\n", "# The SLD of the adjusted substation is named as \"_sld.svg\"\n", "voltage_level_name = 'VLevel2'\n", - "sld_path = optimizer_snapshot_dir / run_dir / 'topology_0' / \"sld\" / f\"{voltage_level_name}_sld_initial.svg\"\n", + "sld_dir = run_path / 'sld'\n", + "sld_path = sld_dir / f\"{voltage_level_name}_sld_initial.svg\"\n", "\n", "# Load initial grid into pypowsybl\n", "network = pp.network.load(str(iteration_name / file_path))\n", @@ -300,7 +301,7 @@ "# Display SLD before optimization\n", "display(SVG(filename=sld_path))\n", "# Display SLD after optimization\n", - "display(SVG(filename=optimizer_snapshot_dir / run_dir / 'topology_0' / \"sld\" / f\"{voltage_level_name}_sld.svg\"))\n" + "display(SVG(filename=sld_dir / f\"{voltage_level_name}_sld.svg\"))\n" ] }, { diff --git a/notebooks/example3_e2e_pipeline.ipynb b/notebooks/example3_e2e_pipeline.ipynb index 866fdabf2..efa3e5112 100644 --- a/notebooks/example3_e2e_pipeline.ipynb +++ b/notebooks/example3_e2e_pipeline.ipynb @@ -360,9 +360,9 @@ " \"k_best_topos\": 3 # Change this value to use a different number of topologies\n", "})\n", "\n", - "# Hint: optimisation_run_dir should contain the path to the folder which contains the valid res.json file.\n", - "optimisation_run_dir = f\"../data/{optimizer_snapshot_dir}/run_0\"\n", - "optimisation_run_dir = Path(optimisation_run_dir)\n", + "# Reuse the run directory generated in the earlier optimization cell so the action set\n", + "# and the optimizer results stay aligned during this notebook execution.\n", + "optimisation_run_dir = Path(run_dir)\n", "\n", "topo_paths = run_pipeline(\n", " pipeline_cfg=pipeline_cfg,\n", diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_classes.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_classes.py index 10247444c..c74198a9d 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_classes.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_classes.py @@ -10,14 +10,12 @@ import datetime import numpy as np -from toop_engine_interfaces.asset_topology import ( - AssetBay, - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, Topology, ) +from toop_engine_interfaces.asset_topology.assets import AssetBay, BranchAsset, Busbar, BusbarCoupler +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection def get_basic_node_breaker_topology() -> Topology: @@ -33,8 +31,8 @@ def get_basic_node_breaker_topology() -> Topology: topology_id="test", grid_model_file="test_file", name=None, - stations=[ - Station( + raw_stations=[ + RawStation( grid_model_id="VL4_0", name="VLevel4", type=None, @@ -55,49 +53,45 @@ def get_basic_node_breaker_topology() -> Topology: in_service=True, ) ], - assets=[ - SwitchableAsset( - grid_model_id="L4", - type="LINE", - name="", - in_service=True, - branch_end=None, - asset_bay=AssetBay( - sl_switch_grid_model_id=None, - dv_switch_grid_model_id="L42_BREAKER", - sr_switch_grid_model_id={"BBS4_1": "L42_DISCONNECTOR_3_0", "BBS4_2": "L42_DISCONNECTOR_3_1"}, - ), - ), - SwitchableAsset( - grid_model_id="L5", - type="LINE", - name="", - in_service=True, - branch_end=None, - asset_bay=AssetBay( - sl_switch_grid_model_id=None, - dv_switch_grid_model_id="L52_BREAKER", - sr_switch_grid_model_id={"BBS4_1": "L52_DISCONNECTOR_5_0", "BBS4_2": "L52_DISCONNECTOR_5_1"}, - ), - ), - SwitchableAsset( - grid_model_id="L8", - type="LINE", - name="", - in_service=True, - branch_end=None, - asset_bay=AssetBay( - sl_switch_grid_model_id=None, - dv_switch_grid_model_id="L82_BREAKER", - sr_switch_grid_model_id={"BBS4_1": "L82_DISCONNECTOR_7_0", "BBS4_2": "L82_DISCONNECTOR_7_1"}, - ), - ), + branch_connections=[ + StationAssetConnection(asset_id="L4", branch_end=None, asset_bay_id="VL4_0::L4::bay"), + StationAssetConnection(asset_id="L5", branch_end=None, asset_bay_id="VL4_0::L5::bay"), + StationAssetConnection(asset_id="L8", branch_end=None, asset_bay_id="VL4_0::L8::bay"), ], - asset_switching_table=np.array([[False, False, False], [True, True, False]]), - asset_connectivity=np.array([[True, True, True], [True, True, True]]), + injection_connections=[], + branch_switching_table=np.array([[False, False, False], [True, True, False]], dtype=bool), + injection_switching_table=np.zeros((2, 0), dtype=bool), + branch_connectivity=np.array([[True, True, True], [True, True, True]], dtype=bool), + injection_connectivity=np.zeros((2, 0), dtype=bool), ) ], - asset_setpoints=None, + branch_assets=[ + BranchAsset(grid_model_id="L4", type="LINE", name="", in_service=True), + BranchAsset(grid_model_id="L5", type="LINE", name="", in_service=True), + BranchAsset(grid_model_id="L8", type="LINE", name="", in_service=True), + ], + injection_assets=[], + asset_bays=[ + AssetBay( + asset_bay_id="VL4_0::L4::bay", + sl_switch_grid_model_id=None, + dv_switch_grid_model_id="L42_BREAKER", + sr_switch_grid_model_id={"BBS4_1": "L42_DISCONNECTOR_3_0", "BBS4_2": "L42_DISCONNECTOR_3_1"}, + ), + AssetBay( + asset_bay_id="VL4_0::L5::bay", + sl_switch_grid_model_id=None, + dv_switch_grid_model_id="L52_BREAKER", + sr_switch_grid_model_id={"BBS4_1": "L52_DISCONNECTOR_5_0", "BBS4_2": "L52_DISCONNECTOR_5_1"}, + ), + AssetBay( + asset_bay_id="VL4_0::L8::bay", + sl_switch_grid_model_id=None, + dv_switch_grid_model_id="L82_BREAKER", + sr_switch_grid_model_id={"BBS4_1": "L82_DISCONNECTOR_7_0", "BBS4_2": "L82_DISCONNECTOR_7_1"}, + ), + ], timestamp=datetime.datetime(2025, 2, 4, 9, 12, 0, 109256), + asset_setpoints=None, metrics=None, ) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_grids.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_grids.py index 876e8839b..3f145824e 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_grids.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/example_grids.py @@ -51,13 +51,14 @@ from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK from toop_engine_grid_helpers.powsybl.powsybl_helpers import save_lf_params_to_fs from toop_engine_importer.pypowsybl_import import preprocessing -from toop_engine_interfaces.asset_topology import ( - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, +from toop_engine_interfaces.asset_topology.asset_topology import ( Topology, ) +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler, InjectionAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.topology_conversion import ( + topology_parts_from_materialized_station, +) from toop_engine_interfaces.backend import BackendInterface from toop_engine_interfaces.folder_structure import ( NETWORK_MASK_NAMES, @@ -158,7 +159,7 @@ class PandapowerCounters: def random_station_info_backend( backend: BackendInterface, node_idx: Integral, pp_counters: Optional[PandapowerCounters] -) -> tuple[Station, Optional[PandapowerCounters]]: +) -> tuple[MaterializedStation, Optional[PandapowerCounters]]: """Generate a random station for any backend This will create a Station object with 2 busbars, 1 coupler and a random assignment of assets @@ -191,12 +192,11 @@ def random_station_info_backend( ): if branch_node == node_idx: switchable_assets.append( - SwitchableAsset( + BranchAsset( grid_model_id=branch_id, - type=branch_type, + asset_type=branch_type, name=branch_name, in_service=True, - branch_end="from", ) ) @@ -209,12 +209,11 @@ def random_station_info_backend( ): if branch_node == node_idx: switchable_assets.append( - SwitchableAsset( + BranchAsset( grid_model_id=branch_id, - type=branch_type, + asset_type=branch_type, name=branch_name, in_service=True, - branch_end="to", ) ) @@ -227,18 +226,26 @@ def random_station_info_backend( ): if injection_node == node_idx: switchable_assets.append( - SwitchableAsset( + InjectionAsset( grid_model_id=injection_id, - type=injection_type, + asset_type=injection_type, name=injection_name, in_service=True, ) ) - asset_switching_table = np.zeros((2, len(switchable_assets)), dtype=bool) - is_on_a = np.random.rand(len(switchable_assets)) > 0.5 - asset_switching_table[0, is_on_a] = True - asset_switching_table[1, ~is_on_a] = True + branch_assets = [asset for asset in switchable_assets if isinstance(asset, BranchAsset)] + injection_assets = [asset for asset in switchable_assets if isinstance(asset, InjectionAsset)] + + branch_switching_table = np.zeros((2, len(branch_assets)), dtype=bool) + branch_is_on_a = np.random.rand(len(branch_assets)) > 0.5 + branch_switching_table[0, branch_is_on_a] = True + branch_switching_table[1, ~branch_is_on_a] = True + + injection_switching_table = np.zeros((2, len(injection_assets)), dtype=bool) + injection_is_on_a = np.random.rand(len(injection_assets)) > 0.5 + injection_switching_table[0, injection_is_on_a] = True + injection_switching_table[1, ~injection_is_on_a] = True global_id = backend.get_node_ids()[node_idx] if pp_counters is not None: @@ -256,7 +263,7 @@ def random_station_info_backend( bus_b_id = global_id + "_b" switch_id = global_id + "_coupler" - return Station( + return MaterializedStation( grid_model_id=global_id, busbars=[ Busbar( @@ -278,12 +285,16 @@ def random_station_info_backend( open=False, ), ], - assets=switchable_assets, - asset_switching_table=asset_switching_table, - asset_connectivity=np.ones_like(asset_switching_table, dtype=bool), + branch_connections=[MaterializedAssetConnection(asset=asset) for asset in branch_assets], + injection_connections=[MaterializedAssetConnection(asset=asset) for asset in injection_assets], + branch_switching_table=branch_switching_table, + injection_switching_table=injection_switching_table, + branch_connectivity=np.ones_like(branch_switching_table, dtype=bool), + injection_connectivity=np.ones_like(injection_switching_table, dtype=bool), ), pp_counters +# ruff: noqa: C901 def random_topology_info_backend(backend: BackendInterface, pp_counters: Optional[PandapowerCounters]) -> Topology: """Generate a random topology for any backend @@ -307,9 +318,40 @@ def random_topology_info_backend(backend: BackendInterface, pp_counters: Optiona new_station, pp_counters = random_station_info_backend(backend, node_idx, pp_counters) stations.append(new_station) + raw_stations = [] + branch_assets: dict[str, BranchAsset] = {} + injection_assets: dict[str, InjectionAsset] = {} + asset_bays = {} + for station in stations: + raw_station, station_branch_assets, station_injection_assets, station_asset_bays = ( + topology_parts_from_materialized_station(station) + ) + raw_stations.append(raw_station) + for asset in station_branch_assets: + existing_asset = branch_assets.get(asset.grid_model_id) + if existing_asset is None: + branch_assets[asset.grid_model_id] = asset + elif existing_asset != asset: + raise ValueError(f"Conflicting branch asset payload for grid_model_id {asset.grid_model_id}") + for asset in station_injection_assets: + existing_asset = injection_assets.get(asset.grid_model_id) + if existing_asset is None: + injection_assets[asset.grid_model_id] = asset + elif existing_asset != asset: + raise ValueError(f"Conflicting injection asset payload for grid_model_id {asset.grid_model_id}") + for asset_bay in station_asset_bays: + existing_asset_bay = asset_bays.get(asset_bay.asset_bay_id) + if existing_asset_bay is None: + asset_bays[asset_bay.asset_bay_id] = asset_bay + elif existing_asset_bay != asset_bay: + raise ValueError(f"Conflicting asset bay payload for asset_bay_id {asset_bay.asset_bay_id}") + return Topology( - stations=stations, topology_id="random_topology", + raw_stations=raw_stations, + branch_assets=list(branch_assets.values()), + injection_assets=list(injection_assets.values()), + asset_bays=list(asset_bays.values()), timestamp=datetime.datetime.now(), ) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/asset_topology_to_dgs.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/asset_topology_to_dgs.py index 0af93bb54..91f72a540 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/asset_topology_to_dgs.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/asset_topology_to_dgs.py @@ -20,7 +20,7 @@ import pandas as pd import pandera as pa import pandera.typing as pat -from beartype.typing import Any, Optional, cast +from beartype.typing import Optional from toop_engine_dc_solver.export.dgs_v7_definitions import ( DGS_GENERAL_SHEET_CONTENT_FID, DGS_GENERAL_SHEET_CONTENT_FID_CIM, @@ -89,8 +89,7 @@ def switch_update_schema_to_dgs( dgs_df = dgs_df.astype({"FID(a:40)": str, "OP": str, "on_off": int}) if cim: dgs_df["FID(a:40)"] = "_" + dgs_df["FID(a:40)"] - DgsElmCoupSchema.validate(dgs_df) - return cast(pat.DataFrame[DgsElmCoupSchema], dgs_df) + return dgs_df @pa.check_types @@ -105,8 +104,7 @@ def get_dgs_general_schema( general_info = DGS_GENERAL_SHEET_CONTENT_FID df_general = pd.DataFrame(general_info) - DgsGeneralSchema.validate(df_general) - return cast(pat.DataFrame[DgsGeneralSchema], df_general) + return df_general @pa.check_types @@ -171,7 +169,7 @@ def switch_dgs_schema_to_bytes_io( BytesIO object containing the xlsx file with the DGS information """ bytes_io = io.BytesIO() - with pd.ExcelWriter(cast(Any, bytes_io), engine="openpyxl") as writer: + with pd.ExcelWriter(bytes_io, engine="openpyxl") as writer: df_general.to_excel(writer, index=False, sheet_name="General") switch_dgs_schema.to_excel(writer, index=False, sheet_name=sheet_name) bytes_io.seek(0) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/disconnection_switch_updates.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/disconnection_switch_updates.py index 55a40b09d..50339e74d 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/disconnection_switch_updates.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/disconnection_switch_updates.py @@ -11,8 +11,9 @@ import pandera as pa import pandera.typing as pat import structlog -from beartype.typing import cast -from toop_engine_interfaces.asset_topology import AssetBay, Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.assets import AssetBay +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation from toop_engine_interfaces.interface_helpers import get_empty_dataframe_from_model from toop_engine_interfaces.nminus1_definition import GridElement from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema @@ -21,7 +22,7 @@ def get_disconnected_asset_ids( - stations: list[Station], + stations: list[MaterializedStation], disconnections: list[GridElement], ) -> dict[str, list[AssetBay]]: """Collect representable disconnection asset ids from the provided topology. @@ -41,17 +42,19 @@ def get_disconnected_asset_ids( Returns ------- dict[str, list[AssetBay]] - Mapping of grid element IDs to be disconnected to the switchable assets that can perform the disconnection. + Mapping of grid element IDs to be disconnected to the switchable asset bays that can perform the disconnection. Note that not all requested disconnections might be representable as switch updates, so this mapping might be incomplete. """ disconnection_map: dict[str, GridElement] = {disconnection.id: disconnection for disconnection in disconnections} disconnection_asset_map: dict[str, list[AssetBay]] = {disconnection.id: [] for disconnection in disconnections} for station in stations: - for asset in station.assets: - if asset.grid_model_id in disconnection_map and asset.asset_bay is not None: + for asset_connection in [*station.branch_connections, *station.injection_connections]: + asset = asset_connection.asset + asset_bay = asset_connection.asset_bay + if asset.grid_model_id in disconnection_map and asset_bay is not None: corresponding_disconnection = disconnection_map[asset.grid_model_id] - disconnection_asset_map[corresponding_disconnection.id].append(asset.asset_bay) + disconnection_asset_map[corresponding_disconnection.id].append(asset_bay) return disconnection_asset_map @@ -74,8 +77,9 @@ def get_changing_switches_from_disconnections( pat.DataFrame[SwitchUpdateSchema] Switch update rows representing the requested disconnections where possible. """ + stations = starting_topology.materialize_stations() disconnection_asset_map: dict[str, list[AssetBay]] = get_disconnected_asset_ids( - stations=starting_topology.stations, + stations=stations, disconnections=disconnections, ) @@ -92,7 +96,7 @@ def get_changing_switches_from_disconnections( disconnection_id=disconnection.id, disconnection_name=disconnection.name, disconnection_type=disconnection.type, - available_station_ids=[station.grid_model_id for station in starting_topology.stations], + available_station_ids=[station.grid_model_id for station in stations], ) for asset in assets: switch_updates.append( @@ -105,6 +109,6 @@ def get_changing_switches_from_disconnections( if not switch_updates: return get_empty_dataframe_from_model(SwitchUpdateSchema) - return cast( - pat.DataFrame[SwitchUpdateSchema], pd.DataFrame.from_records(switch_updates, columns=["grid_model_id", "open"]) + return pd.DataFrame.from_records(switch_updates, columns=["grid_model_id", "open"]).astype( + {"grid_model_id": str, "open": bool} ) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/export.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/export.py index c29cb7440..832cbae5c 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/export.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/export.py @@ -16,12 +16,12 @@ import pandera as pa import pandera.typing as pat import structlog -from beartype.typing import cast from toop_engine_dc_solver.export.disconnection_switch_updates import get_changing_switches_from_disconnections from toop_engine_dc_solver.export.station_switch_updates import ( get_changing_switches_from_changed_stations, ) -from toop_engine_interfaces.asset_topology import Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation from toop_engine_interfaces.interface_helpers import get_empty_dataframe_from_model from toop_engine_interfaces.nminus1_definition import GridElement from toop_engine_interfaces.stored_action_set import ActionSet @@ -33,7 +33,7 @@ def _get_changed_stations_from_action_indices( action_set: ActionSet, actions: list[int], -) -> list[Station]: +) -> list[MaterializedStation]: """Resolve action indices to concrete changed stations. Parameters @@ -53,7 +53,7 @@ def _get_changed_stations_from_action_indices( ValueError If any action index is negative or beyond the available range. """ - changed_stations: list[Station] = [] + changed_stations: list[MaterializedStation] = [] for action_index in actions: if action_index < 0 or action_index >= len(action_set.local_actions): raise ValueError(f"Action index {action_index} is out of bounds for the action set") @@ -94,7 +94,7 @@ def _get_disconnections_from_indices( @pa.check_types def get_changing_switches_from_actions( - changed_stations: list[Station], + changed_stations: list[MaterializedStation], simplified_starting_topology: Topology, disconnections: list[GridElement] | None = None, full_starting_topology: Topology | None = None, @@ -162,7 +162,7 @@ def get_changing_switches_from_actions( if combined_switch_updates.empty: combined_switch_updates = get_empty_dataframe_from_model(SwitchUpdateSchema) combined_switch_updates = combined_switch_updates.astype({"grid_model_id": str, "open": bool}) - return cast(pat.DataFrame[SwitchUpdateSchema], combined_switch_updates) + return combined_switch_updates @pa.check_types diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/station_switch_updates.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/station_switch_updates.py index 844c2eae9..54a7debd7 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/station_switch_updates.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/export/station_switch_updates.py @@ -15,22 +15,46 @@ import pandas as pd import pandera as pa import pandera.typing as pat -from beartype.typing import cast -from jaxtyping import Bool -from toop_engine_interfaces.asset_topology import Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation from toop_engine_interfaces.interface_helpers import get_empty_dataframe_from_model from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema -def _get_busbar_lookup(station: Station) -> dict[int, str]: +def _get_busbar_lookup(station: MaterializedStation) -> dict[int, str]: """Map busbar row indices in the switching table to busbar ids.""" return {index: busbar.grid_model_id for index, busbar in enumerate(station.busbars)} +def _get_asset_busbar_lookup( + station: MaterializedStation, + asset_connection: MaterializedAssetConnection, +) -> dict[int, str]: + """Resolve row-to-busbar ids for one asset connection. + + Simplified split-station actions can use per-asset logical rows that no longer match the + station-level busbar ids. When that happens, the asset bay selector-switch keys preserve the + physical busbar ordering needed to translate row changes back into switch updates. + """ + station_busbar_lookup = _get_busbar_lookup(station) + asset_bay = asset_connection.asset_bay + if asset_bay is None: + return station_busbar_lookup + + station_busbar_ids = list(station_busbar_lookup.values()) + asset_busbar_ids = list(asset_bay.sr_switch_grid_model_id.keys()) + if len(asset_busbar_ids) == len(station_busbar_ids) and not set(station_busbar_ids).issubset( + asset_bay.sr_switch_grid_model_id + ): + return {index: busbar_id for index, busbar_id in enumerate(asset_busbar_ids)} + + return station_busbar_lookup + + def _resolve_changed_stations( - changed_stations: list[Station], + changed_stations: list[MaterializedStation], starting_topology: Topology, -) -> tuple[dict[str, Station], dict[str, Station], list[str]]: +) -> tuple[dict[str, MaterializedStation], dict[str, MaterializedStation], list[str]]: """Resolve station lookups and preserve changed-station ordering. This helper is intentionally limited to station actions. It validates that all changed stations @@ -62,21 +86,22 @@ def _resolve_changed_stations( if len(changed_station_ids) != len(set(changed_station_ids)): raise ValueError("Changed stations must be unique by grid_model_id.") - starting_station_lookup = {station.grid_model_id: station for station in starting_topology.stations} + starting_stations = starting_topology.materialize_stations() + starting_station_lookup = {station.grid_model_id: station for station in starting_stations} changed_station_lookup = {station.grid_model_id: station for station in changed_stations} missing_station_ids = set(changed_station_lookup).difference(starting_station_lookup) if missing_station_ids: raise ValueError(f"Changed stations not found in starting topology: {sorted(missing_station_ids)}") ordered_changed_station_ids = [ - station.grid_model_id for station in starting_topology.stations if station.grid_model_id in changed_station_lookup + station.grid_model_id for station in starting_stations if station.grid_model_id in changed_station_lookup ] return starting_station_lookup, changed_station_lookup, ordered_changed_station_ids def _get_coupler_switch_diffs( - changed_station: Station, - starting_station: Station, + changed_station: MaterializedStation, + starting_station: MaterializedStation, ) -> list[dict[str, str | bool]]: """Collect coupler switch changes between two station states. @@ -120,76 +145,159 @@ def _get_coupler_switch_diffs( return diff_switches -def _get_asset_switch_diffs( - changed_station: Station, - starting_station: Station, +def _get_branch_switch_diffs( + changed_station: MaterializedStation, + starting_station: MaterializedStation, fail_on_disconnect: bool = False, ) -> list[dict[str, str | bool]]: - """Collect selector and breaker switch changes between two station states. + """Collect branch selector and breaker switch changes between two station states. Parameters ---------- - changed_station : Station - Station describing the target asset-to-busbar assignments. - starting_station : Station - Station describing the reference asset-to-busbar assignments. The station assets must be - in the same order as ``changed_station``. This is the ordering contract provided by - ``ActionSet.simplified_starting_topology``. - fail_on_disconnect: bool - Fundamentally, the stations should never disconnect an element. If this is detected, we can either raise - or open the breaker. If fail_on_disconnect is true, a ValueError will be raised + changed_station : MaterializedStation + Station describing the target branch-to-busbar assignments. + starting_station : MaterializedStation + Station describing the reference branch assignments. The branch connection + array must stay in the same order as ``changed_station``. + fail_on_disconnect : bool, default=False + Whether to raise when a changed branch becomes fully disconnected instead + of emitting a breaker-opening update. Returns ------- list[dict[str, str | bool]] - Switch update records for selector and breaker switches whose state changes. + Switch update records derived from ``branch_connections`` and + ``branch_switching_table``. A changed branch column can produce selector + switch updates for busbar reassignments and, when the branch becomes + fully disconnected, one breaker update via the asset-bay disconnecting + switch. Raises ------ ValueError - If the station switching tables are structurally incompatible or asset order does not match. + If the branch switching tables are structurally incompatible, the branch + order does not match, or a disconnect is detected while + ``fail_on_disconnect`` is true. """ - changed_switching_table = _get_station_switching_table(changed_station) - starting_switching_table = _get_station_switching_table(starting_station) - if changed_switching_table.shape != starting_switching_table.shape: + if changed_station.branch_switching_table.shape != starting_station.branch_switching_table.shape: raise ValueError( "Changed station asset switching table shape does not match starting topology for station " f"{changed_station.grid_model_id}." ) - changed_busbar_lookup = _get_busbar_lookup(changed_station) - changed_asset_ids = [asset.grid_model_id for asset in changed_station.assets] - starting_asset_ids = [asset.grid_model_id for asset in starting_station.assets] + changed_asset_ids = [asset_connection.asset.grid_model_id for asset_connection in changed_station.branch_connections] + starting_asset_ids = [asset_connection.asset.grid_model_id for asset_connection in starting_station.branch_connections] if changed_asset_ids != starting_asset_ids: raise ValueError( "Changed station assets are not ordered like the starting topology for station " f"{changed_station.grid_model_id}. Use ActionSet.simplified_starting_topology as input." ) - switching_xor = np.logical_xor(starting_switching_table, changed_switching_table) + switching_xor = np.logical_xor(starting_station.branch_switching_table, changed_station.branch_switching_table) + diff_switches: list[dict[str, str | bool]] = [] + + for column, changed_asset_connection in enumerate(changed_station.branch_connections): + changed_busbar_lookup = _get_asset_busbar_lookup(changed_station, changed_asset_connection) + asset_bay = changed_asset_connection.asset_bay + if asset_bay is None: + continue + changed_switch_states = changed_station.branch_switching_table[:, column] + starting_switch_states = starting_station.branch_switching_table[:, column] + changed_rows = np.flatnonzero(switching_xor[:, column]) + changed_active = int(changed_switch_states.sum()) + starting_active = int(starting_switch_states.sum()) + + if changed_active == 0: + if starting_active > 0: + if fail_on_disconnect: + raise ValueError( + f"Station action in station {changed_station.grid_model_id} would disconnect " + f"asset {changed_asset_connection.asset.grid_model_id}." + ) + diff_switches.append({"grid_model_id": asset_bay.dv_switch_grid_model_id, "open": True}) + continue + + for row in changed_rows: + busbar_id = changed_busbar_lookup[int(row)] + switch_id = asset_bay.sr_switch_grid_model_id[busbar_id] + diff_switches.append({"grid_model_id": switch_id, "open": not bool(changed_switch_states[row])}) + + return diff_switches + + +def _get_injection_switch_diffs( + changed_station: MaterializedStation, + starting_station: MaterializedStation, + fail_on_disconnect: bool = False, +) -> list[dict[str, str | bool]]: + """Collect injection selector and breaker switch changes between two station states. + + Parameters + ---------- + changed_station : MaterializedStation + Station describing the target injection-to-busbar assignments. + starting_station : MaterializedStation + Station describing the reference injection assignments. The injection + connection array must stay in the same order as ``changed_station``. + fail_on_disconnect : bool, default=False + Whether to raise when a changed injection becomes fully disconnected + instead of emitting a breaker-opening update. + + Returns + ------- + list[dict[str, str | bool]] + Switch update records derived from ``injection_connections`` and + ``injection_switching_table``. This mirrors + ``_get_branch_switch_diffs`` for the injection-side station tables. + + Raises + ------ + ValueError + If the injection switching tables are structurally incompatible, the + injection order does not match, or a disconnect is detected while + ``fail_on_disconnect`` is true. + """ + if changed_station.injection_switching_table.shape != starting_station.injection_switching_table.shape: + raise ValueError( + "Changed station asset switching table shape does not match starting topology for station " + f"{changed_station.grid_model_id}." + ) + + changed_asset_ids = [asset_connection.asset.grid_model_id for asset_connection in changed_station.injection_connections] + starting_asset_ids = [ + asset_connection.asset.grid_model_id for asset_connection in starting_station.injection_connections + ] + if changed_asset_ids != starting_asset_ids: + raise ValueError( + "Changed station assets are not ordered like the starting topology for station " + f"{changed_station.grid_model_id}. Use ActionSet.simplified_starting_topology as input." + ) + switching_xor = np.logical_xor( + starting_station.injection_switching_table, + changed_station.injection_switching_table, + ) diff_switches: list[dict[str, str | bool]] = [] - for column, changed_asset in enumerate(changed_station.assets): - asset_bay = changed_asset.asset_bay + + for column, changed_asset_connection in enumerate(changed_station.injection_connections): + changed_busbar_lookup = _get_asset_busbar_lookup(changed_station, changed_asset_connection) + asset_bay = changed_asset_connection.asset_bay if asset_bay is None: continue - changed_switch_states = changed_switching_table[:, column] - starting_switch_states = starting_switching_table[:, column] + changed_switch_states = changed_station.injection_switching_table[:, column] + starting_switch_states = starting_station.injection_switching_table[:, column] changed_rows = np.flatnonzero(switching_xor[:, column]) changed_active = int(changed_switch_states.sum()) starting_active = int(starting_switch_states.sum()) if changed_active == 0: - breaker_id = asset_bay.dv_switch_grid_model_id - # The asset was disconnected by disconnecting all entries in the switching table. This can only be represented - # through a breaker-open if there was at least one active busbar connection in the starting state. if starting_active > 0: if fail_on_disconnect: raise ValueError( f"Station action in station {changed_station.grid_model_id} would disconnect " - f"asset {changed_asset.grid_model_id}." + f"asset {changed_asset_connection.asset.grid_model_id}." ) - diff_switches.append({"grid_model_id": breaker_id, "open": True}) + diff_switches.append({"grid_model_id": asset_bay.dv_switch_grid_model_id, "open": True}) continue for row in changed_rows: @@ -200,9 +308,54 @@ def _get_asset_switch_diffs( return diff_switches +def _get_asset_switch_diffs( + changed_station: MaterializedStation, + starting_station: MaterializedStation, + fail_on_disconnect: bool = False, +) -> list[dict[str, str | bool]]: + """Collect selector and breaker switch changes between two station states. + + Parameters + ---------- + changed_station : MaterializedStation + Station describing the target branch/injection-to-busbar assignments. + starting_station : MaterializedStation + Station describing the reference branch/injection assignments. The branch and injection + connection arrays must each stay in the same order as ``changed_station``. This is the + ordering contract provided by ``ActionSet.simplified_starting_topology``. + fail_on_disconnect : bool, default=False + Fundamentally, the stations should never disconnect an element. If this is detected, the + helper can either raise or emit a breaker-opening update. If ``fail_on_disconnect`` is + true, a ``ValueError`` is raised instead. + + Returns + ------- + list[dict[str, str | bool]] + Switch update records for selector and breaker switches whose state changes across both the + branch and injection station tables. + + Raises + ------ + ValueError + If the station switching tables are structurally incompatible or asset order does not match. + """ + return [ + *_get_branch_switch_diffs( + changed_station=changed_station, + starting_station=starting_station, + fail_on_disconnect=fail_on_disconnect, + ), + *_get_injection_switch_diffs( + changed_station=changed_station, + starting_station=starting_station, + fail_on_disconnect=fail_on_disconnect, + ), + ] + + def _get_switch_updates_from_station_ids( - changed_station_lookup: dict[str, Station], - starting_station_lookup: dict[str, Station], + changed_station_lookup: dict[str, MaterializedStation], + starting_station_lookup: dict[str, MaterializedStation], ordered_station_ids: list[str], ) -> pat.DataFrame[SwitchUpdateSchema]: """Build switch updates for a specific ordered list of stations. @@ -239,43 +392,30 @@ def _get_switch_updates_from_station_ids( if diff_switch_df.empty: diff_switch_df = get_empty_dataframe_from_model(SwitchUpdateSchema) diff_switch_df = diff_switch_df.astype({"grid_model_id": str, "open": bool}) - return cast(pat.DataFrame[SwitchUpdateSchema], diff_switch_df) - - -def _get_station_switching_table(station: Station) -> Bool[np.ndarray, " n_busbar n_asset"]: - """Return the station switching table as a boolean numpy array. - - Parameters - ---------- - station : Station - Station whose switching table should be normalized to a boolean ndarray. - - Returns - ------- - Bool[np.ndarray, " n_busbar n_asset"] - Boolean switching table indexed by busbar and asset. - """ - return np.asarray(station.asset_switching_table, dtype=bool) + return diff_switch_df @pa.check_types def get_changing_switches_from_changed_stations( - changed_stations: list[Station], + changed_stations: list[MaterializedStation], starting_topology: Topology, ) -> pat.DataFrame[SwitchUpdateSchema]: """Get changed switches by comparing changed stations to the starting topology. This is intended for changed stations originating from ``ActionSet.local_actions`` where only - coupler open states and the asset switching table differ from the starting topology. + coupler open states and the split station switching tables differ from the starting topology. + In the split topology model that means ``branch_switching_table`` and + ``injection_switching_table`` are compared independently and then merged into one switch-update + table. Parameters ---------- - changed_stations : list[Station] + changed_stations : list[MaterializedStation] Stations describing the target state for the affected substations. starting_topology : Topology Starting topology containing the reference state for all stations. This is expected to be - ``ActionSet.simplified_starting_topology`` so that station asset ordering matches the - ordering used by ``ActionSet.local_actions``. + ``ActionSet.simplified_starting_topology`` so that both branch and injection connection + ordering match the ordering used by ``ActionSet.local_actions``. Returns diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/benchmarks/bench_postprocessing.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/benchmarks/bench_postprocessing.py index ab179682e..ab876f53b 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/benchmarks/bench_postprocessing.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/benchmarks/bench_postprocessing.py @@ -18,7 +18,7 @@ import jax import tyro -from beartype.typing import Literal, Optional, TypeAlias, cast +from beartype.typing import Literal, Optional, TypeAlias from pydantic import BaseModel, PositiveInt from toop_engine_dc_solver.jax.inputs import load_static_information from toop_engine_dc_solver.jax.topology_computations import random_topology @@ -168,15 +168,12 @@ def sample_topologies( ) -> ActionIndexComputations: """Sample topology actions using the persisted static information action set.""" static_information = load_static_information(data_folder / PREPROCESSING_PATHS["static_information_file_path"]) - return cast( - ActionIndexComputations, - random_topology( - rng_key=jax.random.PRNGKey(seed), - branch_action_set=static_information.dynamic_information.action_set, - limit_n_subs=n_substations_split, - batch_size=n_topologies, - topo_vect_format=False, - ), + return random_topology( + rng_key=jax.random.PRNGKey(seed), + branch_action_set=static_information.dynamic_information.action_set, + limit_n_subs=n_substations_split, + batch_size=n_topologies, + topo_vect_format=False, ) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/inputs.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/inputs.py index 54d4d96a9..5c1e9080b 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/inputs.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/inputs.py @@ -224,7 +224,6 @@ def validate_static_information( assert di.susceptance.shape == (n_branch,) assert jnp.all(di.susceptance != 0) - assert di.branches_to_fail.shape[0] <= n_branch assert di.action_set is not None diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/abstract_runner.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/abstract_runner.py index a60d7abd4..8f20b28bd 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/abstract_runner.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/abstract_runner.py @@ -22,7 +22,7 @@ import pandera.typing as pat from beartype.typing import Optional, TypeAlias, Union from fsspec import AbstractFileSystem -from toop_engine_interfaces.asset_topology import RealizedTopology +from toop_engine_interfaces.asset_topology.applied_topology import RealizedTopology from toop_engine_interfaces.loadflow_results_polars import LoadflowResultsPolars from toop_engine_interfaces.nminus1_definition import Nminus1Definition from toop_engine_interfaces.stored_action_set import ActionSet diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_pandapower.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_pandapower.py index d48240a2a..5c7cbec49 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_pandapower.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_pandapower.py @@ -20,15 +20,13 @@ from pandapower.toolbox import element_bus_tuples, get_connected_elements_dict from toop_engine_grid_helpers.pandapower.pandapower_helpers import get_element_table, get_remotely_connected_buses from toop_engine_grid_helpers.pandapower.pandapower_id_helpers import parse_globally_unique_id, table_id -from toop_engine_interfaces.asset_topology import ( - Busbar, - BusbarCoupler, - RealizedStation, - RealizedTopology, - Station, +from toop_engine_interfaces.asset_topology.applied_topology import AppliedStation, RealizedTopology +from toop_engine_interfaces.asset_topology.asset_topology import ( Topology, ) -from toop_engine_interfaces.asset_topology_helpers import accumulate_diffs, find_busbars_for_coupler +from toop_engine_interfaces.asset_topology.asset_topology_helpers import accumulate_diffs, find_busbars_for_coupler +from toop_engine_interfaces.asset_topology.assets import Busbar, BusbarCoupler +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation logger = structlog.get_logger(__name__) @@ -276,32 +274,193 @@ def delete_excess_switches( return deleted -def apply_station_assets( +def _apply_branch_assets( net: pp.pandapowerNet, - station: Station, + station: MaterializedStation, + station_buses: Iterable[int], + station_bus_ids: list[int], ) -> tuple[list[int], list[tuple[int, int, bool]]]: + """Apply branch reassignment and disconnection updates for one station. + + Parameters + ---------- + net : pandapowerNet + The pandapower network to modify in place. + station : MaterializedStation + Station whose ``branch_connections`` and ``branch_switching_table`` + define the desired branch placements. + station_buses : Iterable[int] + All pandapower bus ids that are considered part of the station when + locating the currently connected bus for a branch. + station_bus_ids : list[int] + Bus ids aligned with ``station.busbars``. The branch switching-table row + indices are resolved through this ordering. + + Returns + ------- + tuple[list[int], list[tuple[int, int, bool]]] + A pair containing disconnected branch indices and branch reassignment + diffs. Each reassignment diff stores the branch index, the affected + station-bus index, and whether the branch was assigned or unassigned. + + Raises + ------ + NotImplementedError + If a branch is connected to multiple busbars at once. + """ + branch_disconnection_diff: list[int] = [] + branch_reassignment_diff: list[tuple[int, int, bool]] = [] + + for asset_index, asset_connection in enumerate(station.branch_connections): + asset = asset_connection.asset + pp_id, asset_type = parse_globally_unique_id(asset.grid_model_id) + pp_table = get_element_table(asset_type) + pp_id = int(pp_id) + target_buses: list[int] = np.flatnonzero(station.branch_switching_table[:, asset_index]).tolist() + + if len(target_buses) > 1: + raise NotImplementedError("Connecting an asset to multiple buses is not supported.") + + if len(target_buses) == 0: + net[pp_table].loc[pp_id, "in_service"] = False + branch_disconnection_diff.append(int(asset_index)) + continue + + target_bus_index = target_buses[0] + target_bus_id = station_bus_ids[target_bus_index] + previous_bus = reassign_asset_to_bus( + net=net, + asset_id=pp_id, + asset_table=pp_table, + target_bus_id=target_bus_id, + station_buses=station_buses, + ) + if previous_bus != target_bus_id: + branch_reassignment_diff.append((int(asset_index), int(target_bus_index), True)) + try: + previous_bus_index = station_bus_ids.index(previous_bus) + except ValueError: + previous_bus_index = -1 + logger.warning( + f"Asset {asset.grid_model_id} was reassigned from bus {previous_bus} which is not in the station." + ) + branch_reassignment_diff.append((int(asset_index), int(previous_bus_index), False)) + + return branch_disconnection_diff, branch_reassignment_diff + + +def _apply_injection_assets( + net: pp.pandapowerNet, + station: MaterializedStation, + station_buses: Iterable[int], + station_bus_ids: list[int], +) -> tuple[list[int], list[tuple[int, int, bool]]]: + """Apply injection reassignment and disconnection updates for one station. + + Parameters + ---------- + net : pandapowerNet + The pandapower network to modify in place. + station : MaterializedStation + Station whose ``injection_connections`` and + ``injection_switching_table`` define the desired injection placements. + station_buses : Iterable[int] + All pandapower bus ids that are considered part of the station when + locating the currently connected bus for an injection. + station_bus_ids : list[int] + Bus ids aligned with ``station.busbars``. The injection switching-table + row indices are resolved through this ordering. + + Returns + ------- + tuple[list[int], list[tuple[int, int, bool]]] + A pair containing disconnected injection indices and injection + reassignment diffs. Each reassignment diff stores the injection index, + the affected station-bus index, and whether the injection was assigned + or unassigned. + + Raises + ------ + NotImplementedError + If an injection is connected to multiple busbars at once. + """ + injection_disconnection_diff: list[int] = [] + injection_reassignment_diff: list[tuple[int, int, bool]] = [] + + for asset_index, asset_connection in enumerate(station.injection_connections): + asset = asset_connection.asset + pp_id, asset_type = parse_globally_unique_id(asset.grid_model_id) + pp_table = get_element_table(asset_type) + pp_id = int(pp_id) + target_buses: list[int] = np.flatnonzero(station.injection_switching_table[:, asset_index]).tolist() + + if len(target_buses) > 1: + raise NotImplementedError("Connecting an asset to multiple buses is not supported.") + + if len(target_buses) == 0: + net[pp_table].loc[pp_id, "in_service"] = False + injection_disconnection_diff.append(int(asset_index)) + continue + + target_bus_index = target_buses[0] + target_bus_id = station_bus_ids[target_bus_index] + previous_bus = reassign_asset_to_bus( + net=net, + asset_id=pp_id, + asset_table=pp_table, + target_bus_id=target_bus_id, + station_buses=station_buses, + ) + if previous_bus != target_bus_id: + injection_reassignment_diff.append((int(asset_index), int(target_bus_index), True)) + try: + previous_bus_index = station_bus_ids.index(previous_bus) + except ValueError: + previous_bus_index = -1 + logger.warning( + f"Asset {asset.grid_model_id} was reassigned from bus {previous_bus} which is not in the station." + ) + injection_reassignment_diff.append((int(asset_index), int(previous_bus_index), False)) + + return injection_disconnection_diff, injection_reassignment_diff + + +def apply_station_assets( + net: pp.pandapowerNet, + station: MaterializedStation, +) -> tuple[ + list[int], + list[int], + list[tuple[int, int, bool]], + list[tuple[int, int, bool]], +]: """Apply a station topology to a pandapower network. This assumes that exactly the assets in the station also exist in the pandapower network. If the assets are connected via asset bays and switches before the application of this function, these asset bays will be dangling after. - It returns diffs compatible with the RealizedStation format. + It returns diffs compatible with the AppliedStation format. Parameters ---------- net : pandapowerNet The pandapower network to apply the topology to. Will be modified in place. - station : Station + station : MaterializedStation The station to apply. It is assumed that the station grid_model_id refers to a bus in the pandapower network. Returns ------- list[int] - A list of asset indices that were disconnected from the grid. + Branch indices that were disconnected from the grid. + list[int] + Injection indices that were disconnected from the grid. list[tuple[int, int, bool]] - A list of tuples containing the asset index, the bus id to which it was assigned/unassigned and a boolean indicating - whether the asset was assigned (true) or unassigned (false). + Branch reassignment tuples containing the branch index, the bus id to which it was assigned/unassigned and a + boolean indicating whether the branch was assigned (true) or unassigned (false). + list[tuple[int, int, bool]] + Injection reassignment tuples containing the injection index, the bus id to which it was assigned/unassigned and a + boolean indicating whether the injection was assigned (true) or unassigned (false). """ assert all(table_id(bus.grid_model_id) in net.bus.index for bus in station.busbars), ( "All busbars must be present in the pandapower network." @@ -311,56 +470,34 @@ def apply_station_assets( net=net, buses=[table_id(bus.grid_model_id) for bus in station.busbars], consider=("s",), respect_switches=False ) - disconnection_diff = [] - reassignment_diff = [] - for asset_index, asset in enumerate(station.assets): - pp_id, asset_type = parse_globally_unique_id(asset.grid_model_id) - pp_table = get_element_table(asset_type) - pp_id = int(pp_id) - # Note that this is an index into the asset switching table, not into the pandapower net.buses table - target_buses: list[int] = np.flatnonzero(station.asset_switching_table[:, asset_index]).tolist() + station_bus_ids = [table_id(bus.grid_model_id) for bus in station.busbars] - if len(target_buses) > 1: - raise NotImplementedError("Connecting an asset to multiple buses is not supported.") + branch_disconnection_diff, branch_reassignment_diff = _apply_branch_assets( + net=net, + station=station, + station_buses=station_buses, + station_bus_ids=station_bus_ids, + ) + injection_disconnection_diff, injection_reassignment_diff = _apply_injection_assets( + net=net, + station=station, + station_buses=station_buses, + station_bus_ids=station_bus_ids, + ) - if len(target_buses) == 0: - # We are supposed to disconnect the asset, we do that by setting the in_service flag to False - net[pp_table].loc[pp_id, "in_service"] = False - disconnection_diff.append(asset_index) - else: - # We shall reassign the asset to a new bus - # Note that the target_bus_index indexes into the asset switching table and the target_bus_id indexes into the - # pandapower net.buses table - target_bus_index = target_buses[0] - target_bus_id = table_id(station.busbars[target_bus_index].grid_model_id) - previous_bus = reassign_asset_to_bus( - net=net, - asset_id=pp_id, - asset_table=pp_table, - target_bus_id=target_bus_id, - station_buses=station_buses, - ) - if previous_bus != target_bus_id: - reassignment_diff.append((asset_index, target_bus_index, True)) - try: - # It could be that the asset is connected to a busbar that is not in the station but in the pandapower - # grid. In that case, we don't know where it came from. - previous_bus_index = [table_id(bus.grid_model_id) for bus in station.busbars].index(previous_bus) - except ValueError: - previous_bus_index = -1 - logger.warning( - f"Asset {asset.grid_model_id} was reassigned from bus {previous_bus} which is not in the station." - ) - reassignment_diff.append((asset_index, previous_bus_index, False)) - - return disconnection_diff, reassignment_diff + return ( + branch_disconnection_diff, + injection_disconnection_diff, + branch_reassignment_diff, + injection_reassignment_diff, + ) def apply_station_couplers( net: pp.pandapowerNet, couplers: list[BusbarCoupler], ) -> list[BusbarCoupler]: - """Apply coupler changes from an asset topology station to a pandapower network + """Apply coupler changes from an asset topology station to a pandapower network. This will find all couplers and change them to their desired state. If they have been switched, they will be returned as a coupler diff. @@ -423,13 +560,13 @@ class ApplyGridDiff: def apply_station( net: pp.pandapowerNet, - station: Station, -) -> tuple[ApplyGridDiff, RealizedStation]: + station: MaterializedStation, +) -> tuple[ApplyGridDiff, AppliedStation]: """Apply an asset topology station to a pandapower network. This will force the station into the format of the asset topology, meaning missing busbars and switches will be created, - excess busbars and switches will be deleted, the asset_switching_table will be applied and the couplers will be set to - their desired state. + excess busbars and switches will be deleted, the split branch and injection switching tables will be applied and the + couplers will be set to their desired state. The asset bays and coupler bays in the station will not be recreated, i.e. if a node/breaker model is passed into this function, it will return as a bus/branch model. @@ -441,7 +578,7 @@ def apply_station( ---------- net : pandapowerNet The pandapower network to apply the topology to. Will be modified in place. - station : Station + station : MaterializedStation The station to apply. It is assumed that the station grid_model_id refers to a bus in the pandapower network. Returns @@ -449,7 +586,7 @@ def apply_station( ApplyGridDiff The difference between the switches and busbars that were expected by the asset topology and the actual switches and busbars in the grid. - RealizedStation + AppliedStation The realized station, containing the coupler diff, reassignment diff and disconnection diff. """ busbars_created = create_missing_busbars( @@ -462,7 +599,12 @@ def apply_station( couplers=station.couplers, ) - disconnection_diff, reassignment_diff = apply_station_assets( + ( + branch_disconnection_diff, + injection_disconnection_diff, + branch_reassignment_diff, + injection_reassignment_diff, + ) = apply_station_assets( net=net, station=station, ) @@ -506,11 +648,13 @@ def apply_station( busbars_deleted=busbars_deleted, switches_deleted=switches_deleted, ), - RealizedStation( + AppliedStation( station=station, coupler_diff=coupler_diff, - reassignment_diff=reassignment_diff, - disconnection_diff=disconnection_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, ), ) @@ -519,7 +663,8 @@ def apply_topology(net: pp.pandapowerNet, topology: Topology) -> tuple[list[tupl """Apply an asset topology to a pandapower network. This will apply all stations in the topology to the pandapower network. It will create missing busbars and switches, - delete excess busbars and switches, apply the asset_switching_table and set the couplers to their desired state. + delete excess busbars and switches, apply the split branch and injection switching tables and set the couplers to + their desired state. If switches or busbars had to be adjusted, this is returned separately. @@ -538,15 +683,23 @@ def apply_topology(net: pp.pandapowerNet, topology: Topology) -> tuple[list[tupl RealizedTopology The realized topology, containing the coupler diff, reassignment diff and disconnection diff for each station. """ - realizations = [apply_station(net, station) for station in topology.stations] + realizations = [apply_station(net, station) for station in topology.materialize_stations()] apply_diffs = [(rs.station.grid_model_id, apply_diff) for apply_diff, rs in realizations] realized_stations = [rs for _, rs in realizations] - coupler_diff, reassignment_diff, disconnection_diff = accumulate_diffs(realized_stations) + ( + coupler_diff, + branch_reassignment_diff, + injection_reassignment_diff, + branch_disconnection_diff, + injection_disconnection_diff, + ) = accumulate_diffs(realized_stations) return apply_diffs, RealizedTopology( topology=topology, coupler_diff=coupler_diff, - reassignment_diff=reassignment_diff, - disconnection_diff=disconnection_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, ) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_powsybl.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_powsybl.py index 711e1dc2b..6f2513b00 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_powsybl.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/apply_asset_topo_powsybl.py @@ -18,24 +18,23 @@ the node/breaker way is still TODO. """ -from datetime import datetime - import numpy as np import pandas as pd import pandera as pa import pandera.typing as pat import structlog -from beartype.typing import Literal, Optional, Union, cast +from beartype.typing import Literal, Optional, Union from pypowsybl.network import Network from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import assert_station_in_network -from toop_engine_interfaces.asset_topology import ( - BusbarCoupler, - RealizedStation, - RealizedTopology, - Station, +from toop_engine_interfaces.asset_topology.applied_topology import AppliedStation, RealizedTopology +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, Topology, + copy_topology_with_updates, ) -from toop_engine_interfaces.asset_topology_helpers import accumulate_diffs +from toop_engine_interfaces.asset_topology.asset_topology_helpers import accumulate_diffs +from toop_engine_interfaces.asset_topology.assets import BusbarCoupler +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation from toop_engine_interfaces.interface_helpers import get_empty_dataframe_from_model from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema @@ -44,7 +43,23 @@ @pa.check_types def get_coupler_states_from_busbar_couplers(station_couplers: list[BusbarCoupler]) -> pat.DataFrame[SwitchUpdateSchema]: - """Translate the coupler states to the switch update schema format.""" + """Translate coupler states to the switch-update schema format. + + Parameters + ---------- + station_couplers : list[BusbarCoupler] + Couplers whose target open state should be expressed as switch updates. + + Returns + ------- + pat.DataFrame[SwitchUpdateSchema] + Switch-update rows containing the desired state for each coupler. + + Raises + ------ + ValueError + If a coupler is marked out of service. + """ switch_df = get_empty_dataframe_from_model(SwitchUpdateSchema) for coupler in station_couplers: if not coupler.in_service: @@ -54,46 +69,147 @@ def get_coupler_states_from_busbar_couplers(station_couplers: list[BusbarCoupler "open": coupler.open, } switch_df = switch_df.astype({"grid_model_id": str, "open": bool}) - return cast(pat.DataFrame[SwitchUpdateSchema], switch_df) + return switch_df -def get_asset_bay_grid_model_id_list(station: Station) -> list[dict[str, str] | None]: - """Get selector switch ids for each station asset.""" - asset_bays = [asset.asset_bay for asset in station.assets] - sr_switch_grid_model_id_list: list[dict[str, str] | None] = [] - for asset_bay in asset_bays: - if asset_bay is None or asset_bay.sr_switch_grid_model_id is None: - sr_switch_grid_model_id_list.append(None) - else: - sr_switch_grid_model_id_list.append(asset_bay.sr_switch_grid_model_id) - return sr_switch_grid_model_id_list +def get_busbar_lookup(station: MaterializedStation) -> dict[int, str]: + """Map switching-table row indices to station busbar ids. + Parameters + ---------- + station : MaterializedStation + Station whose busbar ordering defines the switching-table row order. -def get_busbar_lookup(station: Station) -> dict[int, str]: - """Get the busbar lookup for the given station.""" + Returns + ------- + dict[int, str] + Mapping from switching-table row index to busbar ``grid_model_id``. + """ return {index: busbar.grid_model_id for index, busbar in enumerate(station.busbars)} -@pa.check_types -def get_asset_switch_states_from_station( - station: Station, -) -> tuple[pat.DataFrame[SwitchUpdateSchema], pat.DataFrame[SwitchUpdateSchema]]: - """Translate asset selector and breaker states of one station to switch updates.""" +def _get_asset_busbar_lookup( + station: MaterializedStation, + asset_connection: MaterializedAssetConnection, +) -> dict[int, str]: + """Resolve row-to-busbar ids for one asset connection. + + Simplified split-station actions can compress station rows while keeping per-asset selector + switch ids that refer to the original physical busbars. In that case the row semantics are + asset-local and must be recovered from the asset bay ordering instead of the station busbar ids. + """ + station_busbar_lookup = get_busbar_lookup(station) + asset_switch_ids = asset_connection.get_sr_switch() + if asset_switch_ids is None: + return station_busbar_lookup + + station_busbar_ids = list(station_busbar_lookup.values()) + asset_busbar_ids = list(asset_switch_ids.keys()) + if len(asset_busbar_ids) == len(station_busbar_ids) and not set(station_busbar_ids).issubset(asset_switch_ids): + return {index: busbar_id for index, busbar_id in enumerate(asset_busbar_ids)} + + return station_busbar_lookup + + +def _get_branch_switch_states_from_station( + station: MaterializedStation, +) -> tuple[list[dict[str, str | bool]], list[dict[str, str | bool]]]: + """Translate branch selector and breaker states of one station to switch updates. + + Parameters + ---------- + station : MaterializedStation + Station whose branch-side switching state should be exported. + + Returns + ------- + tuple[list[dict[str, str | bool]], list[dict[str, str | bool]]] + Two lists containing branch reassignment switch states and branch + disconnection switch states. + + Raises + ------ + ValueError + If a branch switching-table column connects the same branch to multiple + busbars. + """ + switch_reassignment_list: list[dict[str, str | bool]] = [] + switch_disconnection_list: list[dict[str, str | bool]] = [] + + assert station.branch_switching_table.shape[1] == len(station.branch_connections), ( + "The branch switching table has a different number of columns than the branch reassignment list. " + f"Columns: {station.branch_switching_table.shape[1]}, Assets: {len(station.branch_connections)}" + ) + + for column, asset_connection in enumerate(station.branch_connections): + busbar_id_dict = _get_asset_busbar_lookup(station, asset_connection) + asset_switch_ids = asset_connection.get_sr_switch() + if asset_switch_ids is None: + continue + asset_switch_states = station.branch_switching_table[:, column] + active_busbars = int(asset_switch_states.sum()) + if active_busbars == 1: + assigned_busbar = int(np.nonzero(asset_switch_states)[0][0]) + for busbar, switch_id in asset_switch_ids.items(): + switch_reassignment_list.append( + { + "grid_model_id": switch_id, + "open": busbar_id_dict[assigned_busbar] != busbar, + } + ) + elif active_busbars == 0: + asset_bay = asset_connection.asset_bay + assert asset_bay is not None + switch_disconnection_list.append( + { + "grid_model_id": asset_bay.dv_switch_grid_model_id, + "open": True, + } + ) + else: + raise ValueError( + f"Switching table column {column} has more than one True value: {station.branch_switching_table[:, column]}" + ) + + return switch_reassignment_list, switch_disconnection_list + + +def _get_injection_switch_states_from_station( + station: MaterializedStation, +) -> tuple[list[dict[str, str | bool]], list[dict[str, str | bool]]]: + """Translate injection selector and breaker states of one station to switch updates. + + Parameters + ---------- + station : MaterializedStation + Station whose injection-side switching state should be exported. + + Returns + ------- + tuple[list[dict[str, str | bool]], list[dict[str, str | bool]]] + Two lists containing injection reassignment switch states and injection + disconnection switch states. + + Raises + ------ + ValueError + If an injection switching-table column connects the same injection to + multiple busbars. + """ switch_reassignment_list: list[dict[str, str | bool]] = [] switch_disconnection_list: list[dict[str, str | bool]] = [] - busbar_id_dict = get_busbar_lookup(station) - switching_table = np.asarray(station.asset_switching_table, dtype=bool) - asset_reassignment_list = get_asset_bay_grid_model_id_list(station) - assert switching_table.shape[1] == len(asset_reassignment_list), ( - "The asset switching table has a different number of columns than the asset reassignment list. " - f"Columns: {switching_table.shape[1]}, Reassignment list: {len(asset_reassignment_list)}" + + assert station.injection_switching_table.shape[1] == len(station.injection_connections), ( + "The injection switching table has a different number of columns than the injection reassignment list. " + f"Columns: {station.injection_switching_table.shape[1]}, Assets: {len(station.injection_connections)}" ) - for column in range(switching_table.shape[1]): - asset_switch_ids = asset_reassignment_list[column] + for column, asset_connection in enumerate(station.injection_connections): + busbar_id_dict = _get_asset_busbar_lookup(station, asset_connection) + asset_switch_ids = asset_connection.get_sr_switch() if asset_switch_ids is None: continue - asset_switch_states = switching_table[:, column] + asset_switch_states = station.injection_switching_table[:, column] active_busbars = int(asset_switch_states.sum()) if active_busbars == 1: assigned_busbar = int(np.nonzero(asset_switch_states)[0][0]) @@ -105,7 +221,7 @@ def get_asset_switch_states_from_station( } ) elif active_busbars == 0: - asset_bay = station.assets[column].asset_bay + asset_bay = asset_connection.asset_bay assert asset_bay is not None switch_disconnection_list.append( { @@ -114,7 +230,39 @@ def get_asset_switch_states_from_station( } ) else: - raise ValueError(f"Switching table column {column} has more than one True value: {switching_table[:, column]}") + raise ValueError( + f"Switching table column {column} has more than one True value: " + f"{station.injection_switching_table[:, column]}" + ) + + return switch_reassignment_list, switch_disconnection_list + + +@pa.check_types +def get_asset_switch_states_from_station( + station: MaterializedStation, +) -> tuple[pat.DataFrame[SwitchUpdateSchema], pat.DataFrame[SwitchUpdateSchema]]: + """Translate station asset switch states to switch-update dataframes. + + Parameters + ---------- + station : MaterializedStation + Station whose branch and injection switching tables should be exported. + + Returns + ------- + tuple[pat.DataFrame[SwitchUpdateSchema], pat.DataFrame[SwitchUpdateSchema]] + Two dataframes containing reassignment-related switch updates and + disconnection-related switch updates. + """ + branch_reassignment_list, branch_disconnection_list = _get_branch_switch_states_from_station( + station=station, + ) + injection_reassignment_list, injection_disconnection_list = _get_injection_switch_states_from_station( + station=station, + ) + switch_reassignment_list = [*branch_reassignment_list, *injection_reassignment_list] + switch_disconnection_list = [*branch_disconnection_list, *injection_disconnection_list] switch_reassignment_df = pd.DataFrame.from_records(switch_reassignment_list, columns=["grid_model_id", "open"]) switch_disconnection_df = pd.DataFrame.from_records(switch_disconnection_list, columns=["grid_model_id", "open"]) @@ -124,9 +272,7 @@ def get_asset_switch_states_from_station( switch_disconnection_df = get_empty_dataframe_from_model(SwitchUpdateSchema) switch_reassignment_df = switch_reassignment_df.astype({"grid_model_id": str, "open": bool}) switch_disconnection_df = switch_disconnection_df.astype({"grid_model_id": str, "open": bool}) - return cast(pat.DataFrame[SwitchUpdateSchema], switch_reassignment_df), cast( - pat.DataFrame[SwitchUpdateSchema], switch_disconnection_df - ) + return switch_reassignment_df, switch_disconnection_df @pa.check_types @@ -134,7 +280,26 @@ def get_diff_switch_states( network: Network, switch_df: pat.DataFrame[SwitchUpdateSchema], ) -> pat.DataFrame[SwitchUpdateSchema]: - """Filter switch updates down to the ones that differ from the current network state.""" + """Filter switch updates down to the ones differing from the network state. + + Parameters + ---------- + network : Network + Powsybl network providing the current switch states. + switch_df : pat.DataFrame[SwitchUpdateSchema] + Candidate switch updates. + + Returns + ------- + pat.DataFrame[SwitchUpdateSchema] + Only the switch updates whose target ``open`` value differs from the + current network state. + + Raises + ------ + ValueError + If a switch id from ``switch_df`` is missing in the network. + """ diff_switch_df = switch_df.merge( network.get_switches(attributes=["open"]), left_on="grid_model_id", @@ -150,14 +315,29 @@ def get_diff_switch_states( diff_switch_df = diff_switch_df[diff_switch_df["open"] != diff_switch_df["open_network"]] diff_switch_df = diff_switch_df[["grid_model_id", "open"]] diff_switch_df = diff_switch_df.astype({"grid_model_id": str, "open": bool}) - return cast(pat.DataFrame[SwitchUpdateSchema], diff_switch_df) + return diff_switch_df @pa.check_types def get_changing_switches_from_topology(network: Network, target_topology: Topology) -> pat.DataFrame[SwitchUpdateSchema]: - """Get the switch updates needed to realize a target topology on a node-breaker network.""" + """Get switch updates needed to realize a topology on a node-breaker network. + + Parameters + ---------- + network : Network + Powsybl network whose current switch states act as the reference. + target_topology : Topology + Topology whose couplers and split branch/injection station tables define + the target switch states. + + Returns + ------- + pat.DataFrame[SwitchUpdateSchema] + Switch updates required to transform the current network state into the + target node-breaker topology. + """ switch_update_df = get_empty_dataframe_from_model(SwitchUpdateSchema) - for station in target_topology.stations: + for station in target_topology.materialize_stations(): coupler_df = get_coupler_states_from_busbar_couplers(station.couplers) switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) station_switch_updates = pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True) @@ -174,12 +354,12 @@ def get_changing_switches_from_topology(network: Network, target_topology: Topol switch_update_df = switch_update_df.astype({"grid_model_id": str, "open": bool}) return get_diff_switch_states( network=network, - switch_df=cast(pat.DataFrame[SwitchUpdateSchema], switch_update_df), + switch_df=switch_update_df, ) -def find_asset(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) -> tuple[bool, bool, bool, Optional[str]]: - """Find out whether a given element is a branch, if its connected and if branch, which direction it has. +def find_branch(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) -> tuple[bool, bool, Optional[str]]: + """Find a branch connection in one station and resolve its local orientation. It will try to match the branch with two methods. First, the voltage level id is compared. However, there are branches which have the same voltage level id on both sides, in which case @@ -199,21 +379,18 @@ def find_asset(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) - Returns ------- - bool - Whether the element is a branch bool Whether the element is connected. bool Whether side 1 is on the busbar that was passed in. This is always false if the element - is not a branch or if it is not connected. + is not connected. Optional[str] - The bus_breaker_id that the element is connected to. If the element is a branch it refers - to the side that was found. Might be None if the element is not connected. + The bus_breaker_id on the matched branch side. Might be None if the element is not connected. Raises ------ ValueError - If the element is a branch and connected, but not found in the station + If the branch is not found in the station. """ branches_df = net.get_branches( attributes=[ @@ -228,12 +405,7 @@ def find_asset(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) - ] ) if elem_id not in branches_df.index: - injections_df = net.get_injections(attributes=["connected", "voltage_level_id", "bus_id", "bus_breaker_bus_id"]) - - if elem_id not in injections_df.index or injections_df.loc[elem_id]["voltage_level_id"] != voltage_level_id: - raise ValueError(f"Element {elem_id} not found in the station.") - - return False, bool(injections_df.loc[elem_id]["connected"]), False, injections_df.loc[elem_id]["bus_breaker_bus_id"] + raise ValueError(f"Branch {elem_id} not found in the station, assumed to be in {voltage_level_id}") branch_entry = branches_df.loc[elem_id] @@ -241,7 +413,6 @@ def find_asset(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) - if (not same_vl and branch_entry["voltage_level1_id"] == voltage_level_id) or branch_entry["bus1_id"] == bus_id: return ( - True, bool(branch_entry["connected1"] and branch_entry["connected2"]), True, branch_entry["bus_breaker_bus1_id"], @@ -249,7 +420,6 @@ def find_asset(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) - if (not same_vl and branch_entry["voltage_level2_id"] == voltage_level_id) or branch_entry["bus2_id"] == bus_id: return ( - True, bool(branch_entry["connected1"] and branch_entry["connected2"]), False, branch_entry["bus_breaker_bus2_id"], @@ -258,19 +428,50 @@ def find_asset(net: Network, elem_id: str, voltage_level_id: str, bus_id: str) - raise ValueError(f"Branch {elem_id} not found in the station, assumed to be in {voltage_level_id}") +def find_injection(net: Network, elem_id: str, voltage_level_id: str) -> tuple[bool, Optional[str]]: + """Find an injection connection in one station. + + Parameters + ---------- + net : Network + Powsybl network to query. + elem_id : str + Injection id to locate. + voltage_level_id : str + Voltage level id expected for the injection. + + Returns + ------- + tuple[bool, Optional[str]] + Whether the injection is connected and the bus-breaker bus id on which + it is currently placed. + + Raises + ------ + ValueError + If the injection is not present in the expected station. + """ + injections_df = net.get_injections(attributes=["connected", "voltage_level_id", "bus_id", "bus_breaker_bus_id"]) + + if elem_id not in injections_df.index or injections_df.loc[elem_id]["voltage_level_id"] != voltage_level_id: + raise ValueError(f"Element {elem_id} not found in the station.") + + return bool(injections_df.loc[elem_id]["connected"]), injections_df.loc[elem_id]["bus_breaker_bus_id"] + + def move_branch(net: Network, elem_id: str, bus_breaker_id: str, from_end: bool) -> None: - """Move a branch to a new busbar in the network + """Move a branch to a new busbar in the network. Parameters ---------- net : Network - The powsybl network to disconnect the injection in + The powsybl network to update in place. elem_id : str - The id of the injection to disconnect + The id of the branch to move. bus_breaker_id : str - The id of the busbar to move the injection to + The id of the target bus-breaker bus. from_end : bool - Whether the branch is connected "from" side the busbar or "to" side the busbar. + Whether the matched station side corresponds to branch end 1. """ if from_end: net.update_branches(id=elem_id, bus_breaker_bus1_id=bus_breaker_id, connected1=True) @@ -287,9 +488,9 @@ def disconnect_branch( Parameters ---------- net : Network - The powsybl network to disconnect the injection in + The powsybl network to update in place. elem_id : str - The id of the injection to disconnect + The id of the branch to disconnect. """ net.update_branches(id=elem_id, connected1=False, connected2=False) @@ -304,11 +505,11 @@ def move_injection( Parameters ---------- net : Network - The powsybl network to disconnect the injection in + The powsybl network to update in place. elem_id : str - The id of the injection to disconnect + The id of the injection to move. bus_breaker_id : str - The id of the busbar to move the injection to + The id of the target bus-breaker bus. """ net.update_injections(id=elem_id, bus_breaker_bus_id=bus_breaker_id, connected=True) @@ -317,112 +518,230 @@ def disconnect_injection( net: Network, elem_id: str, ) -> None: - """Disconnects an injection in the network + """Disconnect an injection in the network. Parameters ---------- net : Network - The powsybl network to disconnect the injection in + The powsybl network to update in place. elem_id : str - The id of the injection to disconnect + The id of the injection to disconnect. """ net.update_injections(id=elem_id, connected=False) -def apply_single_asset_bus_branch( +def _get_target_bus_index( + station: MaterializedStation, + switching_column: np.ndarray, + bus_breaker_id: Optional[str], +) -> int: + """Resolve the target busbar index for one switching-table column. + + Parameters + ---------- + station : MaterializedStation + Station whose busbar order defines valid target indices. + switching_column : np.ndarray + One branch or injection switching-table column. + bus_breaker_id : Optional[str] + Current bus-breaker id of the asset, if known. + + Returns + ------- + int + Index of the target busbar. If the current bus-breaker id matches one of + the active busbars, that index is preferred; otherwise the first active + entry is used. + """ + target_bus_indices = [ + index + for index, (connected, busbar) in enumerate(zip(switching_column.tolist(), station.busbars, strict=True)) + if connected and busbar.grid_model_id == bus_breaker_id + ] + if len(target_bus_indices) == 1: + return target_bus_indices[0] + return int(np.argmax(switching_column)) + + +def _apply_single_branch_bus_branch( net: Network, - station: Station, + station: MaterializedStation, asset_index: int, ) -> tuple[Literal["disconnected", "reassigned", "nothing"], list[tuple[int, int, bool]]]: - """Reassign or disconnect a single asset in a bus/branch topology + """Reassign or disconnect a single branch connection in a bus/branch topology. + + Parameters + ---------- + net : Network + The powsybl network to apply the topology to. It is assumed that the station is fully present in the + network. + station : MaterializedStation + The asset topology station in which the branch at position asset_index will be applied to the powsybl network. + asset_index : int + The index of the branch connection inside station.branch_connections. + + Returns + ------- + Literal["disconnected", "reassigned", "nothing"] + A string indicating whether the branch was disconnected, reassigned or left as is. + list[tuple[int, int, bool]] + A list of reassignment diffs for this branch. + """ + vl_id = net.get_buses(attributes=["voltage_level_id"]).loc[station.grid_model_id]["voltage_level_id"] + + asset = station.branch_connections[asset_index].asset + switching_column = station.branch_switching_table[:, asset_index] + + is_connected, from_side, bus_breaker_id = find_branch( + net=net, elem_id=asset.grid_model_id, voltage_level_id=vl_id, bus_id=station.grid_model_id + ) + + if not np.any(switching_column): + if is_connected: + disconnect_branch(net=net, elem_id=asset.grid_model_id) + return "disconnected", [] + return "nothing", [] + + target_bus_index = _get_target_bus_index(station, switching_column, bus_breaker_id) + target_bus_breaker_id = station.busbars[target_bus_index].grid_model_id + if target_bus_breaker_id == bus_breaker_id: + return "nothing", [] + + move_branch( + net=net, + elem_id=asset.grid_model_id, + bus_breaker_id=target_bus_breaker_id, + from_end=from_side, + ) + reassignments = [(asset_index, target_bus_index, True)] + + old_indices = [index for index, busbar in enumerate(station.busbars) if busbar.grid_model_id == bus_breaker_id] + if len(old_indices) == 1: + reassignments.append((asset_index, old_indices[0], False)) + + return "reassigned", reassignments + + +def apply_single_branch_bus_branch( + net: Network, + station: MaterializedStation, +) -> tuple[list[int], list[tuple[int, int, bool]]]: + """Apply all branch connection updates for one station in bus/branch topology. + + Parameters + ---------- + net : Network + Powsybl network to modify in place. + station : MaterializedStation + Station whose branch-side topology should be realized. - If the asset has no connections in the switching table, it is set to be disconnected. If it isn't already - disconnected, it will be disconnected in the net and 'disconnected' will be returned. + Returns + ------- + tuple[list[int], list[tuple[int, int, bool]]] + Disconnected branch indices and branch reassignment diffs. + """ + branch_disconnection_diff: list[int] = [] + branch_reassignment_diff: list[tuple[int, int, bool]] = [] - If the asset has one or more switching table entries, but none of them matches the current bus_breaker assignment, - the asset needs to be reassigned. In this case it will be reassigned to the first busbar that is True in the switching - table and 'reassigned' will be returned. + for asset_index in range(len(station.branch_connections)): + result, new_reassignments = _apply_single_branch_bus_branch(net, station, asset_index) + if result == "disconnected": + branch_disconnection_diff.append(asset_index) + elif result == "reassigned": + branch_reassignment_diff.extend( + [(int(asset_index), int(busbar_index), bool(connected)) for _, busbar_index, connected in new_reassignments] + ) + + return branch_disconnection_diff, branch_reassignment_diff - If neither a disconnection or a reassignment is needed, 'nothing' will be returned and the resulting powsybl network - will be the same as before. + +def _apply_single_injection_bus_branch( + net: Network, + station: MaterializedStation, + asset_index: int, +) -> tuple[Literal["disconnected", "reassigned", "nothing"], list[tuple[int, int, bool]]]: + """Reassign or disconnect a single injection connection in a bus/branch topology. Parameters ---------- net : Network The powsybl network to apply the topology to. It is assumed that the station is fully present in the network. - station : Station - The asset topology station in which the asset at position asset_index will be applied to the powsybl network. + station : MaterializedStation + The asset topology station in which the injection at position asset_index will be applied to the powsybl network. asset_index : int - The index of the asset in the station.assets list. + The index of the injection connection inside station.injection_connections. Returns ------- Literal["disconnected", "reassigned", "nothing"] - A string indicating whether the asset was disconnected, reassigned or left as is. + A string indicating whether the injection was disconnected, reassigned or left as is. list[tuple[int, int, bool]] - A list of reassignments that have been made. Each tuple contains the asset index that was - affected (not the asset grid_model_id but the index into the asset_switching_table), the busbar - index (again the index into the switching table) and whether the asset was connected (True) or - disconnected (False) to that busbar. Note that this list will have either 0, 1 or 2 entries and - the first entry of the tuple, the asset index, will be the same as the asset_index passed in. - If no reassignments happened, this will be the empty list. - - Raises - ------ - ValueError - If the asset is not in the station or if the asset is not in the network + A list of reassignment diffs for this injection. """ vl_id = net.get_buses(attributes=["voltage_level_id"]).loc[station.grid_model_id]["voltage_level_id"] - is_branch, is_connected, from_side, bus_breaker_id = find_asset( - net=net, elem_id=station.assets[asset_index].grid_model_id, voltage_level_id=vl_id, bus_id=station.grid_model_id + asset = station.injection_connections[asset_index].asset + switching_column = station.injection_switching_table[:, asset_index] + + is_connected, bus_breaker_id = find_injection(net=net, elem_id=asset.grid_model_id, voltage_level_id=vl_id) + + if not np.any(switching_column): + if is_connected: + disconnect_injection(net=net, elem_id=asset.grid_model_id) + return "disconnected", [] + return "nothing", [] + + target_bus_index = _get_target_bus_index(station, switching_column, bus_breaker_id) + target_bus_breaker_id = station.busbars[target_bus_index].grid_model_id + if target_bus_breaker_id == bus_breaker_id: + return "nothing", [] + + move_injection( + net=net, + elem_id=asset.grid_model_id, + bus_breaker_id=target_bus_breaker_id, ) + reassignments = [(asset_index, target_bus_index, True)] - switching_column = station.asset_switching_table[:, asset_index] + old_indices = [index for index, busbar in enumerate(station.busbars) if busbar.grid_model_id == bus_breaker_id] + if len(old_indices) == 1: + reassignments.append((asset_index, old_indices[0], False)) - target_disconnected = not np.any(switching_column) + return "reassigned", reassignments - # If the bus_breaker_id of the asset is currently set in the switching table, use - # that as the target bus_breaker_id. Otherwise, just use the first True entry. - target_bus_indices = [ - index - for index, (connected, busbar) in enumerate(zip(switching_column.tolist(), station.busbars, strict=True)) - if connected and busbar.grid_model_id == bus_breaker_id - ] - if len(target_bus_indices) == 1: - target_bus_index = target_bus_indices[0] - else: - target_bus_index = int(np.argmax(switching_column)) - if target_disconnected and is_connected: - if is_branch: - disconnect_branch(net=net, elem_id=station.assets[asset_index].grid_model_id) - else: - disconnect_injection(net=net, elem_id=station.assets[asset_index].grid_model_id) - return "disconnected", [] - if station.busbars[target_bus_index].grid_model_id != bus_breaker_id: - if is_branch: - move_branch( - net=net, - elem_id=station.assets[asset_index].grid_model_id, - bus_breaker_id=station.busbars[target_bus_index].grid_model_id, - from_end=from_side, - ) - else: - move_injection( - net=net, - elem_id=station.assets[asset_index].grid_model_id, - bus_breaker_id=station.busbars[target_bus_index].grid_model_id, - ) - reassignments = [(asset_index, target_bus_index, True)] +def apply_single_injection_bus_branch( + net: Network, + station: MaterializedStation, +) -> tuple[list[int], list[tuple[int, int, bool]]]: + """Apply all injection connection updates for one station in bus/branch topology. + + Parameters + ---------- + net : Network + Powsybl network to modify in place. + station : MaterializedStation + Station whose injection-side topology should be realized. - old_indices = [index for index, busbar in enumerate(station.busbars) if busbar.grid_model_id == bus_breaker_id] - if len(old_indices) == 1: - reassignments.append((asset_index, old_indices[0], False)) + Returns + ------- + tuple[list[int], list[tuple[int, int, bool]]] + Disconnected injection indices and injection reassignment diffs. + """ + injection_disconnection_diff: list[int] = [] + injection_reassignment_diff: list[tuple[int, int, bool]] = [] + + for asset_index in range(len(station.injection_connections)): + result, new_reassignments = _apply_single_injection_bus_branch(net, station, asset_index) + if result == "disconnected": + injection_disconnection_diff.append(asset_index) + elif result == "reassigned": + injection_reassignment_diff.extend( + [(int(asset_index), int(busbar_index), bool(connected)) for _, busbar_index, connected in new_reassignments] + ) - return "reassigned", reassignments - return "nothing", [] + return injection_disconnection_diff, injection_reassignment_diff def set_coupler( @@ -430,21 +749,26 @@ def set_coupler( coupler_id: str, target_state: bool, ) -> bool: - """Set the state of a coupler in the network + """Set the state of a coupler in the network. Parameters ---------- net : Network - The powsybl network to switch the coupler in, will be modified in-place + The powsybl network to switch the coupler in, modified in place. coupler_id : str - The id of the coupler to switch, should be in net.get_switches + The id of the coupler to switch, expected in ``net.get_switches()``. target_state : bool - The target state of the coupler, True for open, False for closed + Target switch state, ``True`` for open and ``False`` for closed. Returns ------- bool - True if the coupler was switched, False if it was already in the target state + ``True`` if the coupler was switched, ``False`` if it already had the target state. + + Raises + ------ + ValueError + If the coupler is missing in the network. """ switches_df = net.get_switches(attributes=["open"]) if coupler_id not in switches_df.index: @@ -455,8 +779,8 @@ def set_coupler( return True -def apply_station_bus_branch(net: Network, station: Station) -> RealizedStation: - """Apply a station topology to a powsybl model in bus/branch format +def apply_station_bus_branch(net: Network, station: MaterializedStation) -> AppliedStation: + """Apply a station topology to a powsybl model in bus/branch format. This will assume that the substations are in bus/branch format and that the busbars in the station are the same as in the asset topology, just that the assets are not yet on their spot and the busbar couplers are not yet correctly switched @@ -469,13 +793,14 @@ def apply_station_bus_branch(net: Network, station: Station) -> RealizedStation: busbar. Furthermore, we assume to find the bus-breaker buses in the voltage level to represent the busbars in the asset topology. If there are more buses, these additional buses will be ignored, if there are fewer buses, an exception is raised. Will be modified in-place. - station : Station - The asset topology station. The switching state of the assets and busbar couplers shall be applied to the matched - station in the powsybl grid + station : MaterializedStation + The asset topology station. The split branch and injection switching + tables, plus the coupler states, are applied to the matched station in + the powsybl grid. Returns ------- - RealizedStation + AppliedStation The realized station object which contains the input station plus a diff of switched couplers, reassignments and disconnections. @@ -486,31 +811,26 @@ def apply_station_bus_branch(net: Network, station: Station) -> RealizedStation: """ assert_station_in_network(net, station, couplers_strict=False, assets_strict=False, busbars_strict=False) - disconnection_diff = [] - reassignment_diff = [] coupler_diff = [] - - for asset_index in range(len(station.assets)): - result, new_reassignments = apply_single_asset_bus_branch(net, station, asset_index) - if result == "disconnected": - disconnection_diff.append(asset_index) - elif result == "reassigned": - reassignment_diff.extend(new_reassignments) + branch_disconnection_diff, branch_reassignment_diff = apply_single_branch_bus_branch(net, station) + injection_disconnection_diff, injection_reassignment_diff = apply_single_injection_bus_branch(net, station) for coupler in station.couplers: if set_coupler(net, coupler.grid_model_id, coupler.open): coupler_diff.append(coupler) - return RealizedStation( + return AppliedStation( station=station, - disconnection_diff=disconnection_diff, - reassignment_diff=reassignment_diff, coupler_diff=coupler_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, ) def apply_topology_bus_branch(net: Network, topology: Topology) -> RealizedTopology: - """Apply an asset topology to a network and return the diff + """Apply an asset topology to a network and return the diff. This takes an asset topology and applies it to the network. It will return the diff that it had to do to reach the asset topology in the form of a RealizedTopology @@ -531,37 +851,44 @@ def apply_topology_bus_branch(net: Network, topology: Topology) -> RealizedTopol Returns ------- RealizedTopology - The realized topology object which contains the input topology plus a diff of switched couplers, reassignments and - disconnections. + The realized topology object containing the input topology plus the + coupler, reassignment, and disconnection diffs. """ - realized_stations = [apply_station_bus_branch(net, station) for station in topology.stations] + realized_stations = [apply_station_bus_branch(net, station) for station in topology.materialize_stations()] - coupler_diff, reassignment_diff, disconnection_diff = accumulate_diffs(realized_stations) + ( + coupler_diff, + branch_reassignment_diff, + injection_reassignment_diff, + branch_disconnection_diff, + injection_disconnection_diff, + ) = accumulate_diffs(realized_stations) return RealizedTopology( topology=topology, coupler_diff=coupler_diff, - reassignment_diff=reassignment_diff, - disconnection_diff=disconnection_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, ) @pa.check_types def apply_node_breaker_topology(net: Network, target_topology: Topology) -> pa.typing.DataFrame[SwitchUpdateSchema]: - """Apply a node-breaker Topology to a powsybl network + """Apply a node-breaker topology to a powsybl network. Parameters ---------- net : Network The powsybl network to modify, will be modified in place. target_topology : Topology - The target topology to apply to the network, i.e. how you want the network to look like + The target topology to apply to the network. Returns ------- - switch_update_df: SwitchUpdateSchema - The dataframe of the switches that were updated. This is the same as the one returned by get_diff_switch_states - but with the index set to the switch id. + pa.typing.DataFrame[SwitchUpdateSchema] + The dataframe of switches that were updated. """ switch_update_df = get_changing_switches_from_topology(network=net, target_topology=target_topology) switch_update_df.rename(columns={"grid_model_id": "id"}, inplace=True) @@ -575,12 +902,12 @@ def apply_node_breaker_topology(net: Network, target_topology: Topology) -> pa.t def is_node_breaker_grid(net: Network, relevant_station: Optional[str] = None) -> bool: - """Check if the network is in node-breaker format + """Check if the network is in node-breaker format. Parameters ---------- net : Network - The powsybl network to check + The powsybl network to check. relevant_station : Optional[str] The name of any relevant station to check. It is possible that a grid has a mix of node-breaker and bus/branch but the relevant stations should be all uniform, in one of the two formats. @@ -600,8 +927,10 @@ def is_node_breaker_grid(net: Network, relevant_station: Optional[str] = None) - ) -def apply_station(net: Network, station: Station) -> Union[pa.typing.DataFrame[SwitchUpdateSchema], RealizedStation]: - """Apply a station topology to a powsybl model +def apply_station( + net: Network, topology: Topology, raw_station: RawStation +) -> Union[pa.typing.DataFrame[SwitchUpdateSchema], AppliedStation]: + """Apply a station topology to a powsybl model. This will apply the station topology to the network. If the network is in bus/branch format, it will return the realized station. If the network is in node/breaker format, it will return the switch update dataframe. @@ -614,23 +943,34 @@ def apply_station(net: Network, station: Station) -> Union[pa.typing.DataFrame[S busbar. Furthermore, we assume to find the bus-breaker buses in the voltage level to represent the busbars in the asset topology. If there are more buses, these additional buses will be ignored, if there are fewer buses, an exception is raised. Will be modified in-place. - station : Station - The asset topology station. The switching state of the assets and busbar couplers shall be applied to the matched - station in the powsybl grid + topology : Topology + The owning topology that provides canonical assets and asset bays for the raw station. + raw_station : RawStation + The lean station view to apply. The switching state of the assets and busbar couplers shall be applied to the + matched station in the powsybl grid. Returns ------- - Union[pa.typing.DataFrame[SwitchUpdateSchema], RealizedStation] + Union[pa.typing.DataFrame[SwitchUpdateSchema], AppliedStation] The realized station object which contains the input station plus a diff of switched couplers, reassignments and disconnections or a dataframe of switches that were updated. + + Raises + ------ + ValueError + If the station cannot be matched into the expected powsybl representation. """ - if is_node_breaker_grid(net=net, relevant_station=station.grid_model_id): + station_topology = copy_topology_with_updates( + topology, + [raw_station], + topology.asset_bays, + branch_assets=topology.branch_assets, + injection_assets=topology.injection_assets, + ) + + if is_node_breaker_grid(net=net, relevant_station=raw_station.grid_model_id): return apply_node_breaker_topology( net=net, - target_topology=Topology( - topology_id="this_id_will_be_ignored", - stations=[station], - timestamp=datetime.now(), - ), + target_topology=station_topology, ) - return apply_station_bus_branch(net=net, station=station) + return apply_station_bus_branch(net=net, station=station_topology.materialize_stations()[0]) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_pandapower.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_pandapower.py index 078323814..190783633 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_pandapower.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_pandapower.py @@ -32,8 +32,12 @@ from toop_engine_grid_helpers.pandapower.pandapower_id_helpers import ( parse_globally_unique_id, ) -from toop_engine_interfaces.asset_topology import RealizedStation, RealizedTopology -from toop_engine_interfaces.asset_topology_helpers import accumulate_diffs, electrical_components +from toop_engine_interfaces.asset_topology.applied_topology import ( + AppliedStation, + RealizedTopology, +) +from toop_engine_interfaces.asset_topology.asset_topology_helpers import accumulate_diffs, electrical_components +from toop_engine_interfaces.asset_topology.topology_conversion import topology_from_materialized_stations from toop_engine_interfaces.loadflow_result_helpers_polars import extract_solver_matrices_polars from toop_engine_interfaces.loadflow_results_polars import LoadflowResultsPolars from toop_engine_interfaces.nminus1_definition import Contingency, Nminus1Definition @@ -76,7 +80,7 @@ def apply_topology( """ net = deepcopy(net) - realized_stations: list[RealizedStation] = [] + realized_stations: list[AppliedStation] = [] for action in actions: # Apply the action to the network if action >= len(action_set.local_actions): @@ -84,16 +88,23 @@ def apply_topology( _diff, realized_station = apply_station(net, action_set.local_actions[action]) realized_stations.append(realized_station) - coupler_diff, reassignment_diff, disconnection_diff = accumulate_diffs(realized_stations) + ( + coupler_diff, + branch_reassignment_diff, + injection_reassignment_diff, + branch_disconnection_diff, + injection_disconnection_diff, + ) = accumulate_diffs(realized_stations) realized_topology = RealizedTopology( - topology=action_set.starting_topology.model_copy( - update={ - "stations": [s.station for s in realized_stations], - } + topology=topology_from_materialized_stations( + action_set.starting_topology, + [s.station for s in realized_stations], ), coupler_diff=coupler_diff, - reassignment_diff=reassignment_diff, - disconnection_diff=disconnection_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, ) return net, realized_topology @@ -194,48 +205,48 @@ def compute_cross_coupler_flows( assert len(components) == 2, "The split station must have exactly two sides" busbars_a = list(components[0]) - for index, asset in enumerate(station.assets): - asset_id, asset_type = parse_globally_unique_id(asset.grid_model_id) - asset_id = int(asset_id) - - if station.asset_switching_table[busbars_a, index].any(): - # The asset is on busbar A, include it - if asset.is_branch() is True: - if asset.branch_end is None: - raise ValueError("Branch end must be set in asset topo") - from_end = asset.branch_end in ("from", "hv") - p_on_a += get_pandapower_branch_loadflow_results_sequence( - net, types=[asset_type], ids=[asset_id], measurement="active", from_end=from_end, adjust_signs=False - ).item() - - q_on_a += np.nan_to_num( - get_pandapower_branch_loadflow_results_sequence( - net, - types=[asset_type], - ids=[asset_id], - measurement="reactive", - from_end=from_end, - adjust_signs=False, - ) - ).item() - elif asset.is_branch() is False: - p_on_a += get_pandapower_loadflow_results_injection( + for index, asset_connection in enumerate(station.branch_connections): + if station.branch_switching_table[busbars_a, index].any(): + asset_id, asset_type = parse_globally_unique_id(asset_connection.asset.grid_model_id) + asset_id = int(asset_id) + branch_end = asset_connection.branch_end + if branch_end is None: + raise ValueError("Branch end must be set in asset topo") + from_end = branch_end in ("from", "hv") + p_on_a += get_pandapower_branch_loadflow_results_sequence( + net, types=[asset_type], ids=[asset_id], measurement="active", from_end=from_end, adjust_signs=False + ).item() + + q_on_a += np.nan_to_num( + get_pandapower_branch_loadflow_results_sequence( + net, + types=[asset_type], + ids=[asset_id], + measurement="reactive", + from_end=from_end, + adjust_signs=False, + ) + ).item() + + for index, asset_connection in enumerate(station.injection_connections): + if station.injection_switching_table[busbars_a, index].any(): + asset_id, asset_type = parse_globally_unique_id(asset_connection.asset.grid_model_id) + asset_id = int(asset_id) + p_on_a += get_pandapower_loadflow_results_injection( + net, + types=[asset_type], + ids=[asset_id], + reactive=False, + ).item() + + q_on_a += np.nan_to_num( + get_pandapower_loadflow_results_injection( net, types=[asset_type], ids=[asset_id], - reactive=False, - ).item() - - q_on_a += np.nan_to_num( - get_pandapower_loadflow_results_injection( - net, - types=[asset_type], - ids=[asset_id], - reactive=True, - ) - ).item() - else: - raise ValueError("Asset type must be set in asset topo to determine branch/injection") + reactive=True, + ) + ).item() cross_coupler_flows_p.append(p_on_a) cross_coupler_flows_q.append(q_on_a) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_powsybl.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_powsybl.py index 66f923a07..a57383446 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_powsybl.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/postprocess_powsybl.py @@ -46,7 +46,8 @@ extract_single_injection_loadflow_result, load_powsybl_from_fs, ) -from toop_engine_interfaces.asset_topology_helpers import electrical_components +from toop_engine_interfaces.asset_topology.asset_topology_helpers import electrical_components +from toop_engine_interfaces.asset_topology.topology_conversion import topology_from_materialized_stations from toop_engine_interfaces.loadflow_results_polars import LoadflowResultsPolars from toop_engine_interfaces.nminus1_definition import Contingency, Nminus1Definition from toop_engine_interfaces.stored_action_set import ActionSet @@ -135,11 +136,11 @@ def apply_topology(net: Network, actions: list[int], action_set: ActionSet) -> A return None stations = [action_set.local_actions[action] for action in actions] - changed_stations_topo = action_set.starting_topology.model_copy(update={"stations": stations}) - if is_node_breaker_grid(net, stations[0].grid_model_id): + changed_stations_topo = topology_from_materialized_stations(action_set.starting_topology, stations) additional_info = apply_node_breaker_topology(net, changed_stations_topo) else: + changed_stations_topo = topology_from_materialized_stations(action_set.simplified_starting_topology, stations) additional_info = apply_topology_bus_branch(net, changed_stations_topo) return additional_info @@ -265,23 +266,24 @@ def compute_cross_coupler_flows( p_sum = 0.0 q_sum = 0.0 - for index, asset in enumerate(station.assets): - if station.asset_switching_table[busbars_a, index].any(): - # The asset is on busbar A, include it - if asset.is_branch() is True: - if asset.branch_end is None: - raise ValueError("Branch end is None") - from_end = asset.branch_end in ("from", "hv") - p, q = extract_single_branch_loadflow_result(branch_res, asset.grid_model_id, from_end) - p_sum += p - q_sum += q - elif asset.is_branch() is False: - p, q = extract_single_injection_loadflow_result( - injection_res, - asset.grid_model_id, - ) - p_sum += p - q_sum += q + for index, asset_connection in enumerate(station.branch_connections): + if station.branch_switching_table[busbars_a, index].any(): + branch_end = asset_connection.branch_end + if branch_end is None: + raise ValueError("Branch end is None") + from_end = branch_end in ("from", "hv") + p, q = extract_single_branch_loadflow_result(branch_res, asset_connection.asset.grid_model_id, from_end) + p_sum += p + q_sum += q + + for index, asset_connection in enumerate(station.injection_connections): + if station.injection_switching_table[busbars_a, index].any(): + p, q = extract_single_injection_loadflow_result( + injection_res, + asset_connection.asset.grid_model_id, + ) + p_sum += p + q_sum += q active_power.append(p_sum) reactive_power.append(q_sum) @@ -375,8 +377,8 @@ def _get_relevant_station_id(self) -> Optional[str]: return None for topology in (self.action_set.starting_topology, self.action_set.simplified_starting_topology): - if topology is not None and len(topology.stations): - return topology.stations[0].grid_model_id + if topology is not None and len(topology.raw_stations): + return topology.raw_stations[0].grid_model_id return None @contextmanager diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/realize_assignment.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/realize_assignment.py index ce7771077..2a1834f43 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/realize_assignment.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/postprocess/realize_assignment.py @@ -17,12 +17,51 @@ from jaxtyping import Array, Bool, Int from toop_engine_dc_solver.preprocess.helpers.switching_distance import per_station_switching_distance from toop_engine_dc_solver.preprocess.preprocess_switching import OptimalSeparationSetInfo -from toop_engine_interfaces.asset_topology import Station +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import ( + _validate_station_physical_assignments, + _validate_station_switching_tables, +) from toop_engine_interfaces.messages.preprocess.preprocess_commands import ReassignmentLimits logger = structlog.get_logger(__name__) +def _validate_realized_station_update( + station: MaterializedStation, + action_switching: Bool[np.ndarray, " n_busbars n_branches"], + action_coupler_states: Bool[np.ndarray, " n_couplers"], +) -> None: + """Validate the mutated fields of a realized station. + + The realization step only changes the branch switching table and coupler open states. + All other station-local payloads stay identical to the validated input station, so a full + pydantic round-trip per action is unnecessary in this hot path. + """ + if len(action_coupler_states) != len(station.couplers): + raise ValueError( + f"Coupler state count {len(action_coupler_states)} does not match station coupler count " + f"{len(station.couplers)} for station {station.grid_model_id}." + ) + + _validate_station_switching_tables( + station_grid_model_id=station.grid_model_id, + station_name=station.name, + busbar_count=len(station.busbars), + asset_count=len(station.branch_connections), + asset_switching_table=np.asarray(action_switching, dtype=bool), + asset_connectivity=station.branch_connectivity, + asset_kind="branch", + ) + _validate_station_physical_assignments( + station_grid_model_id=station.grid_model_id, + station_name=station.name, + asset_switching_table=np.asarray(action_switching, dtype=bool), + asset_connectivity=station.branch_connectivity, + asset_kind="branch", + ) + + @partial(jax.jit, static_argnames=("batch_size", "choice_heuristic")) def _compute_switching_tables_for_actions( local_branch_action_set: Bool[Array, " n_combinations n_assets"], @@ -345,13 +384,13 @@ def compute_switching_table( def realise_ba_to_physical_topo_per_station_jax( local_branch_action_set: Bool[np.ndarray, " n_combinations n_branches"], - station: Station, + station: MaterializedStation, separation_set_info: OptimalSeparationSetInfo, batch_size: int = 1024, choice_heuristic: Literal["first", "least_connected_busbar", "most_connected_busbar"] = "least_connected_busbar", validate: bool = True, reassignment_limits: Optional[ReassignmentLimits] = None, -) -> tuple[list[Station], Bool[np.ndarray, "n_combinations n_branches"], list[list[int]], list[int]]: +) -> tuple[list[MaterializedStation], Bool[np.ndarray, "n_combinations n_branches"], list[list[int]], list[int]]: """Realize the branch actions to physical topology per station. This iterates over all actions in the local branch action set and tries to find a realization for them. @@ -427,16 +466,20 @@ def realise_ba_to_physical_topo_per_station_jax( local_branch_action_set = local_branch_action_set[1:] n_branches = local_branch_action_set.shape[1] - # Currently we ignore injection assets which are at the end of the separation set due to the sorting within the - # simplification process for the station. separation_set = separation_set[:, :, :n_branches] - asset_switching_table = station.asset_switching_table[:, :n_branches] - asset_connectivity = ( - station.asset_connectivity[:, :n_branches] - if station.asset_connectivity is not None - else np.ones_like(asset_switching_table, dtype=bool) - ) + branch_switching_table = np.asarray(station.branch_switching_table, dtype=bool) + branch_connectivity = ( + station.branch_connectivity + if station.branch_connectivity is not None + else np.ones_like(station.branch_switching_table, dtype=bool) + ) + assert branch_switching_table.shape[1] == local_branch_action_set.shape[1], ( + "The number of branches in the station must match the number of branches in the local action set." + ) + assert branch_connectivity.shape[1] == local_branch_action_set.shape[1], ( + "The number of branches in the station must match the number of branches in the local action set." + ) # Map over the local action set and compute a switching table for each action with jax.default_device(jax.devices("cpu")[0]): ( @@ -452,8 +495,8 @@ def realise_ba_to_physical_topo_per_station_jax( separation_set=jnp.array(separation_set, dtype=bool), coupler_states=jnp.array(coupler_states, dtype=bool), busbar_mapping=jnp.array(busbar_b_array, dtype=bool), - current_switching_table=jnp.array(asset_switching_table, dtype=bool), - asset_connectivity=jnp.array(asset_connectivity, dtype=bool), + current_switching_table=jnp.array(branch_switching_table, dtype=bool), + asset_connectivity=jnp.array(branch_connectivity, dtype=bool), batch_size=batch_size, choice_heuristic=choice_heuristic, ) @@ -478,23 +521,42 @@ def realise_ba_to_physical_topo_per_station_jax( phy_reassignment_distance = phy_reassignment_distance[within_limit] # Create the realised stations - realised_stations = [ - station.model_copy( - update={ - "asset_switching_table": np.concatenate( - [action_switching, station.asset_switching_table[:, n_branches:]], axis=1 - ), - "couplers": [ - coupler.model_copy(update={"open": bool(open)}) - for coupler, open in zip(station.couplers, action_coupler_states, strict=True) + coupler_payloads = [coupler.model_dump(round_trip=True) for coupler in station.couplers] + realised_stations: list[MaterializedStation] = [] + for action_switching, action_coupler_states in zip(switching_table, chosen_coupler_state, strict=True): + if validate: + _validate_realized_station_update( + station=station, + action_switching=action_switching, + action_coupler_states=action_coupler_states, + ) + + realised_stations.append( + MaterializedStation.model_construct( + grid_model_id=station.grid_model_id, + name=station.name, + station_type=station.station_type, + region=station.region, + voltage_level=station.voltage_level, + busbars=station.busbars, + couplers=[ + type(coupler).model_construct(**(payload | {"open": bool(open_state)})) + for coupler, payload, open_state in zip( + station.couplers, + coupler_payloads, + action_coupler_states, + strict=True, + ) ], - } + branch_connections=station.branch_connections, + injection_connections=station.injection_connections, + branch_switching_table=action_switching, + injection_switching_table=station.injection_switching_table, + branch_connectivity=station.branch_connectivity, + injection_connectivity=station.injection_connectivity, + model_log=station.model_log, + ) ) - for action_switching, action_coupler_states in zip(switching_table, chosen_coupler_state, strict=True) - ] - if validate: - for realised_station in realised_stations: - Station.model_validate(realised_station) # Convert the busbar mapping to a list of busbar A mappings busbar_mappings_converted = [np.flatnonzero(~mapping).tolist() for mapping in chosen_busbar_mapping] diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/action_set.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/action_set.py index 6d478d970..c9edf7a86 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/action_set.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/action_set.py @@ -39,8 +39,7 @@ ) from toop_engine_dc_solver.preprocess.helpers.switching_distance import min_hamming_distance_matrix from toop_engine_dc_solver.preprocess.network_data import NetworkData, get_relevant_stations -from toop_engine_interfaces.asset_topology import Station -from toop_engine_interfaces.asset_topology_helpers import get_connected_assets +from toop_engine_interfaces.asset_topology.asset_topology import RawStation from toop_engine_interfaces.messages.preprocess.preprocess_commands import ReassignmentLimits logger = structlog.get_logger(__name__) @@ -632,7 +631,7 @@ def unpad_branch_actions( def determine_injection_topology_sub( network_data: NetworkData, local_injection_idxs: Int[np.ndarray, " n_injections_at_node"], - station: Station, + station: RawStation, n_local_branch_actions: int, local_busbar_a_mapping: list[list[int]], n_injections_at_node: int, @@ -651,7 +650,7 @@ def determine_injection_topology_sub( The network data containing injection and branch information. local_injection_idxs : list[int] List of local injection indices corresponding to the station. - station : Station + station : RawStation The station object containing information about busbars and connected assets. n_local_branch_actions : int Number of local branch actions to consider. @@ -675,8 +674,11 @@ def determine_injection_topology_sub( bba_connected_injection_ids = [ asset.grid_model_id for bb_index in busbar_a_mapping - for asset in get_connected_assets(station, bb_index) - if not asset.is_branch() + for asset in station.get_connected_assets( + bb_index, + topology_assets=network_data.simplified_asset_topology.injection_assets, + asset_scope="injection", + ) ] bba_connected_injection_idxs = [ np.argmax(local_injection_idxs == network_data.injection_ids.index(injection_id)) diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/helpers/ptdf.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/helpers/ptdf.py index 9fd609696..6b67c08d9 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/helpers/ptdf.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/helpers/ptdf.py @@ -19,6 +19,7 @@ def compute_ptdf( to_node: Int[np.ndarray, " n_branch"], susceptances: Float[np.ndarray, " n_branch"], slack_bus: int, + branch_flow_susceptances: Optional[Float[np.ndarray, " n_branch"]] = None, ) -> Float[np.ndarray, " n_branch n_node"]: """Calculate the PTDF (Power Transfer Distiribution Factors). @@ -37,9 +38,12 @@ def compute_ptdf( The to-nodes vector. Changes if the topology changes, e.g. the to-bus of a branch can be set to the second bus of a substation. susceptances : Float[np.ndarray, " n_branch"] - Vector with the susceptances for the branches. + Vector with the susceptances used in the nodal angle solve. slack_bus: int The slack bus of the grid. Cannot be distributed for the bsdf formulation to work + branch_flow_susceptances : Optional[Float[np.ndarray, " n_branch"]] + Optional branch coefficients used when mapping bus angles to branch flows. If not given, + the nodal susceptances are used for both the nodal solve and the branch-flow rows. number_of_busses: int How many busbars are in the system-> How many nodes in the ptdf @@ -50,6 +54,8 @@ def compute_ptdf( """ number_of_busses = int(np.max(np.concatenate([from_node, to_node]))) + 1 number_of_branches = int(from_node.shape[0]) + if branch_flow_susceptances is None: + branch_flow_susceptances = susceptances # use bus 1 for voltage angle reference noref = np.arange(1, number_of_busses) @@ -58,7 +64,12 @@ def compute_ptdf( ptdf = np.zeros((number_of_branches, number_of_busses)) node_node_susceptance, branch_node_susceptance, *_ = get_susceptance_matrices( - from_node, to_node, susceptances, number_of_branches, number_of_busses + from_node, + to_node, + susceptances, + number_of_branches, + number_of_busses, + branch_flow_susceptances=branch_flow_susceptances, ) ptdf[:, noslack] = spsolve( node_node_susceptance[np.ix_(noslack, noref)].T, @@ -77,6 +88,7 @@ def get_susceptance_matrices( susceptances: Float[np.ndarray, " n_branch"], number_of_branches: int, number_of_busses: int, + branch_flow_susceptances: Optional[Float[np.ndarray, " n_branch"]] = None, ) -> tuple[spmatrix, spmatrix]: """Build the B matrices necessary for DC power flow and PTDF calculation. @@ -96,11 +108,14 @@ def get_susceptance_matrices( The to-nodes vector. Changes if the topology changes, e.g. the to-bus of a branch can be set to the second bus of a substation. susceptances : Float[np.ndarray, " n_branch"] - Vector with the susceptances for the branches. + Vector with the susceptances used in the nodal angle solve. number_of_branches: int How many branches are in the system number_of_busses: int How many busbars are in the system + branch_flow_susceptances : Optional[Float[np.ndarray, " n_branch"]] + Optional branch coefficients used for the branch-to-node flow matrix. If not given, + the nodal susceptances are used. Returns ------- @@ -109,14 +124,19 @@ def get_susceptance_matrices( branch_node_susceptance: Float[np.ndarray, " n_branch n_node"] BranchxNode Matrix allowing for the calculation of the power flow """ + if branch_flow_susceptances is None: + branch_flow_susceptances = susceptances + # build connection matrix Cft = Cf - Ct for line and from - to busses connectivity_matrix = get_connectivity_matrix(from_node, to_node, number_of_branches, number_of_busses) - # Build branch-to-node susceptance matrix - branch_node_susceptance = csc_matrix(connectivity_matrix.multiply(susceptances[:, np.newaxis])) + nodal_branch_node_susceptance = csc_matrix(connectivity_matrix.multiply(susceptances[:, np.newaxis])) + + # Build branch-to-node susceptance matrix used to map bus angles to branch flows. + branch_node_susceptance = csc_matrix(connectivity_matrix.multiply(branch_flow_susceptances[:, np.newaxis])) # build node_node_susceptance - node_node_susceptance = connectivity_matrix.T * branch_node_susceptance + node_node_susceptance = connectivity_matrix.T * nodal_branch_node_susceptance return node_node_susceptance, branch_node_susceptance diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/network_data.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/network_data.py index 5ac03d5ef..2b0a1271b 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/network_data.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/network_data.py @@ -17,7 +17,8 @@ from jaxtyping import Bool, Float, Int from toop_engine_dc_solver.preprocess.preprocess_switching import OptimalSeparationSetInfo from toop_engine_grid_helpers.powsybl.powsybl_helpers import load_lf_params_from_fs -from toop_engine_interfaces.asset_topology import Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import RawStation, Topology +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation from toop_engine_interfaces.backend import BackendInterface from toop_engine_interfaces.nminus1_definition import Contingency, GridElement, MonitoredElement, Nminus1Definition from toop_engine_interfaces.stored_action_set import ActionSet, PSTRange @@ -310,7 +311,7 @@ class NetworkData: """The mask over nodes that are a controllable phase shifter. When adding the PSDF matrix, bogus nodes will be included. The ones that refer to a controllable PST will be mentioned in this mask.""" - realised_stations: Optional[list[list[Station]]] = None + realised_stations: Optional[list[list[MaterializedStation]]] = None """The realised stations for each relevant node depending on the branch_actions. The outer list is of length equal to the number of relevant nodes. The inner list if of length equal to the number of branch actions feasible for the given node. Each station is a simplified station.""" @@ -571,34 +572,34 @@ def validate_network_data(network_data: NetworkData) -> None: ): assert len(branch_act) == len(inj_act) == len(sw_dist) - assert len(network_data.simplified_asset_topology.stations) == n_rel_subs + assert len(network_data.simplified_asset_topology.raw_stations) == n_rel_subs assert len(network_data.realised_stations) == n_rel_subs for realizations in network_data.realised_stations: for realized_station in realizations: - Station.model_validate(realized_station) + MaterializedStation.model_validate(realized_station) assert len(network_data.busbar_a_mappings) == n_rel_subs assert len(network_data.branch_action_set_switching_distance) == n_rel_subs -def get_relevant_stations(network_data: NetworkData) -> list[Station]: +def get_relevant_stations(network_data: NetworkData) -> list[RawStation]: """ - Get the relevant asset-topology stations from the network data. + Get the relevant raw asset-topology stations from the network data. Parameters ---------- network_data : NetworkData - The network data containing asset topology. + The network data containing the simplified asset topology and relevant node mask. Returns ------- - list[Station] - A list of relevant stations. + list[RawStation] + The relevant raw stations in the same order as the relevant nodes. """ relevant_node_ids = [ node for node, mask in zip(network_data.node_ids, network_data.relevant_node_mask, strict=True) if mask ] - def find_station(stations: list[Station], grid_model_id: str, fallback: Optional[Station] = None) -> Station: + def find_station(stations: list[RawStation], grid_model_id: str, fallback: Optional[RawStation] = None) -> RawStation: for station in stations: if station.grid_model_id == grid_model_id: return station @@ -606,7 +607,7 @@ def find_station(stations: list[Station], grid_model_id: str, fallback: Optional return fallback raise ValueError(f"Could not find station with grid_model_id {grid_model_id}") - return [find_station(network_data.simplified_asset_topology.stations, node_id) for node_id in relevant_node_ids] + return [find_station(network_data.simplified_asset_topology.raw_stations, node_id) for node_id in relevant_node_ids] def map_branch_injection_ids( @@ -738,14 +739,14 @@ def extract_nminus1_definition(network_data: NetworkData) -> Nminus1Definition: network_data.simplified_asset_topology if network_data.simplified_asset_topology else network_data.asset_topology ) monitored_nodes = [ - MonitoredElement(id=busbar.grid_model_id, name=busbar.name or "", type=busbar.type, kind="bus") - for station in asset_topology.stations + MonitoredElement(id=busbar.grid_model_id, name=busbar.name or "", type=busbar.busbar_type, kind="bus") + for station in asset_topology.materialize_stations() for busbar in station.busbars ] monitored_switches = [ - MonitoredElement(id=switch.grid_model_id, name=switch.name or "", type=switch.type, kind="switch") - for station in asset_topology.stations + MonitoredElement(id=switch.grid_model_id, name=switch.name or "", type=switch.coupler_type, kind="switch") + for station in asset_topology.materialize_stations() for switch in station.couplers ] diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/pandapower/pandapower_backend.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/pandapower/pandapower_backend.py index 293e2feaf..d4b81d63b 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/pandapower/pandapower_backend.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/pandapower/pandapower_backend.py @@ -36,7 +36,7 @@ get_trafo3w_ppc_branch_idx, get_trafo3w_ppc_node_idx, ) -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.backend import BackendInterface from toop_engine_interfaces.filesystem_helper import load_numpy_filesystem, load_pydantic_model_fs from toop_engine_interfaces.folder_structure import ( diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_backend.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_backend.py index 949a0ef94..a007eca6d 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_backend.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_backend.py @@ -28,7 +28,7 @@ ) from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK from toop_engine_grid_helpers.powsybl.powsybl_helpers import load_powsybl_from_fs -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.backend import BackendInterface from toop_engine_interfaces.filesystem_helper import load_numpy_filesystem, load_pydantic_model_fs from toop_engine_interfaces.folder_structure import ( diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess.py index 33d9b8006..d1f9586c0 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess.py @@ -72,7 +72,8 @@ make_optimal_separation_set, prepare_for_separation_set, ) -from toop_engine_interfaces.asset_topology_helpers import order_topology +from toop_engine_interfaces.asset_topology.asset_topology_helpers import order_topology +from toop_engine_interfaces.asset_topology.topology_conversion import topology_from_materialized_stations from toop_engine_interfaces.backend import BackendInterface from toop_engine_interfaces.messages.preprocess.preprocess_commands import PreprocessParameters, ReassignmentLimits from toop_engine_interfaces.messages.preprocess.preprocess_heartbeat import ( @@ -195,7 +196,7 @@ def filter_relevant_nodes_no_asset_station(network_data: NetworkData) -> Network """ assert network_data.asset_topology is not None, "Asset topology has to be passed in" relevant_node_ids = np.array(network_data.node_ids)[np.flatnonzero(network_data.relevant_node_mask)] - station_ids = np.array([station.grid_model_id for station in network_data.asset_topology.stations]) + station_ids = np.array([station.grid_model_id for station in network_data.asset_topology.materialize_stations()]) keep_mask = np.isin(relevant_node_ids, station_ids) for node_id in relevant_node_ids[~keep_mask]: @@ -947,9 +948,9 @@ def remove_relevant_subs( simplified_asset_topology = ( network_data.simplified_asset_topology.model_copy( update={ - "stations": [ + "raw_stations": [ x - for x, has_action in zip(network_data.simplified_asset_topology.stations, keep_mask, strict=True) + for x, has_action in zip(network_data.simplified_asset_topology.raw_stations, keep_mask, strict=True) if has_action ] } @@ -1195,7 +1196,7 @@ def simplify_asset_topology(network_data: NetworkData, close_couplers: bool = Fa network_data.relevant_nodes, network_data.branches_at_nodes, network_data.injection_idx_at_nodes, - topology.stations, + topology.materialize_stations(), strict=True, ): assert network_data.node_ids[node_index] == station.grid_model_id, "The station id does not match the node id" @@ -1223,9 +1224,7 @@ def simplify_asset_topology(network_data: NetworkData, close_couplers: bool = Fa network_data = replace( network_data, - simplified_asset_topology=topology.model_copy( - update={"stations": stations}, - ), + simplified_asset_topology=topology_from_materialized_stations(topology, stations), ) return remove_relevant_subs(network_data, np.array(keep_mask, dtype=bool)) @@ -1254,7 +1253,7 @@ def compute_separation_set_for_stations( assert network_data.simplified_asset_topology is not None, "Please simplify the asset topology first" actions = 0 separation_sets_info: list[OptimalSeparationSetInfo] = [] - for station in network_data.simplified_asset_topology.stations: + for station in network_data.simplified_asset_topology.materialize_stations(): separation_set_info = make_optimal_separation_set(station, clip_hamming_distance, clip_at_size) separation_sets_info.append(separation_set_info) actions += separation_set_info.separation_set.shape[0] diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_bb_outage.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_bb_outage.py index c1022f43a..7b7892c64 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_bb_outage.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_bb_outage.py @@ -19,12 +19,33 @@ OutageData, get_relevant_stations, ) -from toop_engine_interfaces.asset_topology import Station, SwitchableAsset -from toop_engine_interfaces.asset_topology_helpers import find_station_by_id, get_connected_assets +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, +) +from toop_engine_interfaces.asset_topology.asset_topology_helpers import find_station_by_id +from toop_engine_interfaces.asset_topology.assets import BranchAsset, InjectionAsset, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation logger = structlog.get_logger(__name__) +def _topology_assets(network_data: NetworkData) -> list[SwitchableAsset]: + """Get the list of assets in the topology. + + Parameters + ---------- + network_data : NetworkData + The network data containing the asset topology. + + Returns + ------- + list[SwitchableAsset] + A list of switchable assets in the topology. + """ + topology = network_data.simplified_asset_topology or network_data.asset_topology + return [*topology.branch_assets, *topology.injection_assets] + + def get_total_injection_along_stub_branch( stub_branch_index: int, current_nodal_index: int, network_data: NetworkData ) -> Float[np.ndarray, " n_timestep"]: @@ -86,7 +107,7 @@ def get_total_injection_along_stub_branch( return total_injection -def get_busbar_index(station: Station, busbar_id: str) -> int: +def get_busbar_index(station: MaterializedStation, busbar_id: str) -> int: """Get the index of a busbar within a station by its grid_model_id of busbar (busbar_id). Parameters @@ -147,7 +168,7 @@ def extract_outage_index_injection_from_asset( branch_index_to_outage = None if asset.in_service: - if asset.is_branch(): + if isinstance(asset, BranchAsset): # Branch is a line or a trafo try: branch_index = network.branch_ids.index(asset.grid_model_id) @@ -168,7 +189,7 @@ def extract_outage_index_injection_from_asset( branch_index, nodal_index_for_busbar, network ) stub_power_map[key] = nodal_injection_to_outage - else: + elif isinstance(asset, InjectionAsset): # Branch is an injection try: injection_index = network.injection_ids.index(asset.grid_model_id) @@ -205,15 +226,15 @@ def get_busbar_map_adjacent_branches(network_data: NetworkData) -> Bool[np.ndarr # Gather all branches connected to a station with a busbar outage for station_id in network_data.busbar_outage_map.keys(): # Find the asset topo station id - station = find_station_by_id(network_data.asset_topology.stations, station_id) - for asset in station.assets: - bb_outage_asset_indices.add(asset.grid_model_id) + station = find_station_by_id(network_data.asset_topology.materialize_stations(), station_id) + for asset_connection in station.branch_connections: + bb_outage_asset_indices.add(asset_connection.asset.grid_model_id) busbar_outage_branch_mask = np.array([(id in bb_outage_asset_indices) for id in network_data.branch_ids]) return busbar_outage_branch_mask -def get_busbar_branches_map(station: Station, network_data: NetworkData) -> dict[str, list[int]]: +def get_busbar_branches_map(station: MaterializedStation, network_data: NetworkData) -> dict[str, list[int]]: """Map each busbar in a station to the list of branch indices connected to it. These branch_indices index into network_data.branch_ids. @@ -238,9 +259,16 @@ def get_busbar_branches_map(station: Station, network_data: NetworkData) -> dict - The branch indices are determined based on their position in the `network_data.branch_ids` list. """ busbar_branches_map = {} + topology_assets = _topology_assets(network_data) for bb_index, bb in enumerate(station.busbars): - connected_assets = get_connected_assets(station, bb_index) - connected_branches = [asset.grid_model_id for asset in connected_assets if asset.is_branch() and asset.in_service] + connected_branches = [ + asset.grid_model_id + for asset in station.get_connected_assets( + bb_index, + topology_assets=topology_assets, + asset_scope="branch", + ) + ] connected_branches = [network_data.branch_ids.index(branch_id) for branch_id in connected_branches] busbar_branches_map[bb.grid_model_id] = connected_branches return busbar_branches_map @@ -290,7 +318,7 @@ def get_phy_bb_nodal_index( def extract_busbar_outage_data( - station: Station, + station: MaterializedStation, busbar_id: str, network: NetworkData, stub_power_map: dict[str, Float[np.ndarray, " n_timestep"]], @@ -329,7 +357,10 @@ def extract_busbar_outage_data( assert station.busbars[busbar_index].in_service, f"Busbar {busbar_id} is not in service. Cannot outage it." connected_branches_to_outage = [] - connected_assets = get_connected_assets(station, busbar_index) + connected_assets = station.get_connected_assets( + busbar_index, + topology_assets=_topology_assets(network), + ) node_indices_to_outage = np.nonzero(np.array(network.node_ids) == station.grid_model_id)[0].tolist() # Determine the nodal_index of the physical busbar. @@ -402,7 +433,7 @@ def update_network_data_with_non_rel_bb_outages( delta_p = [] nodal_indices = [] - for station in asset_topology.stations: + for station in asset_topology.materialize_stations(): if station.grid_model_id in outage_station_busbars_map: for bb_id in outage_station_busbars_map[station.grid_model_id]: (branch_indices_to_outage, nodal_injection_to_outage, node_index_to_outage) = extract_busbar_outage_data( @@ -515,7 +546,7 @@ def get_branch_injection_outages_for_rel_subs( def get_modified_stations( network_data: NetworkData, stations_to_outage: Optional[list[str]], -) -> list[Optional[list[Station]]]: +) -> list[Optional[list[MaterializedStation]]]: """Get the modified stations after applying branch actions. Note: We don't consider injection actions here. @@ -551,7 +582,7 @@ def get_modified_stations( def get_all_rel_bb_outage_data( - modified_stations: list[Optional[list[Station]]], + modified_stations: list[Optional[list[MaterializedStation]]], network_data: NetworkData, busbars_to_outage: Optional[set[str]] = None, ) -> list[Optional[list[list[Optional[OutageData]]]]]: @@ -744,7 +775,7 @@ def get_non_rel_articulation_nodes( , list[str]],network_data: NetworkData) -> dict[str, list[str]]: """ asset_topology = network_data.simplified_asset_topology - for station in asset_topology.stations: + for station in asset_topology.materialize_stations(): if station.grid_model_id in non_rel_busbar_outage_map: busbar_intid_index_mapper = {busbar.int_id: index for index, busbar in enumerate(station.busbars)} nodes = [busbar_index for busbar_index in range(len(station.busbars))] @@ -768,7 +799,7 @@ def get_non_rel_articulation_nodes( def get_rel_articulation_nodes( - rel_stations: list[Station], busbar_a_mappings: list[list[list[int]]] + rel_stations: list[RawStation | MaterializedStation], busbar_a_mappings: list[list[list[int]]] ) -> list[list[list[int]]]: """Determine the busbars that serve as articulation nodes for relevant substations in the network. @@ -780,7 +811,7 @@ def get_rel_articulation_nodes( Parameters ---------- - rel_stations : list[Station] + rel_stations : list[RawStation | MaterializedStation] A list of relevant substations in the network. busbar_a_mappings : list[list[list[int]]] A list of busbar_a mappings for each relevant substation. The outer list is of length equal to the number diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_station_realisations.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_station_realisations.py index 38185eb35..d2fee3094 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_station_realisations.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_station_realisations.py @@ -19,8 +19,7 @@ NetworkData, get_relevant_stations, ) -from toop_engine_interfaces.asset_topology import Station -from toop_engine_interfaces.asset_topology_helpers import get_connected_assets +from toop_engine_interfaces.asset_topology.asset_topology import RawStation from toop_engine_interfaces.messages.preprocess.preprocess_commands import ReassignmentLimits @@ -77,7 +76,7 @@ def enumerate_station_realisations( for index, (station, local_branch_action_set, separation_set_info) in enumerate( zip( - network_data.simplified_asset_topology.stations, + network_data.simplified_asset_topology.materialize_stations(), branch_action_set, network_data.separation_sets_info, strict=True, @@ -111,7 +110,7 @@ def enumerate_station_realisations( def get_injections_on_physical_bb( - network_data: NetworkData, sub: Station, busbar_index: int + network_data: NetworkData, sub: RawStation, busbar_index: int ) -> Float[np.ndarray, " n_timesteps"]: """Calculate the total connected injections in megawatts (MW) to a given physical busbar inside a substation. @@ -129,10 +128,12 @@ def get_injections_on_physical_bb( Float[np.ndarray, " n_timesteps"] The total connected injections in MW for the specified busbar index over all timesteps. """ - connected_assets = get_connected_assets(sub, busbar_index) - connected_injection_ids = [ - asset.grid_model_id for asset in connected_assets if asset.in_service and not asset.is_branch() - ] + connected_assets = sub.get_connected_assets( + busbar_index, + topology_assets=network_data.simplified_asset_topology.injection_assets, + asset_scope="injection", + ) + connected_injection_ids = [asset.grid_model_id for asset in connected_assets if asset.in_service] # Certain IDs in the connected_injection_ids may not be present in the network_data.injection_ids. # TODO: FInd out why? @@ -148,7 +149,7 @@ def get_injections_on_physical_bb( def get_injections_on_electrical_busbar( - network_data: NetworkData, sub: Station, busbar_mapping: list[int] + network_data: NetworkData, sub: RawStation, busbar_mapping: list[int] ) -> Float[np.ndarray, " n_timesteps"]: """Calculate the total injections on an electrical busbar. diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_switching.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_switching.py index d3384c782..0cfc99c77 100644 --- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_switching.py +++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/preprocess_switching.py @@ -12,6 +12,7 @@ import itertools from dataclasses import dataclass +from typing import get_args import jax.numpy as jnp import networkx as nx @@ -24,14 +25,11 @@ number_connected_components, ) from toop_engine_dc_solver.preprocess.helpers.switching_distance import hamming_distance -from toop_engine_interfaces.asset_topology import ( - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, +from toop_engine_interfaces.asset_topology.asset_topology import ( Topology, + copy_topology_with_updates, ) -from toop_engine_interfaces.asset_topology_helpers import ( +from toop_engine_interfaces.asset_topology.asset_topology_helpers import ( filter_disconnected_busbars, filter_duplicate_couplers, filter_out_of_service, @@ -39,6 +37,9 @@ fuse_all_couplers_with_type, order_station_assets, ) +from toop_engine_interfaces.asset_topology.asset_types import AssetInjectionType +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler, InjectionAsset, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation logger = structlog.get_logger(__name__) @@ -70,7 +71,7 @@ class OptimalSeparationSetInfo: def make_separation_set( - station: Station, + station: MaterializedStation, ) -> tuple[ Bool[np.ndarray, " n_configurations 2 ..."], Bool[np.ndarray, " n_configurations n_couplers"], @@ -123,13 +124,13 @@ def make_separation_set( """ assert all(busbar.in_service for busbar in station.busbars) assert all(coupler.in_service for coupler in station.couplers) - assert all(asset.in_service for asset in station.assets) + assert all(asset_connection.asset.in_service for asset_connection in station.branch_connections) if not len(station.couplers): - return (np.zeros((0, 2, len(station.assets)), dtype=bool), np.zeros((0, 0), dtype=bool), []) + return (np.zeros((0, 2, len(station.branch_connections)), dtype=bool), np.zeros((0, 0), dtype=bool), []) # Go through all possible coupler assignments and find all the configurations in which there are # exactly two electrical busbars. switching_map = { - busbar_index: station.asset_switching_table[busbar_index] for busbar_index, _ in enumerate(station.busbars) + busbar_index: station.branch_switching_table[busbar_index] for busbar_index, _ in enumerate(station.busbars) } busbar_int_id_map = {busbar.int_id: busbar_index for busbar_index, busbar in enumerate(station.busbars)} configurations = [] @@ -259,8 +260,8 @@ def __bool__(self) -> bool: def prepare_for_separation_set( - station: Station, branch_ids: list[str], injection_ids: list[str], close_couplers: bool = False -) -> tuple[Station, StationProblems]: + station: MaterializedStation, branch_ids: list[str], injection_ids: list[str], close_couplers: bool = False +) -> tuple[MaterializedStation, StationProblems]: """Prepare a Station so it can be used in make_separation_set. This function will: @@ -326,13 +327,13 @@ def prepare_for_separation_set( update={"model_log": (station.model_log or []) + [f"Problems during simplification: {problems}"]} ) - Station.model_validate(station) + MaterializedStation.model_validate(station) return station, problems def make_optimal_separation_set( - station: Station, + station: MaterializedStation, clip_hamming_distance: int = 0, clip_at_size: int = 100, ) -> OptimalSeparationSetInfo: @@ -423,29 +424,45 @@ def add_missing_asset_topology_branch_info( # Faster lookup of the position of the branch branch_id_lookup = {branch_id: i for i, branch_id in enumerate(branch_ids)} - new_stations = [] - for station in asset_topology.stations: - new_assets = [] - for asset in station.assets: - index = branch_id_lookup.get(asset.grid_model_id, None) - if index is not None: - branch_end = "from" if branch_from_nodes[index] == station.grid_model_id else "to" - new_assets.append( - asset.model_copy( - update={ - "name": (branch_names[index] if overwrite_if_present or asset.name is None else asset.name), - "type": (branch_types[index] if overwrite_if_present or asset.type is None else asset.type), - "branch_end": ( - branch_end if overwrite_if_present or asset.branch_end is None else asset.branch_end - ), - } - ) - ) + updated_assets_by_id: dict[str, SwitchableAsset] = {} + for asset in asset_topology.branch_assets: + index = branch_id_lookup.get(asset.grid_model_id, None) + if index is not None: + asset_data = asset.model_dump() + asset_data["name"] = branch_names[index] if overwrite_if_present or asset.name is None else asset.name + asset_data["asset_type"] = ( + branch_types[index] if overwrite_if_present or asset.asset_type is None else asset.asset_type + ) + updated_assets_by_id[asset.grid_model_id] = BranchAsset(**asset_data) + else: + updated_assets_by_id[asset.grid_model_id] = asset.model_copy(deep=True) + + updated_injection_assets = [asset.model_copy(deep=True) for asset in asset_topology.injection_assets] + + new_raw_stations = [] + for station in asset_topology.raw_stations: + new_branch_ends = [] + for asset_connection in station.branch_connections: + asset_id = asset_connection.asset_id + branch_end = asset_connection.branch_end + index = branch_id_lookup.get(asset_id, None) + if ( + index is not None + and isinstance(updated_assets_by_id[asset_id], BranchAsset) + and (overwrite_if_present or branch_end is None) + ): + new_branch_ends.append("from" if branch_from_nodes[index] == station.grid_model_id else "to") else: - new_assets.append(asset) - new_stations.append(station.model_copy(update={"assets": new_assets})) - - return asset_topology.model_copy(update={"stations": new_stations}) + new_branch_ends.append(branch_end) + new_raw_stations.append(station.with_asset_terminals(new_branch_ends)) + + return copy_topology_with_updates( + reference_topology=asset_topology, + raw_stations=new_raw_stations, + asset_bays=asset_topology.asset_bays, + branch_assets=[updated_assets_by_id[asset.grid_model_id] for asset in asset_topology.branch_assets], + injection_assets=updated_injection_assets, + ) def add_missing_asset_topology_injection_info( @@ -482,22 +499,28 @@ def add_missing_asset_topology_injection_info( # Faster lookup of the position of the injection injection_id_lookup = {injection_id: i for i, injection_id in enumerate(injection_ids)} - new_stations = [] - for station in asset_topology.stations: - new_assets = [] - for asset in station.assets: - index = injection_id_lookup.get(asset.grid_model_id, None) - if index is not None: - new_assets.append( - asset.model_copy( - update={ - "name": (injection_names[index] if overwrite_if_present or asset.name is None else asset.name), - "type": (injection_types[index] if overwrite_if_present or asset.type is None else asset.type), - } - ) - ) + updated_branch_assets = [asset.model_copy(deep=True) for asset in asset_topology.branch_assets] + updated_injection_assets = [] + for asset in asset_topology.injection_assets: + index = injection_id_lookup.get(asset.grid_model_id, None) + if index is not None: + asset_data = asset.model_dump() + asset_data["name"] = injection_names[index] if overwrite_if_present or asset.name is None else asset.name + updated_type = injection_types[index] if overwrite_if_present or asset.asset_type is None else asset.asset_type + if updated_type in get_args(AssetInjectionType): + asset_data["asset_type"] = updated_type + updated_injection_assets.append(InjectionAsset(**asset_data)) + elif isinstance(asset, BranchAsset): + updated_injection_assets.append(BranchAsset(**asset_data)) else: - new_assets.append(asset) - new_stations.append(station.model_copy(update={"assets": new_assets})) - - return asset_topology.model_copy(update={"stations": new_stations}) + updated_injection_assets.append(asset.model_copy(update=asset_data, deep=True)) + else: + updated_injection_assets.append(asset.model_copy(deep=True)) + + return copy_topology_with_updates( + reference_topology=asset_topology, + raw_stations=asset_topology.raw_stations, + asset_bays=asset_topology.asset_bays, + branch_assets=updated_branch_assets, + injection_assets=updated_injection_assets, + ) diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py index 7a9557a23..31f56de10 100644 --- a/packages/dc_solver_pkg/tests/conftest.py +++ b/packages/dc_solver_pkg/tests/conftest.py @@ -14,6 +14,9 @@ from copy import deepcopy from pathlib import Path +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation + os.environ.setdefault("RAY_ENABLE_UV_RUN_RUNTIME_ENV", "0") import chex @@ -98,14 +101,10 @@ ) from toop_engine_grid_helpers.powsybl.powsybl_helpers import save_lf_params_to_fs from toop_engine_importer.pypowsybl_import import preprocessing -from toop_engine_interfaces.asset_topology import ( - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, +from toop_engine_interfaces.asset_topology.asset_topology import ( Topology, ) -from toop_engine_interfaces.asset_topology_helpers import load_asset_topology +from toop_engine_interfaces.asset_topology.asset_topology_helpers import load_asset_topology from toop_engine_interfaces.folder_structure import ( NETWORK_MASK_NAMES, OUTPUT_FILE_NAMES, @@ -635,7 +634,7 @@ def oberrhein_outage_station_busbars_map(_oberrhein_data_folder: Path) -> dict: asset_topo = load_asset_topology(_oberrhein_data_folder / PREPROCESSING_PATHS["asset_topology_file_path"]) retval = {} - for station in asset_topo.stations: + for station in asset_topo.materialize_stations(): if station.grid_model_id in stations_desired: # Get the busbar IDs for the station busbars = [bb.grid_model_id for bb in station.busbars] @@ -845,11 +844,11 @@ def basic_node_breaker_topology() -> Topology: @pytest.fixture(scope="session") -def mock_station() -> Station: - asset1 = SwitchableAsset(grid_model_id="branch_01", in_service=True, branch_end="from", type="line") - asset2 = SwitchableAsset(grid_model_id="branch_02", in_service=True, branch_end="to", type="line") - asset3 = SwitchableAsset(grid_model_id="branch_03", in_service=True, branch_end="from", type="line") - asset4 = SwitchableAsset(grid_model_id="branch_04", in_service=True, branch_end="to", type="line") +def mock_station() -> MaterializedStation: + asset1 = BranchAsset(grid_model_id="branch_01", in_service=True, asset_type="line") + asset2 = BranchAsset(grid_model_id="branch_02", in_service=True, asset_type="line") + asset3 = BranchAsset(grid_model_id="branch_03", in_service=True, asset_type="line") + asset4 = BranchAsset(grid_model_id="branch_04", in_service=True, asset_type="line") # Create mock Busbar objects busbar_0 = Busbar(grid_model_id="busbar_0", int_id=1) @@ -863,13 +862,13 @@ def mock_station() -> Station: # 1-2-3-4-5 # Create a mock Station object - station = Station( + station = MaterializedStation( grid_model_id="station_1", busbars=[busbar_0, busbar_1, busbar_2, busbar_3, busbar_4], couplers=[ BusbarCoupler( grid_model_id="VL4_BREAKER", - type="busbar_coupler", + coupler_type="busbar_coupler", name="VL4_BREAKER", busbar_from_id=1, busbar_to_id=2, @@ -878,7 +877,7 @@ def mock_station() -> Station: ), BusbarCoupler( grid_model_id="VL5_BREAKER", - type="busbar_coupler", + coupler_type="busbar_coupler", name="VL5_BREAKER", busbar_from_id=2, busbar_to_id=3, @@ -887,7 +886,7 @@ def mock_station() -> Station: ), BusbarCoupler( grid_model_id="VL6_BREAKER", - type="busbar_coupler", + coupler_type="busbar_coupler", name="VL6_BREAKER", busbar_from_id=3, busbar_to_id=4, @@ -896,7 +895,7 @@ def mock_station() -> Station: ), BusbarCoupler( grid_model_id="VL7_BREAKER", - type="busbar_coupler", + coupler_type="busbar_coupler", name="VL7_BREAKER", busbar_from_id=4, busbar_to_id=5, @@ -905,7 +904,7 @@ def mock_station() -> Station: ), BusbarCoupler( grid_model_id="VL9_BREAKER", - type="busbar_coupler", + coupler_type="busbar_coupler", name="VL9_BREAKER", busbar_from_id=1, busbar_to_id=3, @@ -913,8 +912,14 @@ def mock_station() -> Station: in_service=True, ), ], - assets=[asset1, asset2, asset3, asset4], - asset_switching_table=np.array( + branch_connections=[ + MaterializedAssetConnection(asset=asset1), + MaterializedAssetConnection(asset=asset2), + MaterializedAssetConnection(asset=asset3), + MaterializedAssetConnection(asset=asset4), + ], + injection_connections=[], + branch_switching_table=np.array( [ [True, False, True, False], # Busbar 0 [False, True, False, False], # Busbar 1 @@ -924,6 +929,7 @@ def mock_station() -> Station: ], dtype=bool, ), + injection_switching_table=np.zeros((5, 0), dtype=bool), ) return station diff --git a/packages/dc_solver_pkg/tests/deprecated/assignment.py b/packages/dc_solver_pkg/tests/deprecated/assignment.py index cf001b154..d7efea900 100644 --- a/packages/dc_solver_pkg/tests/deprecated/assignment.py +++ b/packages/dc_solver_pkg/tests/deprecated/assignment.py @@ -19,7 +19,7 @@ from toop_engine_dc_solver.preprocess.preprocess_switching import ( make_separation_set, ) -from toop_engine_interfaces.asset_topology import Station +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation # TODO: A different rule could take precedent here. I would first check for parallel lines/trafos @@ -145,13 +145,13 @@ def handle_busbar(should_be_on: bool, is_on: bool, busbar: list[int]) -> None: def realize_single_station_assignment( - station: Station, + station: MaterializedStation, configuration_table: Bool[np.ndarray, " n_configurations 2 n_assets"], coupler_states: Bool[np.ndarray, " n_configurations n_couplers"], busbar_matchings: list[set[int]], target_configuration: Bool[np.ndarray, " n_assets"], target_ignores: Optional[Bool[np.ndarray, " n_assets"]], -) -> tuple[Optional[Station], list[int], int]: +) -> tuple[Optional[MaterializedStation], list[int], int]: """Realize a electric target to be physically implemented in the station. This fundamentally involves two steps, at first finding the closest busbar mapping and then @@ -250,7 +250,7 @@ def realize_single_station_assignment( busbar_a, busbar_b, asset_index=asset_i, - asset_connectivity=station.asset_connectivity, + asset_connectivity=np.concatenate([station.branch_connectivity, station.injection_connectivity], axis=1), ) # break when the branch action is not feasible @@ -265,7 +265,7 @@ def realize_single_station_assignment( "couplers": new_couplers, } ) - Station.model_validate(new_station) + MaterializedStation.model_validate(new_station) return new_station, busbar_a, int(reassignment_distance) @@ -275,9 +275,9 @@ def realise_bus_split_single_station( branch_topology_local: Bool[ArrayLike, " n_branches_at_node"], injection_ids_local: list[str], injection_topology_local: Bool[np.ndarray, " n_injections_at_node"], - station: Station, + station: MaterializedStation, missing_element_behavior: Literal["raise", "leave"] = "leave", -) -> tuple[Optional[Station], list[int], int]: +) -> tuple[Optional[MaterializedStation], list[int], int]: """Realizes the bus split for a single station. In this method, the switching table of the input Station is modified as per input diff --git a/packages/dc_solver_pkg/tests/export/test_asset_topology_to_dgs.py b/packages/dc_solver_pkg/tests/export/test_asset_topology_to_dgs.py index 05bd3df1c..39e0764bf 100644 --- a/packages/dc_solver_pkg/tests/export/test_asset_topology_to_dgs.py +++ b/packages/dc_solver_pkg/tests/export/test_asset_topology_to_dgs.py @@ -13,9 +13,7 @@ import numpy as np import pandas as pd import pandera -import pandera.typing as pat import pytest -from beartype.typing import cast from toop_engine_dc_solver.export.asset_topology_to_dgs import ( ForeignIdSchema, get_dgs_general_schema, @@ -29,14 +27,13 @@ DgsElmCoupSchema, ) from toop_engine_dc_solver.postprocess.apply_asset_topo_powsybl import ( - get_asset_bay_grid_model_id_list, get_asset_switch_states_from_station, get_busbar_lookup, get_changing_switches_from_topology, get_coupler_states_from_busbar_couplers, get_diff_switch_states, ) -from toop_engine_interfaces.asset_topology import BusbarCoupler +from toop_engine_interfaces.asset_topology.assets import BusbarCoupler from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema @@ -76,14 +73,13 @@ def test_dgs_list_to_xlsx(tmp_path): ] ) DgsElmCoupSchema.validate(dgs_df) - typed_dgs_df = cast(pat.DataFrame[DgsElmCoupSchema], dgs_df) file_name = os.path.join(tmp_path, "test_dgs.xlsx") assert not os.path.exists(file_name) sheet_name = "ElmCoup" general_info = DGS_GENERAL_SHEET_CONTENT_FID general_df = get_dgs_general_schema(general_info=general_info, cim=False) switch_dgs_schema_to_xlsx( - switch_dgs_schema=typed_dgs_df, + switch_dgs_schema=dgs_df, file_name=file_name, sheet_name=sheet_name, df_general=general_df, @@ -101,7 +97,7 @@ def test_dgs_list_to_xlsx(tmp_path): # default general info file_name = os.path.join(tmp_path, "test2_dgs.xlsx") switch_dgs_schema_to_xlsx( - switch_dgs_schema=typed_dgs_df, + switch_dgs_schema=dgs_df, file_name=file_name, sheet_name=sheet_name, df_general=general_df, @@ -127,7 +123,7 @@ def test_get_coupler_states_from_busbar_couplers(): mock_coupler_2.open = False mock_coupler_2.in_service = True - busbar_couplers = cast(list[BusbarCoupler], [mock_coupler_1, mock_coupler_2]) + busbar_couplers = [mock_coupler_1, mock_coupler_2] result = get_coupler_states_from_busbar_couplers(busbar_couplers) @@ -141,7 +137,7 @@ def test_get_coupler_states_from_busbar_couplers(): def test_get_asset_switch_states_from_station(basic_node_breaker_topology): - station = basic_node_breaker_topology.stations[0] + station = basic_node_breaker_topology.materialize_stations()[0] switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) expected_reassignment = [ {"grid_model_id": "L42_DISCONNECTOR_3_0", "open": True}, @@ -156,7 +152,7 @@ def test_get_asset_switch_states_from_station(basic_node_breaker_topology): # test empty disconnection station = station.model_copy( update={ - "asset_switching_table": np.array([[False, False, False], [True, True, True]]), + "branch_switching_table": np.array([[False, False, False], [True, True, True]]), } ) switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) @@ -178,7 +174,7 @@ def test_get_asset_switch_states_from_station(basic_node_breaker_topology): assert switch_reassignment_df.to_dict(orient="records") == expected_reassignment # test empty reassignment - station = station.model_copy(update={"asset_switching_table": np.array([[False, False, False], [False, False, False]])}) + station = station.model_copy(update={"branch_switching_table": np.array([[False, False, False], [False, False, False]])}) switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) expected_disconnection = [ {"grid_model_id": "L42_BREAKER", "open": True}, @@ -190,8 +186,11 @@ def test_get_asset_switch_states_from_station(basic_node_breaker_topology): def test_get_asset_bay_sr_fid_list(basic_node_breaker_topology): - station = deepcopy(basic_node_breaker_topology.stations[0]) - asset_bay_sr_fid_list = get_asset_bay_grid_model_id_list(station) + station = deepcopy(basic_node_breaker_topology.materialize_stations()[0]) + asset_bay_sr_fid_list = [ + *(asset_connection.get_sr_switch() for asset_connection in station.branch_connections), + *(asset_connection.get_sr_switch() for asset_connection in station.injection_connections), + ] expected = [ {"BBS4_1": "L42_DISCONNECTOR_3_0", "BBS4_2": "L42_DISCONNECTOR_3_1"}, {"BBS4_1": "L52_DISCONNECTOR_5_0", "BBS4_2": "L52_DISCONNECTOR_5_1"}, @@ -199,12 +198,19 @@ def test_get_asset_bay_sr_fid_list(basic_node_breaker_topology): ] assert asset_bay_sr_fid_list == expected - station.assets[1] = station.assets[1].model_copy( + station = station.model_copy( update={ - "asset_bay": None, + "branch_connections": [ + station.branch_connections[0], + station.branch_connections[1].model_copy(update={"asset_bay": None}), + station.branch_connections[2], + ] } ) - asset_bay_sr_fid_list = get_asset_bay_grid_model_id_list(station) + asset_bay_sr_fid_list = [ + *(asset_connection.get_sr_switch() for asset_connection in station.branch_connections), + *(asset_connection.get_sr_switch() for asset_connection in station.injection_connections), + ] expected = [ {"BBS4_1": "L42_DISCONNECTOR_3_0", "BBS4_2": "L42_DISCONNECTOR_3_1"}, None, @@ -214,20 +220,17 @@ def test_get_asset_bay_sr_fid_list(basic_node_breaker_topology): def test_get_busbar_lookup(basic_node_breaker_topology): - station = basic_node_breaker_topology.stations[0] + station = basic_node_breaker_topology.materialize_stations()[0] busbar_lookup = get_busbar_lookup(station) expected = {0: "BBS4_1", 1: "BBS4_2"} assert busbar_lookup == expected def test_station_helpers_build_switch_update_schema(basic_node_breaker_topology): - station = basic_node_breaker_topology.stations[0] + station = basic_node_breaker_topology.materialize_stations()[0] coupler_df = get_coupler_states_from_busbar_couplers(station.couplers) switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) - switch_update_schema = cast( - pat.DataFrame[SwitchUpdateSchema], - pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True), - ) + switch_update_schema = pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True) expected = pd.DataFrame( [ {"grid_model_id": "VL4_BREAKER", "open": True}, @@ -243,13 +246,10 @@ def test_station_helpers_build_switch_update_schema(basic_node_breaker_topology) def test_get_diff_switch_states(basic_node_breaker_grid_v1, basic_node_breaker_topology): net = basic_node_breaker_grid_v1 - station = basic_node_breaker_topology.stations[0] + station = basic_node_breaker_topology.materialize_stations()[0] coupler_df = get_coupler_states_from_busbar_couplers(station.couplers) switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) - switch_update_schema = cast( - pat.DataFrame[SwitchUpdateSchema], - pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True), - ) + switch_update_schema = pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True) diff_switch_states = get_diff_switch_states(network=net, switch_df=switch_update_schema) SwitchUpdateSchema.validate(diff_switch_states) expected = [ @@ -277,18 +277,15 @@ def test_get_changing_switches_from_topology(basic_node_breaker_grid_v1, basic_n def test_switch_update_schema_to_dgs(basic_node_breaker_grid_v1, basic_node_breaker_topology): _net = basic_node_breaker_grid_v1 - station = basic_node_breaker_topology.stations[0] + station = basic_node_breaker_topology.materialize_stations()[0] coupler_df = get_coupler_states_from_busbar_couplers(station.couplers) switch_reassignment_df, switch_disconnection_df = get_asset_switch_states_from_station(station) - switch_update_schema = cast( - pat.DataFrame[SwitchUpdateSchema], - pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True), - ) + switch_update_schema = pd.concat([coupler_df, switch_reassignment_df, switch_disconnection_df], ignore_index=True) foreign_ids = deepcopy(switch_update_schema) foreign_ids["foreign_id"] = foreign_ids["grid_model_id"] + "_foreign_id" foreign_ids.drop(columns=["open"], inplace=True) ForeignIdSchema.validate(foreign_ids) - typed_foreign_ids = cast(pat.DataFrame[ForeignIdSchema], foreign_ids) + typed_foreign_ids = foreign_ids dgs_df = switch_update_schema_to_dgs(switch_update_schema, typed_foreign_ids, cim=False) # check if dgs switch states are correct @@ -323,11 +320,10 @@ def test_switch_dgs_schema_to_bytes_io(tmp_path): ] ) DgsElmCoupSchema.validate(dgs_df) - typed_dgs_df = cast(pat.DataFrame[DgsElmCoupSchema], dgs_df) general_df = get_dgs_general_schema(cim=False) # Call the function - output = switch_dgs_schema_to_bytes_io(typed_dgs_df, general_df, sheet_name="ElmCoup") + output = switch_dgs_schema_to_bytes_io(dgs_df, general_df, sheet_name="ElmCoup") # Check that output is a BytesIO and not empty assert isinstance(output, io.BytesIO) diff --git a/packages/dc_solver_pkg/tests/export/test_disconnection_switch_updates.py b/packages/dc_solver_pkg/tests/export/test_disconnection_switch_updates.py index 4bcd1dfbe..bab73c10b 100644 --- a/packages/dc_solver_pkg/tests/export/test_disconnection_switch_updates.py +++ b/packages/dc_solver_pkg/tests/export/test_disconnection_switch_updates.py @@ -14,10 +14,35 @@ ) from toop_engine_dc_solver.export.export import get_changing_switches_from_actions from toop_engine_dc_solver.postprocess.apply_asset_topo_powsybl import get_changing_switches_from_topology +from toop_engine_interfaces.asset_topology.asset_topology import RawStation, Topology, copy_topology_with_updates from toop_engine_interfaces.nminus1_definition import GridElement from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema +def build_test_topology(reference_topology: Topology, raw_stations: list[RawStation]) -> Topology: + """Build a topology copy from raw stations for disconnection tests. + + Parameters + ---------- + reference_topology : Topology + Source topology providing shared metadata and topology-owned payloads. + raw_stations : list[RawStation] + Raw stations to place into the copied topology. + + Returns + ------- + Topology + Topology copy with updated raw stations. + """ + return copy_topology_with_updates( + reference_topology=reference_topology, + raw_stations=raw_stations, + asset_bays=reference_topology.asset_bays, + branch_assets=reference_topology.branch_assets, + injection_assets=reference_topology.injection_assets, + ) + + def test_get_disconnected_asset_ids_returns_asset_bays_by_disconnection_id(basic_node_breaker_topology) -> None: disconnections = [ GridElement(id="L8", name="", type="LINE", kind="branch"), @@ -25,7 +50,7 @@ def test_get_disconnected_asset_ids_returns_asset_bays_by_disconnection_id(basic ] result = get_disconnected_asset_ids( - stations=basic_node_breaker_topology.stations, + stations=basic_node_breaker_topology.materialize_stations(), disconnections=disconnections, ) @@ -37,7 +62,7 @@ def test_get_disconnected_asset_ids_returns_asset_bays_by_disconnection_id(basic def test_get_changing_switches_from_actions_warns_for_unrepresentable_disconnection( basic_node_breaker_topology, ) -> None: - starting_topology = basic_node_breaker_topology.model_copy(update={"stations": []}) + starting_topology = build_test_topology(basic_node_breaker_topology, []) disconnections = [GridElement(id="L8", name="Line 8", type="LINE", kind="branch")] with patch("toop_engine_dc_solver.export.disconnection_switch_updates.logger.warning") as warning_mock: @@ -63,15 +88,22 @@ def test_get_changing_switches_from_disconnections_matches_network_diff( basic_node_breaker_topology, ) -> None: net = basic_node_breaker_grid_v1 - target_station = basic_node_breaker_topology.stations[0] + target_station = basic_node_breaker_topology.materialize_stations()[0] starting_station = target_station.model_copy( update={ "couplers": [coupler.model_copy(update={"open": False}) for coupler in target_station.couplers], - "asset_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) - target_topology = basic_node_breaker_topology.model_copy(update={"stations": [target_station]}) - starting_topology = target_topology.model_copy(update={"stations": [starting_station]}) + target_raw_station = basic_node_breaker_topology.raw_stations[0] + starting_raw_station = target_raw_station.model_copy( + update={ + "couplers": [coupler.model_copy(update={"open": False}) for coupler in target_raw_station.couplers], + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + } + ) + target_topology = build_test_topology(basic_node_breaker_topology, [target_raw_station]) + starting_topology = build_test_topology(target_topology, [starting_raw_station]) disconnections = [GridElement(id="L8", name="", type="LINE", kind="branch")] expected = get_changing_switches_from_topology(network=net, target_topology=target_topology) @@ -88,19 +120,28 @@ def test_get_changing_switches_from_disconnections_matches_network_diff( def test_get_changing_switches_from_actions_warns_on_overlapping_switch_updates( basic_node_breaker_topology, ) -> None: - target_station = basic_node_breaker_topology.stations[0] + target_station = basic_node_breaker_topology.materialize_stations()[0] changed_station = target_station.model_copy( update={ - "asset_switching_table": np.array([[False, False, False], [True, True, False]], dtype=bool), + "branch_switching_table": np.array([[False, False, False], [True, True, False]], dtype=bool), } ) starting_station = target_station.model_copy( update={ "couplers": [coupler.model_copy(update={"open": False}) for coupler in target_station.couplers], - "asset_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + } + ) + starting_raw_station = basic_node_breaker_topology.raw_stations[0].model_copy( + update={ + "couplers": [ + coupler.model_copy(update={"open": False}) + for coupler in basic_node_breaker_topology.raw_stations[0].couplers + ], + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) - starting_topology = basic_node_breaker_topology.model_copy(update={"stations": [starting_station]}) + starting_topology = build_test_topology(basic_node_breaker_topology, [starting_raw_station]) disconnections = [GridElement(id="L8", name="", type="LINE", kind="branch")] with patch("toop_engine_dc_solver.export.export.logger.warning") as warning_mock: diff --git a/packages/dc_solver_pkg/tests/export/test_export.py b/packages/dc_solver_pkg/tests/export/test_export.py index a76140ed8..fe3627070 100644 --- a/packages/dc_solver_pkg/tests/export/test_export.py +++ b/packages/dc_solver_pkg/tests/export/test_export.py @@ -21,31 +21,63 @@ from toop_engine_dc_solver.postprocess.apply_asset_topo_powsybl import get_changing_switches_from_topology from toop_engine_dc_solver.postprocess.postprocess_powsybl import PowsyblRunner from toop_engine_dc_solver.preprocess.network_data import extract_action_set, extract_nminus1_definition, load_lf_params +from toop_engine_interfaces.asset_topology.asset_topology import RawStation, Topology, copy_topology_with_updates from toop_engine_interfaces.folder_structure import OUTPUT_FILE_NAMES, POSTPROCESSING_PATHS, PREPROCESSING_PATHS from toop_engine_interfaces.nminus1_definition import GridElement from toop_engine_interfaces.stored_action_set import ActionSet from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema +def build_test_topology(reference_topology: Topology, raw_stations: list[RawStation]) -> Topology: + """Build a topology copy from raw stations for export tests. + + Parameters + ---------- + reference_topology : Topology + Source topology providing shared metadata and topology-owned payloads. + raw_stations : list[RawStation] + Raw stations to place into the copied topology. + + Returns + ------- + Topology + Topology copy with updated raw stations. + """ + return copy_topology_with_updates( + reference_topology=reference_topology, + raw_stations=raw_stations, + asset_bays=reference_topology.asset_bays, + branch_assets=reference_topology.branch_assets, + injection_assets=reference_topology.injection_assets, + ) + + def test_get_changing_switches_from_actions_matches_network_diff( basic_node_breaker_grid_v1, basic_node_breaker_topology, ): net = basic_node_breaker_grid_v1 - target_station = basic_node_breaker_topology.stations[0] + target_station = basic_node_breaker_topology.materialize_stations()[0] changed_station = target_station.model_copy( update={ - "asset_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), + "branch_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), } ) + target_raw_station = basic_node_breaker_topology.raw_stations[0] starting_station = target_station.model_copy( update={ "couplers": [coupler.model_copy(update={"open": False}) for coupler in target_station.couplers], - "asset_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) - target_topology = basic_node_breaker_topology.model_copy(update={"stations": [target_station]}) - starting_topology = target_topology.model_copy(update={"stations": [starting_station]}) + starting_raw_station = target_raw_station.model_copy( + update={ + "couplers": [coupler.model_copy(update={"open": False}) for coupler in target_raw_station.couplers], + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + } + ) + target_topology = build_test_topology(basic_node_breaker_topology, [target_raw_station]) + starting_topology = build_test_topology(target_topology, [starting_raw_station]) disconnections = [GridElement(id="L8", name="", type="LINE", kind="branch")] expected = get_changing_switches_from_topology(network=net, target_topology=target_topology) @@ -63,19 +95,23 @@ def test_get_changing_switches_from_actions_matches_network_diff( def test_get_changing_switches_from_action_set_matches_expanded_inputs( basic_node_breaker_topology, ) -> None: - target_station = basic_node_breaker_topology.stations[0] + target_station = basic_node_breaker_topology.materialize_stations()[0] changed_station = target_station.model_copy( update={ - "asset_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), + "branch_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), } ) - starting_station = target_station.model_copy( + + starting_raw_station = basic_node_breaker_topology.raw_stations[0].model_copy( update={ - "couplers": [coupler.model_copy(update={"open": False}) for coupler in target_station.couplers], - "asset_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + "couplers": [ + coupler.model_copy(update={"open": False}) + for coupler in basic_node_breaker_topology.raw_stations[0].couplers + ], + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) - starting_topology = basic_node_breaker_topology.model_copy(update={"stations": [starting_station]}) + starting_topology = build_test_topology(basic_node_breaker_topology, [starting_raw_station]) disconnection_elements = [GridElement(id="L8", name="", type="LINE", kind="branch")] action_set = ActionSet.model_construct( starting_topology=starting_topology, @@ -125,7 +161,7 @@ def test_get_changing_switches_from_action_set_validates_indices( disconnectable_branches=[GridElement(id="L8", name="", type="LINE", kind="branch")], pst_ranges=[], hvdc_ranges=[], - local_actions=[basic_node_breaker_topology.stations[0]], + local_actions=[basic_node_breaker_topology.materialize_stations()[0]], ) with pytest.raises(ValueError, match=expected_message): diff --git a/packages/dc_solver_pkg/tests/export/test_station_switch_updates.py b/packages/dc_solver_pkg/tests/export/test_station_switch_updates.py index 816152fbe..9e544631a 100644 --- a/packages/dc_solver_pkg/tests/export/test_station_switch_updates.py +++ b/packages/dc_solver_pkg/tests/export/test_station_switch_updates.py @@ -7,6 +7,7 @@ import numpy as np import pytest +from pydantic import ValidationError from toop_engine_dc_solver.export.station_switch_updates import ( _get_asset_switch_diffs, _get_coupler_switch_diffs, @@ -14,11 +15,36 @@ get_changing_switches_from_changed_stations, ) from toop_engine_dc_solver.postprocess.apply_asset_topo_powsybl import get_changing_switches_from_topology +from toop_engine_interfaces.asset_topology.asset_topology import RawStation, Topology, copy_topology_with_updates from toop_engine_interfaces.switch_update_schema import SwitchUpdateSchema +def build_test_topology(reference_topology: Topology, raw_stations: list[RawStation]) -> Topology: + """Build a topology copy from raw stations for station-switch tests. + + Parameters + ---------- + reference_topology : Topology + Source topology providing shared metadata and topology-owned payloads. + raw_stations : list[RawStation] + Raw stations to place into the copied topology. + + Returns + ------- + Topology + Topology copy with updated raw stations. + """ + return copy_topology_with_updates( + reference_topology=reference_topology, + raw_stations=raw_stations, + asset_bays=reference_topology.asset_bays, + branch_assets=reference_topology.branch_assets, + injection_assets=reference_topology.injection_assets, + ) + + def test_resolve_changed_stations_preserves_topology_order(basic_node_breaker_topology): - changed_station = basic_node_breaker_topology.stations[0] + changed_station = basic_node_breaker_topology.materialize_stations()[0] starting_lookup, changed_lookup, ordered_station_ids = _resolve_changed_stations( changed_stations=[changed_station], @@ -31,14 +57,11 @@ def test_resolve_changed_stations_preserves_topology_order(basic_node_breaker_to def test_get_coupler_switch_diffs(basic_node_breaker_topology): - starting_station = basic_node_breaker_topology.stations[0].model_copy( - update={ - "couplers": [ - coupler.model_copy(update={"open": False}) for coupler in basic_node_breaker_topology.stations[0].couplers - ] - } + station = basic_node_breaker_topology.materialize_stations()[0] + starting_station = station.model_copy( + update={"couplers": [coupler.model_copy(update={"open": False}) for coupler in station.couplers]} ) - changed_station = basic_node_breaker_topology.stations[0] + changed_station = station result = _get_coupler_switch_diffs( changed_station=changed_station, @@ -49,15 +72,15 @@ def test_get_coupler_switch_diffs(basic_node_breaker_topology): def test_get_asset_switch_diffs(basic_node_breaker_topology): - target_station = basic_node_breaker_topology.stations[0] + target_station = basic_node_breaker_topology.materialize_stations()[0] starting_station = target_station.model_copy( update={ - "asset_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) changed_station = target_station.model_copy( update={ - "asset_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), + "branch_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), } ) @@ -73,24 +96,23 @@ def test_get_asset_switch_diffs(basic_node_breaker_topology): def test_get_asset_switch_diffs_requires_matching_switching_table_shape(basic_node_breaker_topology): - station = basic_node_breaker_topology.stations[0] - changed_station = station.model_copy( - update={ - "asset_switching_table": np.array([[False, False, True]], dtype=bool), - } - ) - - with pytest.raises(ValueError, match="asset switching table shape does not match"): - _get_asset_switch_diffs( - changed_station=changed_station, - starting_station=station, + station = basic_node_breaker_topology.materialize_stations()[0] + with pytest.raises(ValidationError, match="branch_switching_table shape"): + station.model_copy( + update={ + "branch_switching_table": np.array([[False, False, True]], dtype=bool), + } ) def test_get_asset_switch_diffs_requires_matching_asset_order(basic_node_breaker_topology): - station = basic_node_breaker_topology.stations[0] - reordered_assets = [station.assets[1], station.assets[0], station.assets[2]] - changed_station = station.model_copy(update={"assets": reordered_assets}) + station = basic_node_breaker_topology.materialize_stations()[0] + reordered_asset_connections = [ + station.branch_connections[1], + station.branch_connections[0], + station.branch_connections[2], + ] + changed_station = station.model_copy(update={"branch_connections": reordered_asset_connections}) with pytest.raises(ValueError, match="Use ActionSet.simplified_starting_topology as input"): _get_asset_switch_diffs( @@ -100,15 +122,15 @@ def test_get_asset_switch_diffs_requires_matching_asset_order(basic_node_breaker def test_get_asset_switch_diffs_allows_multiple_active_busbars(basic_node_breaker_topology): - station = basic_node_breaker_topology.stations[0] + station = basic_node_breaker_topology.materialize_stations()[0] starting_station = station.model_copy( update={ - "asset_switching_table": np.array([[True, False, False], [True, True, False]], dtype=bool), + "branch_switching_table": np.array([[True, False, False], [True, True, False]], dtype=bool), } ) changed_station = station.model_copy( update={ - "asset_switching_table": np.array([[True, False, False], [False, True, False]], dtype=bool), + "branch_switching_table": np.array([[True, False, False], [False, True, False]], dtype=bool), } ) @@ -125,22 +147,38 @@ def test_get_changing_switches_from_changed_stations_matches_network_diff( basic_node_breaker_topology, ): net = basic_node_breaker_grid_v1 - starting_station = basic_node_breaker_topology.stations[0].model_copy( + station = basic_node_breaker_topology.materialize_stations()[0] + starting_station = station.model_copy( update={ - "couplers": [ - coupler.model_copy(update={"open": False}) for coupler in basic_node_breaker_topology.stations[0].couplers - ], - "asset_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), + "couplers": [coupler.model_copy(update={"open": False}) for coupler in station.couplers], + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) changed_station = starting_station.model_copy( update={ "couplers": [coupler.model_copy(update={"open": True}) for coupler in starting_station.couplers], - "asset_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), + "branch_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), + } + ) + target_raw_station = basic_node_breaker_topology.raw_stations[0].model_copy( + update={ + "couplers": [ + coupler.model_copy(update={"open": True}) for coupler in basic_node_breaker_topology.raw_stations[0].couplers + ], + "branch_switching_table": np.array([[False, False, True], [True, True, False]], dtype=bool), + } + ) + starting_raw_station = basic_node_breaker_topology.raw_stations[0].model_copy( + update={ + "couplers": [ + coupler.model_copy(update={"open": False}) + for coupler in basic_node_breaker_topology.raw_stations[0].couplers + ], + "branch_switching_table": np.array([[True, False, True], [False, True, False]], dtype=bool), } ) - target_topology = basic_node_breaker_topology.model_copy(update={"stations": [changed_station]}) - starting_topology = target_topology.model_copy(update={"stations": [starting_station]}) + target_topology = build_test_topology(basic_node_breaker_topology, [target_raw_station]) + starting_topology = build_test_topology(target_topology, [starting_raw_station]) expected = get_changing_switches_from_topology(network=net, target_topology=target_topology) result = get_changing_switches_from_changed_stations( diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set.json index b9586fa08..b2da454df 100644 --- a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set.json +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set.json @@ -1 +1,842 @@ -{"starting_topology":{"topology_id":"test_ucte_powsybl_example.xiidm","grid_model_file":"/workspaces/AICoE_HPC_RL_Grid_Model_Importing/data/Test_Grid_asset_topo/test_ucte_powsybl_example.xiidm","name":null,"stations":[{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":false,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[true,false,true,false,true,true],[false,true,false,true,false,false],[false,false,false,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL3_0","name":"VLevel3","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS3_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS3_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS3_3","type":"busbar","name":"bus3","int_id":2,"in_service":true},{"grid_model_id":"BBS3_4","type":"busbar","name":"bus4","int_id":3,"in_service":true}],"couplers":[{"grid_model_id":"VL3_BREAKER","type":"BREAKER","name":"VL3_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":false,"in_service":true,"asset_bay":null},{"grid_model_id":"VL3_BREAKER#0","type":"BREAKER","name":"VL3_BREAKER#0","busbar_from_id":0,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null},{"grid_model_id":"VL3_BREAKER#1","type":"BREAKER","name":"VL3_BREAKER#1","busbar_from_id":1,"busbar_to_id":3,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L3","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L32_BREAKER","sr_switch_foreign_id":{"BBS3_1":"L32_DISCONNECTOR_5_0","BBS3_2":"L32_DISCONNECTOR_5_1"}}},{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L62_BREAKER","sr_switch_foreign_id":{"BBS3_1":"L62_DISCONNECTOR_7_0","BBS3_2":"L62_DISCONNECTOR_7_1"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L72_BREAKER","sr_switch_foreign_id":{"BBS3_1":"L72_DISCONNECTOR_9_0","BBS3_2":"L72_DISCONNECTOR_9_1"}}},{"grid_model_id":"L9","type":"LINE","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L91_BREAKER","sr_switch_foreign_id":{"BBS3_1":"L91_DISCONNECTOR_11_0","BBS3_2":"L91_DISCONNECTOR_11_1"}}},{"grid_model_id":"load2","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load2_BREAKER","sr_switch_foreign_id":{"BBS3_1":"load2_DISCONNECTOR_19_0","BBS3_2":"load2_DISCONNECTOR_19_1"}}}],"asset_switching_table":[[true,true,false,true,false],[false,false,true,false,true],[false,false,false,false,false],[false,false,false,false,false]],"asset_connectivity":[[true,true,true,true,true],[true,true,true,true,true],[false,false,false,false,false],[false,false,false,false,false]],"model_log":[]}],"asset_setpoints":null,"timestamp":"2025-05-16T15:25:10.814573","metrics":null},"connectable_branches":[],"disconnectable_branches":[{"id":"L1","type":"LINE","kind":"branch"},{"id":"L2","type":"LINE","kind":"branch"},{"id":"L3","type":"LINE","kind":"branch"},{"id":"L4","type":"LINE","kind":"branch"},{"id":"L5","type":"LINE","kind":"branch"},{"id":"L6","type":"LINE","kind":"branch"},{"id":"L7","type":"LINE","kind":"branch"},{"id":"L8","type":"LINE","kind":"branch"}],"pst_ranges":[],"hvdc_ranges":[],"local_actions":[{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":false,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":true,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,false,false,false,false,true],[false,false,false,false,false,false],[true,true,true,true,true,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,true,false,true,true,true],[false,false,true,false,false,false],[true,false,false,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,true,true,false,false,true],[false,false,false,true,true,false],[true,false,false,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,false,false,true,true,true],[false,true,false,false,false,false],[true,false,true,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,true,true,false,true,true],[false,false,false,true,false,false],[true,false,false,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,true,false,true,false,true],[false,false,true,false,true,false],[true,false,false,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,false,true,false,true,true],[false,true,false,false,false,false],[true,false,false,true,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[true,true,false,false,true,true],[false,false,false,true,false,false],[false,false,true,false,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[false,true,false,false,true,true],[false,false,true,false,false,false],[true,false,false,true,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]},{"grid_model_id":"VL2_0","name":"VLevel2","type":null,"region":"BE","voltage_level":225.0,"busbars":[{"grid_model_id":"BBS2_1","type":"busbar","name":"bus1","int_id":0,"in_service":true},{"grid_model_id":"BBS2_2","type":"busbar","name":"bus2","int_id":1,"in_service":true},{"grid_model_id":"BBS2_3","type":"busbar","name":"bus3","int_id":2,"in_service":true}],"couplers":[{"grid_model_id":"VL2_BREAKER","type":"BREAKER","name":"VL2_BREAKER","busbar_from_id":0,"busbar_to_id":1,"open":true,"in_service":true,"asset_bay":null},{"grid_model_id":"VL2_BREAKER#0","type":"BREAKER","name":"VL2_BREAKER#0","busbar_from_id":1,"busbar_to_id":2,"open":false,"in_service":true,"asset_bay":null}],"assets":[{"grid_model_id":"L6","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L61_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L61_DISCONNECTOR_8_0","BBS2_2":"L61_DISCONNECTOR_8_1","BBS2_3":"L61_DISCONNECTOR_8_2"}}},{"grid_model_id":"L7","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L71_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L71_DISCONNECTOR_10_0","BBS2_2":"L71_DISCONNECTOR_10_1","BBS2_3":"L71_DISCONNECTOR_10_2"}}},{"grid_model_id":"L8","type":"LINE","name":"","in_service":true,"branch_end":"from","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L81_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L81_DISCONNECTOR_12_0","BBS2_2":"L81_DISCONNECTOR_12_1","BBS2_3":"L81_DISCONNECTOR_12_2"}}},{"grid_model_id":"L1","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L12_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L12_DISCONNECTOR_4_0","BBS2_2":"L12_DISCONNECTOR_4_1","BBS2_3":"L12_DISCONNECTOR_4_2"}}},{"grid_model_id":"L2","type":"LINE","name":"","in_service":true,"branch_end":"to","asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"L22_BREAKER","sr_switch_foreign_id":{"BBS2_1":"L22_DISCONNECTOR_6_0","BBS2_2":"L22_DISCONNECTOR_6_1","BBS2_3":"L22_DISCONNECTOR_6_2"}}},{"grid_model_id":"load1","type":"LOAD","name":"","in_service":true,"branch_end":null,"asset_bay":{"sl_switch_foreign_id":null,"dv_switch_foreign_id":"load1_BREAKER","sr_switch_foreign_id":{"BBS2_1":"load1_DISCONNECTOR_18_0","BBS2_2":"load1_DISCONNECTOR_18_1","BBS2_3":"load1_DISCONNECTOR_18_2"}}}],"asset_switching_table":[[true,false,false,false,true,true],[false,true,false,false,false,false],[false,false,true,true,false,false]],"asset_connectivity":[[true,true,true,true,true,true],[true,true,true,true,true,true],[true,true,true,true,true,true]],"model_log":[]}],"global_actions":[]} +{ + "starting_topology": { + "topology_id": "grid.xiidm", + "grid_model_file": "grid.xiidm", + "name": null, + "raw_stations": [ + { + "grid_model_id": "VL2_0", + "name": "VLevel2", + "station_type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS2_1", + "busbar_type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + }, + { + "grid_model_id": "BBS2_2", + "busbar_type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + }, + { + "grid_model_id": "BBS2_3", + "busbar_type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + } + ], + "couplers": [ + { + "grid_model_id": "VL2_BREAKER", + "coupler_type": "BREAKER", + "name": "VL2_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL2_BREAKER#0", + "coupler_type": "BREAKER", + "name": "VL2_BREAKER#0", + "busbar_from_id": 1, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "branch_switching_table": [ + [ + true, + false, + true, + false, + true + ], + [ + false, + true, + false, + true, + false + ], + [ + false, + false, + false, + false, + false + ] + ], + "injection_switching_table": [ + [ + true + ], + [ + false + ], + [ + false + ] + ], + "branch_connectivity": [ + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ] + ], + "injection_connectivity": [ + [ + true + ], + [ + true + ], + [ + true + ] + ], + "model_log": [], + "branch_connections": [ + { + "asset_id": "L1", + "terminal": "to", + "asset_bay_id": "VLevel2::L1::bay" + }, + { + "asset_id": "L2", + "terminal": "to", + "asset_bay_id": "VLevel2::L2::bay" + }, + { + "asset_id": "L6", + "terminal": "from", + "asset_bay_id": "VLevel2::L6::bay" + }, + { + "asset_id": "L7", + "terminal": "from", + "asset_bay_id": "VLevel2::L7::bay" + }, + { + "asset_id": "L8", + "terminal": "from", + "asset_bay_id": "VLevel2::L8::bay" + } + ], + "injection_connections": [ + { + "asset_id": "load1", + "terminal": null, + "asset_bay_id": "VLevel2::load1::bay" + } + ] + }, + { + "grid_model_id": "VL3_0", + "name": "VLevel3", + "station_type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS3_1", + "busbar_type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + }, + { + "grid_model_id": "BBS3_2", + "busbar_type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + }, + { + "grid_model_id": "BBS3_3", + "busbar_type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + }, + { + "grid_model_id": "BBS3_4", + "busbar_type": "busbar", + "name": "bus4", + "int_id": 3, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + } + ], + "couplers": [ + { + "grid_model_id": "VL3_BREAKER", + "coupler_type": "BREAKER", + "name": "VL3_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL3_BREAKER#0", + "coupler_type": "BREAKER", + "name": "VL3_BREAKER#0", + "busbar_from_id": 0, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL3_BREAKER#1", + "coupler_type": "BREAKER", + "name": "VL3_BREAKER#1", + "busbar_from_id": 1, + "busbar_to_id": 3, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "branch_switching_table": [ + [ + true, + true, + false, + true + ], + [ + false, + false, + true, + false + ], + [ + false, + false, + false, + false + ], + [ + false, + false, + false, + false + ] + ], + "injection_switching_table": [ + [ + false + ], + [ + true + ], + [ + false + ], + [ + false + ] + ], + "branch_connectivity": [ + [ + true, + true, + true, + true + ], + [ + true, + true, + true, + true + ], + [ + false, + false, + false, + false + ], + [ + false, + false, + false, + false + ] + ], + "injection_connectivity": [ + [ + true + ], + [ + true + ], + [ + false + ], + [ + false + ] + ], + "model_log": [], + "branch_connections": [ + { + "asset_id": "L3", + "terminal": "to", + "asset_bay_id": "VLevel3::L3::bay" + }, + { + "asset_id": "L6", + "terminal": "to", + "asset_bay_id": "VLevel3::L6::bay" + }, + { + "asset_id": "L7", + "terminal": "to", + "asset_bay_id": "VLevel3::L7::bay" + }, + { + "asset_id": "L9", + "terminal": null, + "asset_bay_id": "VLevel3::L9::bay" + } + ], + "injection_connections": [ + { + "asset_id": "load2", + "terminal": null, + "asset_bay_id": "VLevel3::load2::bay" + } + ] + } + ], + "branch_assets": [ + { + "grid_model_id": "L1", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L2", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L6", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L7", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L8", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L3", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L9", + "asset_type": "LINE", + "name": "", + "in_service": true + } + ], + "injection_assets": [ + { + "grid_model_id": "load1", + "asset_type": "LOAD", + "name": "", + "in_service": true + }, + { + "grid_model_id": "load2", + "asset_type": "LOAD", + "name": "", + "in_service": true + } + ], + "asset_bays": [ + { + "asset_bay_id": "VLevel2::L1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L12_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L12_DISCONNECTOR_4_0", + "BBS2_2": "L12_DISCONNECTOR_4_1", + "BBS2_3": "L12_DISCONNECTOR_4_2" + } + }, + { + "asset_bay_id": "VLevel2::L2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L22_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L22_DISCONNECTOR_6_0", + "BBS2_2": "L22_DISCONNECTOR_6_1", + "BBS2_3": "L22_DISCONNECTOR_6_2" + } + }, + { + "asset_bay_id": "VLevel2::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L61_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L61_DISCONNECTOR_8_0", + "BBS2_2": "L61_DISCONNECTOR_8_1", + "BBS2_3": "L61_DISCONNECTOR_8_2" + } + }, + { + "asset_bay_id": "VLevel2::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L71_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L71_DISCONNECTOR_10_0", + "BBS2_2": "L71_DISCONNECTOR_10_1", + "BBS2_3": "L71_DISCONNECTOR_10_2" + } + }, + { + "asset_bay_id": "VLevel2::L8::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L81_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L81_DISCONNECTOR_12_0", + "BBS2_2": "L81_DISCONNECTOR_12_1", + "BBS2_3": "L81_DISCONNECTOR_12_2" + } + }, + { + "asset_bay_id": "VLevel2::load1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load1_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "load1_DISCONNECTOR_18_0", + "BBS2_2": "load1_DISCONNECTOR_18_1", + "BBS2_3": "load1_DISCONNECTOR_18_2" + } + }, + { + "asset_bay_id": "VLevel3::L3::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L32_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L32_DISCONNECTOR_5_0", + "BBS3_2": "L32_DISCONNECTOR_5_1" + } + }, + { + "asset_bay_id": "VLevel3::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L62_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L62_DISCONNECTOR_7_0", + "BBS3_2": "L62_DISCONNECTOR_7_1" + } + }, + { + "asset_bay_id": "VLevel3::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L72_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L72_DISCONNECTOR_9_0", + "BBS3_2": "L72_DISCONNECTOR_9_1" + } + }, + { + "asset_bay_id": "VLevel3::L9::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L91_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L91_DISCONNECTOR_11_0", + "BBS3_2": "L91_DISCONNECTOR_11_1" + } + }, + { + "asset_bay_id": "VLevel3::load2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load2_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "load2_DISCONNECTOR_19_0", + "BBS3_2": "load2_DISCONNECTOR_19_1" + } + } + ], + "asset_setpoints": null, + "timestamp": "2026-06-18T21:43:40.631530", + "metrics": null + }, + "simplified_starting_topology": { + "topology_id": "grid.xiidm", + "grid_model_file": "grid.xiidm", + "name": null, + "raw_stations": [ + { + "grid_model_id": "VL2_0", + "name": "VLevel2", + "station_type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS2_1", + "busbar_type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + }, + { + "grid_model_id": "BBS2_2", + "busbar_type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + }, + { + "grid_model_id": "BBS2_3", + "busbar_type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + } + ], + "couplers": [ + { + "grid_model_id": "VL2_BREAKER", + "coupler_type": "BREAKER", + "name": "VL2_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL2_BREAKER#0", + "coupler_type": "BREAKER", + "name": "VL2_BREAKER#0", + "busbar_from_id": 1, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "branch_switching_table": [ + [ + true, + false, + true, + true, + false + ], + [ + false, + true, + false, + false, + true + ], + [ + false, + false, + false, + false, + false + ] + ], + "injection_switching_table": [ + [ + true + ], + [ + false + ], + [ + false + ] + ], + "branch_connectivity": [ + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ] + ], + "injection_connectivity": [ + [ + true + ], + [ + true + ], + [ + true + ] + ], + "model_log": [], + "branch_connections": [ + { + "asset_id": "L6", + "terminal": "from", + "asset_bay_id": "VLevel2::L6::bay" + }, + { + "asset_id": "L7", + "terminal": "from", + "asset_bay_id": "VLevel2::L7::bay" + }, + { + "asset_id": "L8", + "terminal": "from", + "asset_bay_id": "VLevel2::L8::bay" + }, + { + "asset_id": "L1", + "terminal": "to", + "asset_bay_id": "VLevel2::L1::bay" + }, + { + "asset_id": "L2", + "terminal": "to", + "asset_bay_id": "VLevel2::L2::bay" + } + ], + "injection_connections": [ + { + "asset_id": "load1", + "terminal": null, + "asset_bay_id": "VLevel2::load1::bay" + } + ] + } + ], + "branch_assets": [ + { + "grid_model_id": "L6", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L7", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L8", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L1", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L2", + "asset_type": "LINE", + "name": "", + "in_service": true + } + ], + "injection_assets": [ + { + "grid_model_id": "load1", + "asset_type": "LOAD", + "name": "", + "in_service": true + } + ], + "asset_bays": [ + { + "asset_bay_id": "VLevel2::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L61_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L61_DISCONNECTOR_8_0", + "BBS2_2": "L61_DISCONNECTOR_8_1", + "BBS2_3": "L61_DISCONNECTOR_8_2" + } + }, + { + "asset_bay_id": "VLevel2::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L71_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L71_DISCONNECTOR_10_0", + "BBS2_2": "L71_DISCONNECTOR_10_1", + "BBS2_3": "L71_DISCONNECTOR_10_2" + } + }, + { + "asset_bay_id": "VLevel2::L8::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L81_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L81_DISCONNECTOR_12_0", + "BBS2_2": "L81_DISCONNECTOR_12_1", + "BBS2_3": "L81_DISCONNECTOR_12_2" + } + }, + { + "asset_bay_id": "VLevel2::L1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L12_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L12_DISCONNECTOR_4_0", + "BBS2_2": "L12_DISCONNECTOR_4_1", + "BBS2_3": "L12_DISCONNECTOR_4_2" + } + }, + { + "asset_bay_id": "VLevel2::L2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L22_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L22_DISCONNECTOR_6_0", + "BBS2_2": "L22_DISCONNECTOR_6_1", + "BBS2_3": "L22_DISCONNECTOR_6_2" + } + }, + { + "asset_bay_id": "VLevel2::load1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load1_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "load1_DISCONNECTOR_18_0", + "BBS2_2": "load1_DISCONNECTOR_18_1", + "BBS2_3": "load1_DISCONNECTOR_18_2" + } + } + ], + "asset_setpoints": null, + "timestamp": "2026-06-18T21:43:40.631530", + "metrics": null + }, + "connectable_branches": [], + "disconnectable_branches": [ + { + "id": "L1", + "name": "VL1_2 ## VL2_3 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L2", + "name": "VL1_4 ## VL2_5 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L3", + "name": "VL1_6 ## VL3_4 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L4", + "name": "VL1_8 ## VL4_4 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L5", + "name": "VL1_10 ## VL4_6 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L6", + "name": "VL2_7 ## VL3_6 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L7", + "name": "VL2_9 ## VL3_8 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L8", + "name": "VL2_11 ## VL4_8 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "pst_ranges": [], + "hvdc_ranges": [], + "local_actions": [] +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set_diffs.hdf5 b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set_diffs.hdf5 new file mode 100644 index 000000000..54cf998e0 Binary files /dev/null and b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set_diffs.hdf5 differ diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/grid.xiidm b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/grid.xiidm index 6e7c7ea43..dec263039 100644 --- a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/grid.xiidm +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/grid.xiidm @@ -1,359 +1,373 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/importer_auxiliary_data.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/importer_auxiliary_data.json index 7085a4902..5053bdc82 100644 --- a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/importer_auxiliary_data.json +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/importer_auxiliary_data.json @@ -1,260 +1,268 @@ -{ - "id_lists": { - "white_list": [], - "black_list": [], - "relevant_subs": [ - "VL2_0", - "VL3_0" - ], - "line_for_nminus1": [ - "L1", - "L2", - "L3", - "L4", - "L5", - "L6", - "L7", - "L8", - "L9", - "L10" - ], - "trafo_for_nminus1": [], - "tie_line_for_nminus1": [], - "boundary_line_for_nminus1": [], - "generator_for_nminus1": [ - "generator1", - "generator2", - "generator3" - ], - "load_for_nminus1": [ - "load1", - "load2", - "load6" - ], - "switch_for_nminus1": [ - "L11_BREAKER", - "L11_DISCONNECTOR_3_0", - "L11_DISCONNECTOR_3_1", - "L21_BREAKER", - "L21_DISCONNECTOR_5_0", - "L21_DISCONNECTOR_5_1", - "L31_BREAKER", - "L31_DISCONNECTOR_7_0", - "L31_DISCONNECTOR_7_1", - "L41_BREAKER", - "L41_DISCONNECTOR_9_0", - "L41_DISCONNECTOR_9_1", - "L51_BREAKER", - "L51_DISCONNECTOR_11_0", - "L51_DISCONNECTOR_11_1", - "VL1_BREAKER", - "VL1_DISCONNECTOR_12_0", - "VL1_DISCONNECTOR_13_1", - "generator1_BREAKER", - "generator1_DISCONNECTOR_15_0", - "generator1_DISCONNECTOR_15_1", - "generator2_BREAKER", - "generator2_DISCONNECTOR_17_0", - "generator2_DISCONNECTOR_17_1", - "L12_BREAKER", - "L12_DISCONNECTOR_4_0", - "L12_DISCONNECTOR_4_1", - "L12_DISCONNECTOR_4_2", - "L22_BREAKER", - "L22_DISCONNECTOR_6_0", - "L22_DISCONNECTOR_6_1", - "L22_DISCONNECTOR_6_2", - "L61_BREAKER", - "L61_DISCONNECTOR_8_0", - "L61_DISCONNECTOR_8_1", - "L61_DISCONNECTOR_8_2", - "L71_BREAKER", - "L71_DISCONNECTOR_10_0", - "L71_DISCONNECTOR_10_1", - "L71_DISCONNECTOR_10_2", - "L81_BREAKER", - "L81_DISCONNECTOR_12_0", - "L81_DISCONNECTOR_12_1", - "L81_DISCONNECTOR_12_2", - "VL2_BREAKER", - "VL2_DISCONNECTOR_13_0", - "VL2_DISCONNECTOR_13_1", - "VL2_DISCONNECTOR_14_1", - "VL2_DISCONNECTOR_14_2", - "VL2_BREAKER#0", - "VL2_DISCONNECTOR_15_0", - "VL2_DISCONNECTOR_15_1", - "VL2_DISCONNECTOR_16_1", - "VL2_DISCONNECTOR_16_2", - "load1_BREAKER", - "load1_DISCONNECTOR_18_0", - "load1_DISCONNECTOR_18_1", - "load1_DISCONNECTOR_18_2", - "L32_BREAKER", - "L32_DISCONNECTOR_5_0", - "L32_DISCONNECTOR_5_1", - "L62_BREAKER", - "L62_DISCONNECTOR_7_0", - "L62_DISCONNECTOR_7_1", - "L72_BREAKER", - "L72_DISCONNECTOR_9_0", - "L72_DISCONNECTOR_9_1", - "L91_BREAKER", - "L91_DISCONNECTOR_11_0", - "L91_DISCONNECTOR_11_1", - "VL3_BREAKER", - "VL3_DISCONNECTOR_12_0", - "VL3_DISCONNECTOR_13_1", - "VL3_BREAKER#0", - "VL3_DISCONNECTOR_14_0", - "VL3_DISCONNECTOR_14_1", - "VL3_DISCONNECTOR_15_2", - "VL3_DISCONNECTOR_15_3", - "VL3_BREAKER#1", - "VL3_DISCONNECTOR_16_0", - "VL3_DISCONNECTOR_16_1", - "VL3_DISCONNECTOR_17_2", - "VL3_DISCONNECTOR_17_3", - "load2_BREAKER", - "load2_DISCONNECTOR_19_0", - "load2_DISCONNECTOR_19_1", - "L42_BREAKER", - "L42_DISCONNECTOR_5_0", - "L42_DISCONNECTOR_5_1", - "L42_DISCONNECTOR_5_2", - "L42_DISCONNECTOR_5_3", - "L52_BREAKER", - "L52_DISCONNECTOR_7_0", - "L52_DISCONNECTOR_7_1", - "L52_DISCONNECTOR_7_2", - "L52_DISCONNECTOR_7_3", - "L82_BREAKER", - "L82_DISCONNECTOR_9_0", - "L82_DISCONNECTOR_9_1", - "L82_DISCONNECTOR_9_2", - "L82_DISCONNECTOR_9_3", - "VL4_BREAKER", - "VL4_DISCONNECTOR_10_0", - "VL4_DISCONNECTOR_10_1", - "VL4_DISCONNECTOR_10_2", - "VL4_DISCONNECTOR_11_1", - "VL4_DISCONNECTOR_11_2", - "VL4_DISCONNECTOR_11_3", - "VL4_BREAKER#0", - "VL4_DISCONNECTOR_12_0", - "VL4_DISCONNECTOR_12_1", - "VL4_DISCONNECTOR_12_2", - "VL4_DISCONNECTOR_13_1", - "VL4_DISCONNECTOR_13_2", - "VL4_DISCONNECTOR_13_3", - "VL4_BREAKER#1", - "VL4_DISCONNECTOR_14_0", - "VL4_DISCONNECTOR_14_1", - "VL4_DISCONNECTOR_14_2", - "VL4_DISCONNECTOR_15_1", - "VL4_DISCONNECTOR_15_2", - "VL4_DISCONNECTOR_15_3", - "VL5_BREAKER", - "VL5_DISCONNECTOR_3_0", - "VL5_DISCONNECTOR_4_1", - "VL5_BREAKER#0", - "VL5_DISCONNECTOR_5_0", - "VL5_DISCONNECTOR_6_2", - "L92_BREAKER", - "L92_DISCONNECTOR_8_0", - "L92_DISCONNECTOR_8_1", - "L92_DISCONNECTOR_8_2", - "L101_BREAKER", - "L101_DISCONNECTOR_10_0", - "L101_DISCONNECTOR_10_1", - "L101_DISCONNECTOR_10_2", - "generator3_BREAKER", - "generator3_DISCONNECTOR_12_0", - "generator3_DISCONNECTOR_12_1", - "generator3_DISCONNECTOR_12_2", - "VL6_DISCONNECTOR_0_6", - "VL6_BREAKER_1_1", - "VL6_DISCONNECTOR_7_2", - "VL6_DISCONNECTOR_1_8", - "VL6_BREAKER_2_1", - "VL6_DISCONNECTOR_9_3", - "VL6_DISCONNECTOR_2_4", - "VL6_DISCONNECTOR_3_5", - "VL6_BREAKER", - "VL6_DISCONNECTOR_10_2", - "VL6_DISCONNECTOR_11_3", - "load6_BREAKER", - "load6_DISCONNECTOR_13_0", - "load6_DISCONNECTOR_13_1", - "L102_BREAKER", - "L102_DISCONNECTOR_15_0", - "L102_DISCONNECTOR_15_1" - ], - "line_disconnectable": [ - "L1", - "L2", - "L3", - "L4", - "L5", - "L6", - "L7", - "L8", - "L9", - "L10" - ] - }, - "import_result": { - "data_folder": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/notebooks/../data/20240113/pst-export/test_ucte_powsybl_example", - "n_relevant_subs": 2, - "n_low_impedance_lines": 0, - "n_branch_across_switch": 0, - "n_line_for_nminus1": 10, - "n_line_for_reward": 3, - "n_line_disconnectable": 10, - "n_trafo_for_nminus1": 0, - "n_trafo_for_reward": 0, - "n_trafo_disconnectable": 0, - "n_tie_line_for_reward": 0, - "n_tie_line_for_nminus1": 0, - "n_tie_line_disconnectable": 0, - "n_boundary_line_for_nminus1": 0, - "n_generator_for_nminus1": 3, - "n_load_for_nminus1": 3, - "n_switch_for_nminus1": 157, - "n_switch_for_reward": 0, - "n_white_list": 0, - "n_white_list_applied": 0, - "n_black_list": 0, - "n_black_list_applied": 0, - "grid_type": "ucte" - }, - "border_current": {}, - "network_changes": {}, - "import_parameter": { - "area_settings": { - "control_area": [ - "BE" - ], - "view_area": [ - "BE" - ], - "nminus1_area": [ - "BE" - ], - "cutoff_voltage": 150, - "cross_border_limits_n0": null, - "cross_border_limits_n1": null, - "dso_trafo_factors": null, - "dso_trafo_weight": 1.0, - "border_line_factors": null, - "border_line_weight": 1.0 - }, - "data_folder": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/notebooks/../data/20240113/pst-export/test_ucte_powsybl_example", - "grid_model_file": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/data/Test_Grid_asset_topo/test_ucte_powsybl_example.xiidm", - "data_type": "cgmes", - "white_list_file": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/notebooks/../data/20240113/BlackWhiteListen/CB_White-Liste.csv", - "black_list_file": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/notebooks/../data/20240113/BlackWhiteListen/CB_Black-Liste.csv", - "ignore_list_file": null, - "contingency_list_file": null - } -} +{ + "id_lists": { + "white_list": [], + "black_list": [], + "relevant_subs": [ + "VL2_0", + "VL3_0" + ], + "line_for_nminus1": [ + "L1", + "L2", + "L3", + "L4", + "L5", + "L6", + "L7", + "L8", + "L9", + "L10" + ], + "trafo_for_nminus1": [], + "tie_line_for_nminus1": [], + "boundary_line_for_nminus1": [], + "generator_for_nminus1": [ + "generator1", + "generator2", + "generator3" + ], + "load_for_nminus1": [ + "load1", + "load2", + "load6" + ], + "switch_for_nminus1": [ + "L11_BREAKER", + "L11_DISCONNECTOR_3_0", + "L11_DISCONNECTOR_3_1", + "L21_BREAKER", + "L21_DISCONNECTOR_5_0", + "L21_DISCONNECTOR_5_1", + "L31_BREAKER", + "L31_DISCONNECTOR_7_0", + "L31_DISCONNECTOR_7_1", + "L41_BREAKER", + "L41_DISCONNECTOR_9_0", + "L41_DISCONNECTOR_9_1", + "L51_BREAKER", + "L51_DISCONNECTOR_11_0", + "L51_DISCONNECTOR_11_1", + "VL1_BREAKER", + "VL1_DISCONNECTOR_12_0", + "VL1_DISCONNECTOR_13_1", + "generator1_BREAKER", + "generator1_DISCONNECTOR_15_0", + "generator1_DISCONNECTOR_15_1", + "generator2_BREAKER", + "generator2_DISCONNECTOR_17_0", + "generator2_DISCONNECTOR_17_1", + "L12_BREAKER", + "L12_DISCONNECTOR_4_0", + "L12_DISCONNECTOR_4_1", + "L12_DISCONNECTOR_4_2", + "L22_BREAKER", + "L22_DISCONNECTOR_6_0", + "L22_DISCONNECTOR_6_1", + "L22_DISCONNECTOR_6_2", + "L61_BREAKER", + "L61_DISCONNECTOR_8_0", + "L61_DISCONNECTOR_8_1", + "L61_DISCONNECTOR_8_2", + "L71_BREAKER", + "L71_DISCONNECTOR_10_0", + "L71_DISCONNECTOR_10_1", + "L71_DISCONNECTOR_10_2", + "L81_BREAKER", + "L81_DISCONNECTOR_12_0", + "L81_DISCONNECTOR_12_1", + "L81_DISCONNECTOR_12_2", + "VL2_BREAKER", + "VL2_DISCONNECTOR_13_0", + "VL2_DISCONNECTOR_13_1", + "VL2_DISCONNECTOR_14_1", + "VL2_DISCONNECTOR_14_2", + "VL2_BREAKER#0", + "VL2_DISCONNECTOR_15_0", + "VL2_DISCONNECTOR_15_1", + "VL2_DISCONNECTOR_16_1", + "VL2_DISCONNECTOR_16_2", + "load1_BREAKER", + "load1_DISCONNECTOR_18_0", + "load1_DISCONNECTOR_18_1", + "load1_DISCONNECTOR_18_2", + "L32_BREAKER", + "L32_DISCONNECTOR_5_0", + "L32_DISCONNECTOR_5_1", + "L62_BREAKER", + "L62_DISCONNECTOR_7_0", + "L62_DISCONNECTOR_7_1", + "L72_BREAKER", + "L72_DISCONNECTOR_9_0", + "L72_DISCONNECTOR_9_1", + "L91_BREAKER", + "L91_DISCONNECTOR_11_0", + "L91_DISCONNECTOR_11_1", + "VL3_BREAKER", + "VL3_DISCONNECTOR_12_0", + "VL3_DISCONNECTOR_13_1", + "VL3_BREAKER#0", + "VL3_DISCONNECTOR_14_0", + "VL3_DISCONNECTOR_14_1", + "VL3_DISCONNECTOR_15_2", + "VL3_DISCONNECTOR_15_3", + "VL3_BREAKER#1", + "VL3_DISCONNECTOR_16_0", + "VL3_DISCONNECTOR_16_1", + "VL3_DISCONNECTOR_17_2", + "VL3_DISCONNECTOR_17_3", + "load2_BREAKER", + "load2_DISCONNECTOR_19_0", + "load2_DISCONNECTOR_19_1", + "L42_BREAKER", + "L42_DISCONNECTOR_5_0", + "L42_DISCONNECTOR_5_1", + "L42_DISCONNECTOR_5_2", + "L42_DISCONNECTOR_5_3", + "L52_BREAKER", + "L52_DISCONNECTOR_7_0", + "L52_DISCONNECTOR_7_1", + "L52_DISCONNECTOR_7_2", + "L52_DISCONNECTOR_7_3", + "L82_BREAKER", + "L82_DISCONNECTOR_9_0", + "L82_DISCONNECTOR_9_1", + "L82_DISCONNECTOR_9_2", + "L82_DISCONNECTOR_9_3", + "VL4_BREAKER", + "VL4_DISCONNECTOR_10_0", + "VL4_DISCONNECTOR_10_1", + "VL4_DISCONNECTOR_10_2", + "VL4_DISCONNECTOR_11_1", + "VL4_DISCONNECTOR_11_2", + "VL4_DISCONNECTOR_11_3", + "VL4_BREAKER#0", + "VL4_DISCONNECTOR_12_0", + "VL4_DISCONNECTOR_12_1", + "VL4_DISCONNECTOR_12_2", + "VL4_DISCONNECTOR_13_1", + "VL4_DISCONNECTOR_13_2", + "VL4_DISCONNECTOR_13_3", + "VL4_BREAKER#1", + "VL4_DISCONNECTOR_14_0", + "VL4_DISCONNECTOR_14_1", + "VL4_DISCONNECTOR_14_2", + "VL4_DISCONNECTOR_15_1", + "VL4_DISCONNECTOR_15_2", + "VL4_DISCONNECTOR_15_3", + "VL5_BREAKER", + "VL5_DISCONNECTOR_3_0", + "VL5_DISCONNECTOR_4_1", + "VL5_BREAKER#0", + "VL5_DISCONNECTOR_5_0", + "VL5_DISCONNECTOR_6_2", + "L92_BREAKER", + "L92_DISCONNECTOR_8_0", + "L92_DISCONNECTOR_8_1", + "L92_DISCONNECTOR_8_2", + "L101_BREAKER", + "L101_DISCONNECTOR_10_0", + "L101_DISCONNECTOR_10_1", + "L101_DISCONNECTOR_10_2", + "generator3_BREAKER", + "generator3_DISCONNECTOR_12_0", + "generator3_DISCONNECTOR_12_1", + "generator3_DISCONNECTOR_12_2", + "VL6_DISCONNECTOR_0_6", + "VL6_BREAKER_1_1", + "VL6_DISCONNECTOR_7_2", + "VL6_DISCONNECTOR_1_8", + "VL6_BREAKER_2_1", + "VL6_DISCONNECTOR_9_3", + "VL6_DISCONNECTOR_2_4", + "VL6_DISCONNECTOR_3_5", + "VL6_BREAKER", + "VL6_DISCONNECTOR_10_2", + "VL6_DISCONNECTOR_11_3", + "load6_BREAKER", + "load6_DISCONNECTOR_13_0", + "load6_DISCONNECTOR_13_1", + "L102_BREAKER", + "L102_DISCONNECTOR_15_0", + "L102_DISCONNECTOR_15_1" + ], + "line_disconnectable": [ + "L1", + "L2", + "L3", + "L4", + "L5", + "L6", + "L7", + "L8", + "L9", + "L10" + ], + "trafo_disconnectable": [] + }, + "import_result": { + "data_folder": "../data/test_grid_node_breaker/grid", + "n_relevant_subs": 2, + "n_low_impedance_lines": 0, + "n_branch_across_switch": 0, + "n_line_for_nminus1": 10, + "n_line_for_reward": 3, + "n_line_disconnectable": 10, + "n_trafo_for_nminus1": 0, + "n_trafo_for_reward": 0, + "n_trafo_disconnectable": 0, + "n_tie_line_for_reward": 0, + "n_tie_line_for_nminus1": 0, + "n_tie_line_disconnectable": 0, + "n_boundary_line_for_nminus1": 0, + "n_generator_for_nminus1": 3, + "n_load_for_nminus1": 3, + "n_switch_for_nminus1": 157, + "n_switch_for_reward": 0, + "n_white_list": 0, + "n_white_list_applied": 0, + "n_black_list": 0, + "n_black_list_applied": 0, + "grid_type": "cgmes" + }, + "border_current": {}, + "network_changes": {}, + "import_parameter": { + "area_settings": { + "control_area": [ + "" + ], + "view_area": [ + "" + ], + "nminus1_area": [ + "" + ], + "cutoff_voltage": 10, + "dso_trafo_factors": null, + "dso_trafo_weight": 1.0, + "border_line_factors": null, + "border_line_weight": 1.0 + }, + "fail_on_non_convergence": true, + "data_folder": "../data/test_grid_node_breaker/grid", + "grid_model_file": "../data/test_grid_node_breaker/grid.xiidm", + "data_type": "cgmes", + "white_list_file": null, + "black_list_file": null, + "ignore_list_file": null, + "select_by_voltage_level_id_list": null, + "ingress_id": null, + "contingency_list_file": null, + "schema_format": null, + "relevant_station_rules": { + "min_busbars": 2, + "min_connected_branches": 4, + "min_connected_elements": 4 + } + } +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/asset_topology.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/asset_topology.json index 1083d710e..79a08e320 100644 --- a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/asset_topology.json +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/asset_topology.json @@ -1,420 +1,516 @@ -{ - "topology_id": "test_ucte_powsybl_example.xiidm", - "grid_model_file": "/workspaces/AICoE_HPC_RL_Grid_Model_Importing/data/Test_Grid_asset_topo/test_ucte_powsybl_example.xiidm", - "name": null, - "stations": [ - { - "grid_model_id": "VL2_0", - "name": "VLevel2", - "type": null, - "region": "BE", - "voltage_level": 225.0, - "busbars": [ - { - "grid_model_id": "BBS2_1", - "type": "busbar", - "name": "bus1", - "int_id": 0, - "in_service": true - }, - { - "grid_model_id": "BBS2_2", - "type": "busbar", - "name": "bus2", - "int_id": 1, - "in_service": true - }, - { - "grid_model_id": "BBS2_3", - "type": "busbar", - "name": "bus3", - "int_id": 2, - "in_service": true - } - ], - "couplers": [ - { - "grid_model_id": "VL2_BREAKER", - "type": "BREAKER", - "name": "VL2_BREAKER", - "busbar_from_id": 0, - "busbar_to_id": 1, - "open": false, - "in_service": true, - "asset_bay": null - }, - { - "grid_model_id": "VL2_BREAKER#0", - "type": "BREAKER", - "name": "VL2_BREAKER#0", - "busbar_from_id": 1, - "busbar_to_id": 2, - "open": false, - "in_service": true, - "asset_bay": null - } - ], - "assets": [ - { - "grid_model_id": "L1", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L12_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L12_DISCONNECTOR_4_0", - "BBS2_2": "L12_DISCONNECTOR_4_1", - "BBS2_3": "L12_DISCONNECTOR_4_2" - } - } - }, - { - "grid_model_id": "L2", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L22_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L22_DISCONNECTOR_6_0", - "BBS2_2": "L22_DISCONNECTOR_6_1", - "BBS2_3": "L22_DISCONNECTOR_6_2" - } - } - }, - { - "grid_model_id": "L6", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L61_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L61_DISCONNECTOR_8_0", - "BBS2_2": "L61_DISCONNECTOR_8_1", - "BBS2_3": "L61_DISCONNECTOR_8_2" - } - } - }, - { - "grid_model_id": "L7", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L71_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L71_DISCONNECTOR_10_0", - "BBS2_2": "L71_DISCONNECTOR_10_1", - "BBS2_3": "L71_DISCONNECTOR_10_2" - } - } - }, - { - "grid_model_id": "L8", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L81_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "L81_DISCONNECTOR_12_0", - "BBS2_2": "L81_DISCONNECTOR_12_1", - "BBS2_3": "L81_DISCONNECTOR_12_2" - } - } - }, - { - "grid_model_id": "load1", - "type": "LOAD", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "load1_BREAKER", - "sr_switch_grid_model_id": { - "BBS2_1": "load1_DISCONNECTOR_18_0", - "BBS2_2": "load1_DISCONNECTOR_18_1", - "BBS2_3": "load1_DISCONNECTOR_18_2" - } - } - } - ], - "asset_switching_table": [ - [ - true, - false, - true, - false, - true, - true - ], - [ - false, - true, - false, - true, - false, - false - ], - [ - false, - false, - false, - false, - false, - false - ] - ], - "asset_connectivity": [ - [ - true, - true, - true, - true, - true, - true - ], - [ - true, - true, - true, - true, - true, - true - ], - [ - true, - true, - true, - true, - true, - true - ] - ], - "model_log": [] - }, - { - "grid_model_id": "VL3_0", - "name": "VLevel3", - "type": null, - "region": "BE", - "voltage_level": 225.0, - "busbars": [ - { - "grid_model_id": "BBS3_1", - "type": "busbar", - "name": "bus1", - "int_id": 0, - "in_service": true - }, - { - "grid_model_id": "BBS3_2", - "type": "busbar", - "name": "bus2", - "int_id": 1, - "in_service": true - }, - { - "grid_model_id": "BBS3_3", - "type": "busbar", - "name": "bus3", - "int_id": 2, - "in_service": true - }, - { - "grid_model_id": "BBS3_4", - "type": "busbar", - "name": "bus4", - "int_id": 3, - "in_service": true - } - ], - "couplers": [ - { - "grid_model_id": "VL3_BREAKER", - "type": "BREAKER", - "name": "VL3_BREAKER", - "busbar_from_id": 0, - "busbar_to_id": 1, - "open": false, - "in_service": true, - "asset_bay": null - }, - { - "grid_model_id": "VL3_BREAKER#0", - "type": "BREAKER", - "name": "VL3_BREAKER#0", - "busbar_from_id": 0, - "busbar_to_id": 2, - "open": false, - "in_service": true, - "asset_bay": null - }, - { - "grid_model_id": "VL3_BREAKER#1", - "type": "BREAKER", - "name": "VL3_BREAKER#1", - "busbar_from_id": 1, - "busbar_to_id": 3, - "open": false, - "in_service": true, - "asset_bay": null - } - ], - "assets": [ - { - "grid_model_id": "L3", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L32_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L32_DISCONNECTOR_3_0", - "BBS3_2": "L32_DISCONNECTOR_3_1" - } - } - }, - { - "grid_model_id": "L6", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L62_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L62_DISCONNECTOR_5_0", - "BBS3_2": "L62_DISCONNECTOR_5_1" - } - } - }, - { - "grid_model_id": "L7", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L72_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L72_DISCONNECTOR_7_0", - "BBS3_2": "L72_DISCONNECTOR_7_1" - } - } - }, - { - "grid_model_id": "L9", - "type": "LINE", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "L91_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "L91_DISCONNECTOR_9_0", - "BBS3_2": "L91_DISCONNECTOR_9_1" - } - } - }, - { - "grid_model_id": "load2", - "type": "LOAD", - "name": "", - "in_service": true, - "branch_end": null, - "asset_bay": { - "sl_switch_grid_model_id": null, - "dv_switch_grid_model_id": "load2_BREAKER", - "sr_switch_grid_model_id": { - "BBS3_1": "load2_DISCONNECTOR_13_0", - "BBS3_2": "load2_DISCONNECTOR_13_1" - } - } - } - ], - "asset_switching_table": [ - [ - true, - true, - false, - true, - false - ], - [ - false, - false, - true, - false, - true - ], - [ - false, - false, - false, - false, - false - ], - [ - false, - false, - false, - false, - false - ] - ], - "asset_connectivity": [ - [ - true, - true, - true, - true, - true - ], - [ - true, - true, - true, - true, - true - ], - [ - false, - false, - false, - false, - false - ], - [ - false, - false, - false, - false, - false - ] - ], - "model_log": [] - } - ], - "asset_setpoints": null, - "timestamp": "2025-05-16T15:25:10.814573", - "metrics": null -} +{ + "topology_id": "grid.xiidm", + "grid_model_file": "grid.xiidm", + "name": null, + "raw_stations": [ + { + "grid_model_id": "VL2_0", + "name": "VLevel2", + "station_type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS2_1", + "busbar_type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + }, + { + "grid_model_id": "BBS2_2", + "busbar_type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + }, + { + "grid_model_id": "BBS2_3", + "busbar_type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": "VL2_0" + } + ], + "couplers": [ + { + "grid_model_id": "VL2_BREAKER", + "coupler_type": "BREAKER", + "name": "VL2_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL2_BREAKER#0", + "coupler_type": "BREAKER", + "name": "VL2_BREAKER#0", + "busbar_from_id": 1, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "branch_switching_table": [ + [ + true, + false, + true, + false, + true + ], + [ + false, + true, + false, + true, + false + ], + [ + false, + false, + false, + false, + false + ] + ], + "injection_switching_table": [ + [ + true + ], + [ + false + ], + [ + false + ] + ], + "branch_connectivity": [ + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ], + [ + true, + true, + true, + true, + true + ] + ], + "injection_connectivity": [ + [ + true + ], + [ + true + ], + [ + true + ] + ], + "model_log": [], + "branch_connections": [ + { + "asset_id": "L1", + "terminal": null, + "asset_bay_id": "VLevel2::L1::bay" + }, + { + "asset_id": "L2", + "terminal": null, + "asset_bay_id": "VLevel2::L2::bay" + }, + { + "asset_id": "L6", + "terminal": null, + "asset_bay_id": "VLevel2::L6::bay" + }, + { + "asset_id": "L7", + "terminal": null, + "asset_bay_id": "VLevel2::L7::bay" + }, + { + "asset_id": "L8", + "terminal": null, + "asset_bay_id": "VLevel2::L8::bay" + } + ], + "injection_connections": [ + { + "asset_id": "load1", + "terminal": null, + "asset_bay_id": "VLevel2::load1::bay" + } + ] + }, + { + "grid_model_id": "VL3_0", + "name": "VLevel3", + "station_type": null, + "region": "BE", + "voltage_level": 225.0, + "busbars": [ + { + "grid_model_id": "BBS3_1", + "busbar_type": "busbar", + "name": "bus1", + "int_id": 0, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + }, + { + "grid_model_id": "BBS3_2", + "busbar_type": "busbar", + "name": "bus2", + "int_id": 1, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + }, + { + "grid_model_id": "BBS3_3", + "busbar_type": "busbar", + "name": "bus3", + "int_id": 2, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + }, + { + "grid_model_id": "BBS3_4", + "busbar_type": "busbar", + "name": "bus4", + "int_id": 3, + "in_service": true, + "bus_branch_bus_id": "VL3_0" + } + ], + "couplers": [ + { + "grid_model_id": "VL3_BREAKER", + "coupler_type": "BREAKER", + "name": "VL3_BREAKER", + "busbar_from_id": 0, + "busbar_to_id": 1, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL3_BREAKER#0", + "coupler_type": "BREAKER", + "name": "VL3_BREAKER#0", + "busbar_from_id": 0, + "busbar_to_id": 2, + "open": false, + "in_service": true, + "asset_bay": null + }, + { + "grid_model_id": "VL3_BREAKER#1", + "coupler_type": "BREAKER", + "name": "VL3_BREAKER#1", + "busbar_from_id": 1, + "busbar_to_id": 3, + "open": false, + "in_service": true, + "asset_bay": null + } + ], + "branch_switching_table": [ + [ + true, + true, + false, + true + ], + [ + false, + false, + true, + false + ], + [ + false, + false, + false, + false + ], + [ + false, + false, + false, + false + ] + ], + "injection_switching_table": [ + [ + false + ], + [ + true + ], + [ + false + ], + [ + false + ] + ], + "branch_connectivity": [ + [ + true, + true, + true, + true + ], + [ + true, + true, + true, + true + ], + [ + false, + false, + false, + false + ], + [ + false, + false, + false, + false + ] + ], + "injection_connectivity": [ + [ + true + ], + [ + true + ], + [ + false + ], + [ + false + ] + ], + "model_log": [], + "branch_connections": [ + { + "asset_id": "L3", + "terminal": null, + "asset_bay_id": "VLevel3::L3::bay" + }, + { + "asset_id": "L6", + "terminal": null, + "asset_bay_id": "VLevel3::L6::bay" + }, + { + "asset_id": "L7", + "terminal": null, + "asset_bay_id": "VLevel3::L7::bay" + }, + { + "asset_id": "L9", + "terminal": null, + "asset_bay_id": "VLevel3::L9::bay" + } + ], + "injection_connections": [ + { + "asset_id": "load2", + "terminal": null, + "asset_bay_id": "VLevel3::load2::bay" + } + ] + } + ], + "branch_assets": [ + { + "grid_model_id": "L1", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L2", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L6", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L7", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L8", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L3", + "asset_type": "LINE", + "name": "", + "in_service": true + }, + { + "grid_model_id": "L9", + "asset_type": "LINE", + "name": "", + "in_service": true + } + ], + "injection_assets": [ + { + "grid_model_id": "load1", + "asset_type": "LOAD", + "name": "", + "in_service": true + }, + { + "grid_model_id": "load2", + "asset_type": "LOAD", + "name": "", + "in_service": true + } + ], + "asset_bays": [ + { + "asset_bay_id": "VLevel2::L1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L12_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L12_DISCONNECTOR_4_0", + "BBS2_2": "L12_DISCONNECTOR_4_1", + "BBS2_3": "L12_DISCONNECTOR_4_2" + } + }, + { + "asset_bay_id": "VLevel2::L2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L22_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L22_DISCONNECTOR_6_0", + "BBS2_2": "L22_DISCONNECTOR_6_1", + "BBS2_3": "L22_DISCONNECTOR_6_2" + } + }, + { + "asset_bay_id": "VLevel2::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L61_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L61_DISCONNECTOR_8_0", + "BBS2_2": "L61_DISCONNECTOR_8_1", + "BBS2_3": "L61_DISCONNECTOR_8_2" + } + }, + { + "asset_bay_id": "VLevel2::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L71_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L71_DISCONNECTOR_10_0", + "BBS2_2": "L71_DISCONNECTOR_10_1", + "BBS2_3": "L71_DISCONNECTOR_10_2" + } + }, + { + "asset_bay_id": "VLevel2::L8::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L81_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "L81_DISCONNECTOR_12_0", + "BBS2_2": "L81_DISCONNECTOR_12_1", + "BBS2_3": "L81_DISCONNECTOR_12_2" + } + }, + { + "asset_bay_id": "VLevel2::load1::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load1_BREAKER", + "sr_switch_grid_model_id": { + "BBS2_1": "load1_DISCONNECTOR_18_0", + "BBS2_2": "load1_DISCONNECTOR_18_1", + "BBS2_3": "load1_DISCONNECTOR_18_2" + } + }, + { + "asset_bay_id": "VLevel3::L3::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L32_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L32_DISCONNECTOR_5_0", + "BBS3_2": "L32_DISCONNECTOR_5_1" + } + }, + { + "asset_bay_id": "VLevel3::L6::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L62_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L62_DISCONNECTOR_7_0", + "BBS3_2": "L62_DISCONNECTOR_7_1" + } + }, + { + "asset_bay_id": "VLevel3::L7::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L72_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L72_DISCONNECTOR_9_0", + "BBS3_2": "L72_DISCONNECTOR_9_1" + } + }, + { + "asset_bay_id": "VLevel3::L9::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "L91_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "L91_DISCONNECTOR_11_0", + "BBS3_2": "L91_DISCONNECTOR_11_1" + } + }, + { + "asset_bay_id": "VLevel3::load2::bay", + "sl_switch_grid_model_id": null, + "dv_switch_grid_model_id": "load2_BREAKER", + "sr_switch_grid_model_id": { + "BBS3_1": "load2_DISCONNECTOR_19_0", + "BBS3_2": "load2_DISCONNECTOR_19_1" + } + } + ], + "asset_setpoints": null, + "timestamp": "2026-06-18T21:43:40.631530", + "metrics": null +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/test_ucte_powsybl_example.xiidm b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm similarity index 100% rename from packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/test_ucte_powsybl_example.xiidm rename to packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json new file mode 100644 index 000000000..c0a0d9f30 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json @@ -0,0 +1 @@ +{"version": "1.10", "voltageInitMode": "DC_VALUES", "transformerVoltageControlOn": false, "phaseShifterRegulationOn": false, "useReactiveLimits": true, "twtSplitShuntAdmittance": false, "shuntCompensatorVoltageControlOn": false, "readSlackBus": true, "writeSlackBus": true, "dc": false, "distributedSlack": true, "balanceType": "PROPORTIONAL_TO_GENERATION_P", "dcUseTransformerRatio": true, "countriesToBalance": [], "componentMode": "MAIN_CONNECTED", "hvdcAcEmulation": true, "dcPowerFactor": 1.0, "extensions": {"DynaFlowParameters": {"svcRegulationOn": true, "dsoVoltageLevel": 45.0, "tfoVoltageLevel": 100.0, "startTime": 0.0, "stopTime": 100.0, "chosenOutputs": ["TIMELINE"], "timeStep": 10.0, "mergeLoads": true}, "open-load-flow-parameters": {"slackBusSelectionMode": "MOST_MESHED", "slackBusesIds": [], "slackDistributionFailureBehavior": "LEAVE_ON_SLACK_BUS", "voltageRemoteControl": true, "voltageRemoteControlRobustMode": true, "lowImpedanceBranchMode": "REPLACE_BY_ZERO_IMPEDANCE_LINE", "loadPowerFactorConstant": false, "plausibleActivePowerLimit": 10000.0, "newtonRaphsonStoppingCriteriaType": "UNIFORM_CRITERIA", "maxActivePowerMismatch": 0.01, "maxReactivePowerMismatch": 0.01, "maxVoltageMismatch": 0.0001, "maxAngleMismatch": 1e-05, "maxRatioMismatch": 1e-05, "maxSusceptanceMismatch": 0.0001, "slackBusPMaxMismatch": 1.0, "voltagePerReactivePowerControl": false, "generatorReactivePowerRemoteControl": false, "transformerReactivePowerControl": false, "maxNewtonRaphsonIterations": 15, "maxOuterLoopIterations": 20, "newtonRaphsonConvEpsPerEq": 0.0001, "voltageInitModeOverride": "NONE", "transformerVoltageControlMode": "INCREMENTAL_VOLTAGE_CONTROL", "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", "minPlausibleTargetVoltage": 0.8, "maxPlausibleTargetVoltage": 1.2, "minNominalVoltageTargetVoltageCheck": 20.0, "minRealisticVoltage": 0.5, "maxRealisticVoltage": 2.0, "minNominalVoltageRealisticVoltageCheck": 0.0, "lowImpedanceThreshold": 1e-08, "reactiveRangeCheckMode": "MAX", "networkCacheEnabled": false, "svcVoltageMonitoring": true, "stateVectorScalingMode": "NONE", "maxSlackBusCount": 1, "debugDir": null, "incrementalTransformerRatioTapControlOuterLoopMaxTapShift": 3, "secondaryVoltageControl": false, "reactiveLimitsMaxPqPvSwitch": 3, "phaseShifterControlMode": "CONTINUOUS_WITH_DISCRETISATION", "alwaysUpdateNetwork": false, "mostMeshedSlackBusSelectorMaxNominalVoltagePercentile": 95.0, "reportedFeatures": [], "slackBusCountryFilter": [], "actionableSwitchesIds": [], "actionableTransformersIds": [], "asymmetrical": false, "reactivePowerDispatchMode": "Q_EQUAL_PROPORTION", "outerLoopNames": null, "useActiveLimits": true, "disableVoltageControlOfGeneratorsOutsideActivePowerLimits": false, "lineSearchStateVectorScalingMaxIteration": 10, "lineSearchStateVectorScalingStepFold": 1.3333333333333333, "maxVoltageChangeStateVectorScalingMaxDv": 0.1, "maxVoltageChangeStateVectorScalingMaxDphi": 0.17453292519943295, "linePerUnitMode": "IMPEDANCE", "useLoadModel": false, "dcApproximationType": "IGNORE_R", "simulateAutomationSystems": false, "acSolverType": "NEWTON_RAPHSON", "maxNewtonKrylovIterations": 100, "newtonKrylovLineSearch": false, "referenceBusSelectionMode": "FIRST_SLACK", "writeReferenceTerminals": true, "voltageTargetPriorities": ["VOLTAGE_SOURCE_CONVERTER", "GENERATOR", "TRANSFORMER", "SHUNT"], "transformerVoltageControlUseInitialTapPosition": false, "generatorVoltageControlMinNominalVoltage": -1.0, "fictitiousGeneratorVoltageControlCheckMode": "FORCED", "areaInterchangeControl": false, "areaInterchangeControlAreaType": "ControlArea", "areaInterchangePMaxMismatch": 2.0, "forceTargetQInReactiveLimits": false, "disableInconsistentVoltageControls": false, "extrapolateReactiveLimits": false, "startWithFrozenACEmulation": false, "generatorsWithZeroMwTargetAreNotStarted": true, "incrementalShuntControlOuterLoopMaxSectionShift": 3, "fixVoltageTargets": false, "acDcNetwork": false}}, "provider_parameters": {"slackDistributionFailureBehavior": "LEAVE_ON_SLACK_BUS"}} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/busbar_for_nminus1.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/busbar_for_nminus1.npy new file mode 100644 index 000000000..2ca75c0f8 Binary files /dev/null and b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/busbar_for_nminus1.npy differ diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/network_data.pkl b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/network_data.pkl deleted file mode 100644 index ffc265408..000000000 Binary files a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/network_data.pkl and /dev/null differ diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/nminus1_definition.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/nminus1_definition.json index 7d3907fa2..151b2e17a 100644 --- a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/nminus1_definition.json +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/nminus1_definition.json @@ -1,156 +1,205 @@ -{ - "monitored_elements": [ - { - "id": "L1", - "type": "LINE", - "kind": "branch" - }, - { - "id": "L2", - "type": "LINE", - "kind": "branch" - }, - { - "id": "L3", - "type": "LINE", - "kind": "branch" - }, - { - "id": "VL1_0", - "type": "BUS", - "kind": "node" - }, - { - "id": "VL2_0", - "type": "BUS", - "kind": "node" - }, - { - "id": "VL3_0", - "type": "BUS", - "kind": "node" - } - ], - "contingencies": [ - { - "elements": [ - { - "id": "L1", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L1" - }, - { - "elements": [ - { - "id": "L2", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L2" - }, - { - "elements": [ - { - "id": "L3", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L3" - }, - { - "elements": [ - { - "id": "L4", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L4" - }, - { - "elements": [ - { - "id": "L5", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L5" - }, - { - "elements": [ - { - "id": "L6", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L6" - }, - { - "elements": [ - { - "id": "L7", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L7" - }, - { - "elements": [ - { - "id": "L8", - "type": "LINE", - "kind": "branch" - } - ], - "name": "L8" - }, - { - "elements": [ - { - "id": "generator3", - "type": "GENERATOR", - "kind": "injection" - } - ], - "name": "generator3" - }, - { - "elements": [ - { - "id": "load2", - "type": "LOAD", - "kind": "injection" - } - ], - "name": "load2" - }, - { - "elements": [ - { - "id": "load6", - "type": "LOAD", - "kind": "injection" - } - ], - "name": "load6" - }, - { - "elements": [ - { - "id": "load1", - "type": "LOAD", - "kind": "injection" - } - ], - "name": "load1" - } - ] -} +{ + "monitored_elements": [ + { + "id": "L1", + "name": "VL1_2 ## VL2_3 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L2", + "name": "VL1_4 ## VL2_5 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "L3", + "name": "VL1_6 ## VL3_4 ## ", + "type": "LINE", + "kind": "branch" + }, + { + "id": "BBS2_1", + "name": "bus1", + "type": "busbar", + "kind": "bus" + }, + { + "id": "BBS2_2", + "name": "bus2", + "type": "busbar", + "kind": "bus" + }, + { + "id": "BBS2_3", + "name": "bus3", + "type": "busbar", + "kind": "bus" + }, + { + "id": "VL2_BREAKER", + "name": "VL2_BREAKER", + "type": "BREAKER", + "kind": "switch" + }, + { + "id": "VL2_BREAKER#0", + "name": "VL2_BREAKER#0", + "type": "BREAKER", + "kind": "switch" + } + ], + "contingencies": [ + { + "elements": [], + "id": "BASECASE", + "name": "" + }, + { + "elements": [ + { + "id": "L1", + "name": "VL1_2 ## VL2_3 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L1", + "name": "VL1_2 ## VL2_3 ## " + }, + { + "elements": [ + { + "id": "L2", + "name": "VL1_4 ## VL2_5 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L2", + "name": "VL1_4 ## VL2_5 ## " + }, + { + "elements": [ + { + "id": "L3", + "name": "VL1_6 ## VL3_4 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L3", + "name": "VL1_6 ## VL3_4 ## " + }, + { + "elements": [ + { + "id": "L4", + "name": "VL1_8 ## VL4_4 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L4", + "name": "VL1_8 ## VL4_4 ## " + }, + { + "elements": [ + { + "id": "L5", + "name": "VL1_10 ## VL4_6 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L5", + "name": "VL1_10 ## VL4_6 ## " + }, + { + "elements": [ + { + "id": "L6", + "name": "VL2_7 ## VL3_6 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L6", + "name": "VL2_7 ## VL3_6 ## " + }, + { + "elements": [ + { + "id": "L7", + "name": "VL2_9 ## VL3_8 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L7", + "name": "VL2_9 ## VL3_8 ## " + }, + { + "elements": [ + { + "id": "L8", + "name": "VL2_11 ## VL4_8 ## ", + "type": "LINE", + "kind": "branch" + } + ], + "id": "L8", + "name": "VL2_11 ## VL4_8 ## " + }, + { + "elements": [ + { + "id": "generator3", + "name": "", + "type": "GENERATOR", + "kind": "injection" + } + ], + "id": "generator3", + "name": "" + }, + { + "elements": [ + { + "id": "load2", + "name": "", + "type": "LOAD", + "kind": "injection" + } + ], + "id": "load2", + "name": "" + }, + { + "elements": [ + { + "id": "load6", + "name": "", + "type": "LOAD", + "kind": "injection" + } + ], + "id": "load6", + "name": "" + }, + { + "elements": [ + { + "id": "load1", + "name": "", + "type": "LOAD", + "kind": "injection" + } + ], + "id": "load1", + "name": "" + } + ], + "spps_rules": null, + "id_type": null +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/res.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/res.json new file mode 100644 index 000000000..ed7488101 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/res.json @@ -0,0 +1,98 @@ +{ + "total_branch_combis": 2532808, + "total_inj_combis": 2532808, + "total_num_splits": 0, + "max_fitness": -88.39493471678644, + "best_topos": [ + { + "actions": [ + 5 + ], + "disconnections": [], + "pst_setpoints": null, + "metrics": { + "fitness": -88.39493471678644, + "extra_scores": { + "overload_energy_n_1": 88.39493471678644, + "split_subs": 1.0, + "top_k_overloads_n_1": 133.17284815697712 + }, + "worst_k_contingency_cases": [ + "generator3", + "L8" + ] + }, + "loadflow_results": null + } + ], + "initial_fitness": -138.25617430975592, + "initial_metrics": { + "overload_energy_n_1": 138.25617430975592, + "split_subs": 0.0 + }, + "args": { + "ga_config": { + "runtime_seconds": 30.0, + "iterations_per_epoch": 100, + "random_seed": 42, + "enable_nodal_inj_optim": false, + "plot": false, + "n_worst_contingencies": 2, + "enable_bb_outage": false, + "bb_outage_as_nminus1": true, + "clip_bb_outage_penalty": false, + "bb_outage_more_islands_penalty": 0.0, + "mutation_repetition": 1, + "random_topo_prob": 0.1, + "n_subs_mutated_lambda": 2.0, + "add_split_prob": 0.25, + "change_split_prob": 0.3, + "remove_split_prob": 0.25, + "add_disconnection_prob": 0.2, + "change_disconnection_prob": 0.2, + "remove_disconnection_prob": 0.2, + "pst_mutation_sigma": 0.0, + "pst_mutation_probability": 0.2, + "pst_reset_probability": 0.0, + "proportion_crossover": 0.1, + "crossover_mutation_ratio": 0.5, + "target_metrics": [ + [ + "overload_energy_n_1", + 1.0 + ] + ], + "observed_metrics": [ + "overload_energy_n_1", + "split_subs" + ], + "me_descriptors": [ + { + "metric": "split_subs", + "num_cells": 4, + "range": null + } + ], + "cell_depth": 1 + }, + "lf_config": { + "max_num_splits": 4, + "max_num_disconnections": 0, + "batch_size": 8, + "distributed": false, + "cross_coupler_flow": false + }, + "fixed_files": [ + "../data/test_grid_node_breaker/grid/static_information.hdf5" + ], + "tensorboard_dir": "../data/test_grid_node_breaker/results/test", + "stats_dir": "../data/test_grid_node_breaker/results/test", + "summary_frequency": 10, + "checkpoint_frequency": 100, + "double_limits": [ + 1.0, + 1.0 + ] + }, + "iteration": 3166 +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_loadflow_results.csv b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_loadflow_results.csv new file mode 100644 index 000000000..8dbdf83a7 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_loadflow_results.csv @@ -0,0 +1,73 @@ +timestep,contingency,element,side,p,q,i,loading,element_name,contingency_name +0,BASECASE,L1,1,23.397332812941563,2.5096278735439626,60.38196197681353,0.6709106886312615,VL1_2 ## VL2_3 ## ,"" +0,BASECASE,L2,1,23.397332812941563,2.5096278735439626,60.38196197681353,0.6709106886312615,VL1_4 ## VL2_5 ## ,"" +0,BASECASE,L3,1,35.156138126733296,2.7085548884780764,90.4780288022079,1.0053114311356433,VL1_6 ## VL3_4 ## ,"" +0,L1,L2,1,39.270438968568286,4.051466896314793,101.30284664189512,1.1255871849099457,VL1_4 ## VL2_5 ## ,VL1_2 ## VL2_3 ## +0,L1,L3,1,39.33816630600753,2.8857047097261557,101.21300913443238,1.124588990382582,VL1_6 ## VL3_4 ## ,VL1_2 ## VL2_3 ## +0,load6,L1,1,12.74054859893965,2.593044329123728,33.36250155868797,0.37069446176319965,VL1_2 ## VL2_3 ## ,"" +0,load6,L2,1,12.74054859893965,2.593044329123728,33.36250155868797,0.37069446176319965,VL1_4 ## VL2_5 ## ,"" +0,load6,L3,1,19.198623809885508,3.3118523010731593,49.991309325882405,0.5554589925098045,VL1_6 ## VL3_4 ## ,"" +0,L2,L1,1,39.270438968568286,4.051466896314793,101.30284664189512,1.1255871849099457,VL1_2 ## VL2_3 ## ,VL1_4 ## VL2_5 ## +0,L2,L3,1,39.33816630600753,2.8857047097261557,101.21300913443238,1.124588990382582,VL1_6 ## VL3_4 ## ,VL1_4 ## VL2_5 ## +0,L3,L1,1,34.38677379330327,3.0225805794927396,88.5767187043908,0.98418576338212,VL1_2 ## VL2_3 ## ,VL1_6 ## VL3_4 ## +0,L3,L2,1,34.38677379330327,3.0225805794927396,88.5767187043908,0.98418576338212,VL1_4 ## VL2_5 ## ,VL1_6 ## VL3_4 ## +0,L4,L1,1,25.920807689745196,2.7328503720490005,66.88146839335323,0.7431274265928137,VL1_2 ## VL2_3 ## ,VL1_8 ## VL4_4 ## +0,L4,L2,1,25.920807689745196,2.7328503720490005,66.88146839335323,0.7431274265928137,VL1_4 ## VL2_5 ## ,VL1_8 ## VL4_4 ## +0,L4,L3,1,38.94570674216824,2.929614454693308,100.21707187226625,1.1135230208029583,VL1_6 ## VL3_4 ## ,VL1_8 ## VL4_4 ## +0,generator3,L1,1,55.32925526004382,8.77265739544697,143.74843149839376,1.5972047944265972,VL1_2 ## VL2_3 ## ,"" +0,generator3,L2,1,55.32925526004382,8.77265739544697,143.74843149839376,1.5972047944265972,VL1_4 ## VL2_5 ## ,"" +0,generator3,L3,1,83.26332957256895,10.655787910880576,215.39631945855612,2.3932924384284013,VL1_6 ## VL3_4 ## ,"" +0,L5,L1,1,25.920807689745196,2.7328503720490005,66.88146839335323,0.7431274265928137,VL1_2 ## VL2_3 ## ,VL1_10 ## VL4_6 ## +0,L5,L2,1,25.920807689745196,2.7328503720490005,66.88146839335323,0.7431274265928137,VL1_4 ## VL2_5 ## ,VL1_10 ## VL4_6 ## +0,L5,L3,1,38.94570674216824,2.929614454693308,100.21707187226625,1.1135230208029583,VL1_6 ## VL3_4 ## ,VL1_10 ## VL4_6 ## +0,load2,L1,1,12.761821550167141,0.9025890201542097,32.828649158137345,0.3647627684237483,VL1_2 ## VL2_3 ## ,"" +0,load2,L2,1,12.761821550167141,0.9025890201542097,32.828649158137345,0.3647627684237483,VL1_4 ## VL2_5 ## ,"" +0,load2,L3,1,19.15460435308123,0.7790085238216449,49.19136879045235,0.5465707643383595,VL1_6 ## VL3_4 ## ,"" +0,L6,L1,1,14.267278361652258,1.7925437303588865,36.897673641556494,0.40997415157284994,VL1_2 ## VL2_3 ## ,VL2_7 ## VL3_6 ## +0,L6,L2,1,14.267278361652258,1.7925437303588865,36.897673641556494,0.40997415157284994,VL1_4 ## VL2_5 ## ,VL2_7 ## VL3_6 ## +0,L6,L3,1,21.44935945481754,2.0443661301587923,55.288511008361574,0.6143167889817953,VL1_6 ## VL3_4 ## ,VL2_7 ## VL3_6 ## +0,L7,L1,1,-1.655942050149406e-7,-6.276081876421813e-7,1.665557117939689e-6,1.850619019932988e-8,VL1_2 ## VL2_3 ## ,VL2_9 ## VL3_8 ## +0,L7,L2,1,-1.655942050149406e-7,-6.276081876421813e-7,1.665557117939689e-6,1.850619019932988e-8,VL1_4 ## VL2_5 ## ,VL2_9 ## VL3_8 ## +0,L7,L3,1,61.1949960491749,3.6764942612595775,157.30956404489243,1.7478840449432491,VL1_6 ## VL3_4 ## ,VL2_9 ## VL3_8 ## +0,L8,L1,1,42.868273813250354,4.329388500988074,110.5595943284707,1.2284399369830077,VL1_2 ## VL2_3 ## ,VL2_11 ## VL4_8 ## +0,L8,L2,1,42.868273813250354,4.329388500988074,110.5595943284707,1.2284399369830077,VL1_4 ## VL2_5 ## ,VL2_11 ## VL4_8 ## +0,L8,L3,1,64.40052895492168,4.560110947236495,165.66559090541378,1.8407287878379308,VL1_6 ## VL3_4 ## ,VL2_11 ## VL4_8 ## +0,load1,L1,1,21.272108118463617,1.4746961934413605,54.71526371499266,0.6079473746110295,VL1_2 ## VL2_3 ## ,"" +0,load1,L2,1,21.272108118463617,1.4746961934413605,54.71526371499266,0.6079473746110295,VL1_4 ## VL2_5 ## ,"" +0,load1,L3,1,31.926614823201483,1.2538768243345544,81.98688808788444,0.910965423198716,VL1_6 ## VL3_4 ## ,"" +0,BASECASE,L1,2,-23.395145224141654,-2.4002484335793044,60.38196197681373,NaN,VL1_2 ## VL2_3 ## ,"" +0,BASECASE,L2,2,-23.395145224141654,-2.4002484335793044,60.38196197681373,NaN,VL1_4 ## VL2_5 ## ,"" +0,BASECASE,L3,2,-35.13157930564553,-2.217378466722371,90.47802880220793,NaN,VL1_6 ## VL3_4 ## ,"" +0,L1,L2,2,-39.26428160852504,-3.743598894182503,101.30284664189493,NaN,VL1_4 ## VL2_5 ## ,VL1_2 ## VL2_3 ## +0,L1,L3,2,-39.307434086353396,-2.2710603166434975,101.21300913443247,NaN,VL1_6 ## VL3_4 ## ,VL1_2 ## VL2_3 ## +0,load6,L1,2,-12.739880765032874,-2.559652633815988,33.36250155868797,NaN,VL1_2 ## VL2_3 ## ,"" +0,load6,L2,2,-12.739880765032874,-2.559652633815988,33.36250155868797,NaN,VL1_4 ## VL2_5 ## ,"" +0,load6,L3,2,-19.19112641686121,-3.1619044405869023,49.99130932588256,NaN,VL1_6 ## VL3_4 ## ,"" +0,L2,L1,2,-39.26428160852504,-3.743598894182503,101.30284664189493,NaN,VL1_2 ## VL2_3 ## ,VL1_4 ## VL2_5 ## +0,L2,L3,2,-39.307434086353396,-2.2710603166434975,101.21300913443247,NaN,VL1_6 ## VL3_4 ## ,VL1_4 ## VL2_5 ## +0,L3,L1,2,-34.38206629224479,-2.7872055265999354,88.57671870439097,NaN,VL1_2 ## VL2_3 ## ,VL1_6 ## VL3_4 ## +0,L3,L2,2,-34.38206629224479,-2.7872055265999354,88.57671870439097,NaN,VL1_4 ## VL2_5 ## ,VL1_6 ## VL3_4 ## +0,L4,L1,2,-25.918123811255906,-2.5986564476156038,66.88146839335343,NaN,VL1_2 ## VL2_3 ## ,VL1_8 ## VL4_4 ## +0,L4,L2,2,-25.918123811255906,-2.5986564476156038,66.88146839335343,NaN,VL1_4 ## VL2_5 ## ,VL1_8 ## VL4_4 ## +0,L4,L3,2,-38.91557635768431,-2.3270067650141764,100.2170718722663,NaN,VL1_6 ## VL3_4 ## ,VL1_8 ## VL4_4 ## +0,generator3,L1,2,-55.316857093108275,-8.15274904869914,143.74843149839347,NaN,VL1_2 ## VL2_3 ## ,"" +0,generator3,L2,2,-55.316857093108275,-8.15274904869914,143.74843149839347,NaN,VL1_4 ## VL2_5 ## ,"" +0,generator3,L3,2,-83.12414284926014,-7.872053444702995,215.39631945855612,NaN,VL1_6 ## VL3_4 ## ,"" +0,L5,L1,2,-25.918123811255906,-2.5986564476156038,66.88146839335343,NaN,VL1_2 ## VL2_3 ## ,VL1_10 ## VL4_6 ## +0,L5,L2,2,-25.918123811255906,-2.5986564476156038,66.88146839335343,NaN,VL1_4 ## VL2_5 ## ,VL1_10 ## VL4_6 ## +0,L5,L3,2,-38.91557635768431,-2.3270067650141764,100.2170718722663,NaN,VL1_6 ## VL3_4 ## ,VL1_10 ## VL4_6 ## +0,load2,L1,2,-12.761174918043187,-0.87025741398682,32.828649158137345,NaN,VL1_2 ## VL2_3 ## ,"" +0,load2,L2,2,-12.761174918043187,-0.87025741398682,32.828649158137345,NaN,VL1_4 ## VL2_5 ## ,"" +0,load2,L3,2,-19.147344980790834,-0.6338210780130232,49.19136879045241,NaN,VL1_6 ## VL3_4 ## ,"" +0,L6,L1,2,-14.266461498659503,-1.7517005807534087,36.897673641556494,NaN,VL1_2 ## VL2_3 ## ,VL2_7 ## VL3_6 ## +0,L6,L2,2,-14.266461498659503,-1.7517005807534087,36.897673641556494,NaN,VL1_4 ## VL2_5 ## ,VL2_7 ## VL3_6 ## +0,L6,L3,2,-21.440188996469114,-1.8609569631878848,55.28851100836166,NaN,VL1_6 ## VL3_4 ## ,VL2_7 ## VL3_6 ## +0,L7,L1,2,1.65594205035578e-7,6.276088982631336e-7,1.6655571324755098e-6,NaN,VL1_2 ## VL2_3 ## ,VL2_9 ## VL3_8 ## +0,L7,L2,2,1.65594205035578e-7,6.276088982631336e-7,1.6655571324755098e-6,NaN,VL1_4 ## VL2_5 ## ,VL2_9 ## VL3_8 ## +0,L7,L3,2,-61.12075715235486,-2.1917163248596228,157.30956404489243,NaN,VL1_6 ## VL3_4 ## ,VL2_9 ## VL3_8 ## +0,L8,L1,2,-42.86093975891092,-3.9626857840457643,110.5595943284707,NaN,VL1_2 ## VL2_3 ## ,VL2_11 ## VL4_8 ## +0,L8,L2,2,-42.86093975891092,-3.9626857840457643,110.5595943284707,NaN,VL1_4 ## VL2_5 ## ,VL2_11 ## VL4_8 ## +0,L8,L3,2,-64.31819369089166,-2.913405666634582,165.66559090541375,NaN,VL1_6 ## VL3_4 ## ,VL2_11 ## VL4_8 ## +0,load1,L1,2,-21.270311862413,-1.38488339093929,54.71526371499254,NaN,VL1_2 ## VL2_3 ## ,"" +0,load1,L2,2,-21.270311862413,-1.38488339093929,54.71526371499254,NaN,VL1_4 ## VL2_5 ## ,"" +0,load1,L3,2,-31.906449273746478,-0.8505658352350752,81.98688808788441,NaN,VL1_6 ## VL3_4 ## ,"" diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_metrics.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_metrics.json new file mode 100644 index 000000000..a5edca999 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/ac_metrics.json @@ -0,0 +1,40 @@ +{ + "fitness": 89.84894757967612, + "extra_scores": { + "max_flow_n_1": 2.3932924384284013, + "overload_energy_n_1": 89.84894757967612, + "max_va_diff_n_1": 1.3844259249571096, + "overload_current_n_1": 232.89318245534363, + "critical_branch_count_n_1": 3.0, + "max_flow_n_0": 1.0053114311356433, + "overload_energy_n_0": 0.1857428463181492, + "max_va_diff_n_0": -0.8918558568711066, + "overload_current_n_0": 0.4780288022078878, + "critical_branch_count_n_0": 1.0, + "split_subs": 1.0, + "disconnected_branches": 0.0, + "non_converging_loadflows": 0.0, + "switching_distance": 0.0 + }, + "worst_k_contingency_cases": null, + "dc_info": { + "actions": [ + 5 + ], + "disconnections": [], + "pst_setpoints": null, + "metrics": { + "fitness": -88.39493471678644, + "extra_scores": { + "overload_energy_n_1": 88.39493471678644, + "split_subs": 1.0, + "top_k_overloads_n_1": 133.17284815697712 + }, + "worst_k_contingency_cases": [ + "generator3", + "L8" + ] + }, + "loadflow_results": null + } +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/dc_loadflow_results.csv b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/dc_loadflow_results.csv new file mode 100644 index 000000000..85ca9a01b --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/dc_loadflow_results.csv @@ -0,0 +1,73 @@ +timestep,contingency,element,side,p,q,i,loading,element_name,contingency_name +0,BASECASE,L1,1,23.404255319148934,NaN,60.0553471497483,0.6672816349972034,VL1_2 ## VL2_3 ## ,"" +0,BASECASE,L2,1,23.404255319148934,NaN,60.0553471497483,0.6672816349972034,VL1_4 ## VL2_5 ## ,"" +0,BASECASE,L3,1,35.1063829787234,NaN,90.08302072462246,1.0009224524958051,VL1_6 ## VL3_4 ## ,"" +0,L1,L2,1,39.285714285714285,NaN,100.80718985850609,1.1200798873167344,VL1_4 ## VL2_5 ## ,VL1_2 ## VL2_3 ## +0,L1,L3,1,39.285714285714285,NaN,100.80718985850609,1.1200798873167344,VL1_6 ## VL3_4 ## ,VL1_2 ## VL2_3 ## +0,load6,L1,1,2.127659574468084,NaN,5.45957701361348,0.06066196681792755,VL1_2 ## VL2_3 ## ,"" +0,load6,L2,1,2.127659574468084,NaN,5.45957701361348,0.06066196681792755,VL1_4 ## VL2_5 ## ,"" +0,load6,L3,1,3.191489361702126,NaN,8.18936552042022,0.09099295022689133,VL1_6 ## VL3_4 ## ,"" +0,L2,L1,1,39.285714285714285,NaN,100.80718985850609,1.1200798873167344,VL1_2 ## VL2_3 ## ,VL1_4 ## VL2_5 ## +0,L2,L3,1,39.285714285714285,NaN,100.80718985850609,1.1200798873167344,VL1_6 ## VL3_4 ## ,VL1_4 ## VL2_5 ## +0,L3,L1,1,34.374999999999986,NaN,88.2062911261928,0.9800699014021421,VL1_2 ## VL2_3 ## ,VL1_6 ## VL3_4 ## +0,L3,L2,1,34.374999999999986,NaN,88.2062911261928,0.9800699014021421,VL1_4 ## VL2_5 ## ,VL1_6 ## VL3_4 ## +0,L4,L1,1,25.925925925925924,NaN,66.52595694366057,0.7391772993740063,VL1_2 ## VL2_3 ## ,VL1_8 ## VL4_4 ## +0,L4,L2,1,25.925925925925924,NaN,66.52595694366057,0.7391772993740063,VL1_4 ## VL2_5 ## ,VL1_8 ## VL4_4 ## +0,L4,L3,1,38.888888888888886,NaN,99.78893541549087,1.1087659490610096,VL1_6 ## VL3_4 ## ,VL1_8 ## VL4_4 ## +0,generator3,L1,1,55.3191489361702,NaN,141.94900235395053,1.577211137266117,VL1_2 ## VL2_3 ## ,"" +0,generator3,L2,1,55.3191489361702,NaN,141.94900235395053,1.577211137266117,VL1_4 ## VL2_5 ## ,"" +0,generator3,L3,1,82.97872340425532,NaN,212.9235035309258,2.3658167058991757,VL1_6 ## VL3_4 ## ,"" +0,L5,L1,1,25.925925925925924,NaN,66.52595694366057,0.7391772993740063,VL1_2 ## VL2_3 ## ,VL1_10 ## VL4_6 ## +0,L5,L2,1,25.925925925925924,NaN,66.52595694366057,0.7391772993740063,VL1_4 ## VL2_5 ## ,VL1_10 ## VL4_6 ## +0,L5,L3,1,38.888888888888886,NaN,99.78893541549087,1.1087659490610096,VL1_6 ## VL3_4 ## ,VL1_10 ## VL4_6 ## +0,load2,L1,1,2.127659574468084,NaN,5.45957701361348,0.06066196681792755,VL1_2 ## VL2_3 ## ,"" +0,load2,L2,1,2.127659574468084,NaN,5.45957701361348,0.06066196681792755,VL1_4 ## VL2_5 ## ,"" +0,load2,L3,1,3.191489361702126,NaN,8.18936552042022,0.09099295022689133,VL1_6 ## VL3_4 ## ,"" +0,L6,L1,1,14.285714285714288,NaN,36.65715994854767,0.40730177720608524,VL1_2 ## VL2_3 ## ,VL2_7 ## VL3_6 ## +0,L6,L2,1,14.285714285714288,NaN,36.65715994854767,0.40730177720608524,VL1_4 ## VL2_5 ## ,VL2_7 ## VL3_6 ## +0,L6,L3,1,21.428571428571434,NaN,54.98573992282152,0.610952665809128,VL1_6 ## VL3_4 ## ,VL2_7 ## VL3_6 ## +0,L7,L1,1,-0.0,NaN,0.0,0.0,VL1_2 ## VL2_3 ## ,VL2_9 ## VL3_8 ## +0,L7,L2,1,-0.0,NaN,0.0,0.0,VL1_4 ## VL2_5 ## ,VL2_9 ## VL3_8 ## +0,L7,L3,1,61.111111111111114,NaN,156.8111842243428,1.7423464913815867,VL1_6 ## VL3_4 ## ,VL2_9 ## VL3_8 ## +0,L8,L1,1,42.85714285714285,NaN,109.97147984564297,1.2219053316182553,VL1_2 ## VL2_3 ## ,VL2_11 ## VL4_8 ## +0,L8,L2,1,42.85714285714285,NaN,109.97147984564297,1.2219053316182553,VL1_4 ## VL2_5 ## ,VL2_11 ## VL4_8 ## +0,L8,L3,1,64.28571428571428,NaN,164.95721976846448,1.832857997427383,VL1_6 ## VL3_4 ## ,VL2_11 ## VL4_8 ## +0,load1,L1,1,10.638297872340424,NaN,27.29788506806741,0.30330983408963785,VL1_2 ## VL2_3 ## ,"" +0,load1,L2,1,10.638297872340424,NaN,27.29788506806741,0.30330983408963785,VL1_4 ## VL2_5 ## ,"" +0,load1,L3,1,15.957446808510637,NaN,40.946827602101116,0.45496475113445684,VL1_6 ## VL3_4 ## ,"" +0,BASECASE,L1,2,-23.404255319148934,NaN,60.0553471497483,NaN,VL1_2 ## VL2_3 ## ,"" +0,BASECASE,L2,2,-23.404255319148934,NaN,60.0553471497483,NaN,VL1_4 ## VL2_5 ## ,"" +0,BASECASE,L3,2,-35.1063829787234,NaN,90.08302072462246,NaN,VL1_6 ## VL3_4 ## ,"" +0,L1,L2,2,-39.285714285714285,NaN,100.80718985850609,NaN,VL1_4 ## VL2_5 ## ,VL1_2 ## VL2_3 ## +0,L1,L3,2,-39.285714285714285,NaN,100.80718985850609,NaN,VL1_6 ## VL3_4 ## ,VL1_2 ## VL2_3 ## +0,load6,L1,2,-2.127659574468084,NaN,5.45957701361348,NaN,VL1_2 ## VL2_3 ## ,"" +0,load6,L2,2,-2.127659574468084,NaN,5.45957701361348,NaN,VL1_4 ## VL2_5 ## ,"" +0,load6,L3,2,-3.191489361702126,NaN,8.18936552042022,NaN,VL1_6 ## VL3_4 ## ,"" +0,L2,L1,2,-39.285714285714285,NaN,100.80718985850609,NaN,VL1_2 ## VL2_3 ## ,VL1_4 ## VL2_5 ## +0,L2,L3,2,-39.285714285714285,NaN,100.80718985850609,NaN,VL1_6 ## VL3_4 ## ,VL1_4 ## VL2_5 ## +0,L3,L1,2,-34.374999999999986,NaN,88.2062911261928,NaN,VL1_2 ## VL2_3 ## ,VL1_6 ## VL3_4 ## +0,L3,L2,2,-34.374999999999986,NaN,88.2062911261928,NaN,VL1_4 ## VL2_5 ## ,VL1_6 ## VL3_4 ## +0,L4,L1,2,-25.925925925925924,NaN,66.52595694366057,NaN,VL1_2 ## VL2_3 ## ,VL1_8 ## VL4_4 ## +0,L4,L2,2,-25.925925925925924,NaN,66.52595694366057,NaN,VL1_4 ## VL2_5 ## ,VL1_8 ## VL4_4 ## +0,L4,L3,2,-38.888888888888886,NaN,99.78893541549087,NaN,VL1_6 ## VL3_4 ## ,VL1_8 ## VL4_4 ## +0,generator3,L1,2,-55.3191489361702,NaN,141.94900235395053,NaN,VL1_2 ## VL2_3 ## ,"" +0,generator3,L2,2,-55.3191489361702,NaN,141.94900235395053,NaN,VL1_4 ## VL2_5 ## ,"" +0,generator3,L3,2,-82.97872340425532,NaN,212.9235035309258,NaN,VL1_6 ## VL3_4 ## ,"" +0,L5,L1,2,-25.925925925925924,NaN,66.52595694366057,NaN,VL1_2 ## VL2_3 ## ,VL1_10 ## VL4_6 ## +0,L5,L2,2,-25.925925925925924,NaN,66.52595694366057,NaN,VL1_4 ## VL2_5 ## ,VL1_10 ## VL4_6 ## +0,L5,L3,2,-38.888888888888886,NaN,99.78893541549087,NaN,VL1_6 ## VL3_4 ## ,VL1_10 ## VL4_6 ## +0,load2,L1,2,-2.127659574468084,NaN,5.45957701361348,NaN,VL1_2 ## VL2_3 ## ,"" +0,load2,L2,2,-2.127659574468084,NaN,5.45957701361348,NaN,VL1_4 ## VL2_5 ## ,"" +0,load2,L3,2,-3.191489361702126,NaN,8.18936552042022,NaN,VL1_6 ## VL3_4 ## ,"" +0,L6,L1,2,-14.285714285714288,NaN,36.65715994854767,NaN,VL1_2 ## VL2_3 ## ,VL2_7 ## VL3_6 ## +0,L6,L2,2,-14.285714285714288,NaN,36.65715994854767,NaN,VL1_4 ## VL2_5 ## ,VL2_7 ## VL3_6 ## +0,L6,L3,2,-21.428571428571434,NaN,54.98573992282152,NaN,VL1_6 ## VL3_4 ## ,VL2_7 ## VL3_6 ## +0,L7,L1,2,0.0,NaN,0.0,NaN,VL1_2 ## VL2_3 ## ,VL2_9 ## VL3_8 ## +0,L7,L2,2,0.0,NaN,0.0,NaN,VL1_4 ## VL2_5 ## ,VL2_9 ## VL3_8 ## +0,L7,L3,2,-61.111111111111114,NaN,156.8111842243428,NaN,VL1_6 ## VL3_4 ## ,VL2_9 ## VL3_8 ## +0,L8,L1,2,-42.85714285714285,NaN,109.97147984564297,NaN,VL1_2 ## VL2_3 ## ,VL2_11 ## VL4_8 ## +0,L8,L2,2,-42.85714285714285,NaN,109.97147984564297,NaN,VL1_4 ## VL2_5 ## ,VL2_11 ## VL4_8 ## +0,L8,L3,2,-64.28571428571428,NaN,164.95721976846448,NaN,VL1_6 ## VL3_4 ## ,VL2_11 ## VL4_8 ## +0,load1,L1,2,-10.638297872340424,NaN,27.29788506806741,NaN,VL1_2 ## VL2_3 ## ,"" +0,load1,L2,2,-10.638297872340424,NaN,27.29788506806741,NaN,VL1_4 ## VL2_5 ## ,"" +0,load1,L3,2,-15.957446808510637,NaN,40.946827602101116,NaN,VL1_6 ## VL3_4 ## ,"" diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/modified_network.xiidm b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/modified_network.xiidm new file mode 100644 index 000000000..b73091602 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/modified_network.xiidm @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/orao_summary.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/orao_summary.json new file mode 100644 index 000000000..692426e90 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/orao_summary.json @@ -0,0 +1 @@ +{"forced-actions": {"preventive-actions-list": {"version": "1.2", "actions": [{"type": "SWITCH", "id": "Open VL2_BREAKER", "switchId": "VL2_BREAKER", "open": true}, {"type": "SWITCH", "id": "Open L12_DISCONNECTOR_4_0", "switchId": "L12_DISCONNECTOR_4_0", "open": true}, {"type": "SWITCH", "id": "Close L12_DISCONNECTOR_4_2", "switchId": "L12_DISCONNECTOR_4_2", "open": false}]}}} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/sld/VLevel2_sld.svg b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/sld/VLevel2_sld.svg new file mode 100644 index 000000000..dfc8b6140 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/topology_0/sld/VLevel2_sld.svg @@ -0,0 +1,647 @@ + + + + + + + + + bus1 + + + + bus2 + + + + bus3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -27 + + + + + -2 + + + + + 69 + + + + + + + + + + + + + + + + + L1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -27 + + + + + -2 + + + + + 69 + + + + + + + + + + + + + + + + + L2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -28 + + + + + -4 + + + + + 72 + + + + + + + + + + + + + + + + + L6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 54 + + + + + 5 + + + + + 139 + + + + + + + + + + + + + + + + + L7 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -72 + + + + + -6 + + + + + 187 + + + + + + + + + + + + + + + + + L8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 + + + + + 10 + + + + + 259 + + + + + + + + + + + + + + + + + + + + load1 + + + + + + + + + + + + + + 224.4 kV + -1.2° + 0 MW + 0 MVar + + + + 224.9 kV + -0.3° + 0 MW + 0 MVar + + + + + diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/unsplit_ac_metrics.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/unsplit_ac_metrics.json new file mode 100644 index 000000000..1b9da01d3 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/optimizer_snapshot/run_0/unsplit_ac_metrics.json @@ -0,0 +1,28 @@ +{ + "fitness": 139.52255150807292, + "extra_scores": { + "max_flow_n_1": 2.442972284125944, + "overload_energy_n_1": 139.52255150807292, + "max_va_diff_n_1": 0.9196808487469375, + "overload_current_n_1": 361.43814087357083, + "critical_branch_count_n_1": 3.0, + "max_flow_n_0": 1.3093068610204894, + "overload_energy_n_0": 21.5672909386536, + "max_va_diff_n_0": 0.0, + "overload_current_n_0": 55.675234983688085, + "critical_branch_count_n_0": 2.0, + "split_subs": 0.0, + "disconnected_branches": 0.0, + "non_converging_loadflows": 0.0 + }, + "worst_k_contingency_cases": null, + "dc_info": { + "actions": [], + "disconnections": [], + "fitness": -138.25617430975592, + "metrics": { + "overload_energy_n_1": 138.25617430975592, + "split_subs": 0.0 + } + } +} diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.hdf5 b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.hdf5 index df6926229..5557c66bc 100644 Binary files a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.hdf5 and b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.hdf5 differ diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.zip b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.zip deleted file mode 100644 index 00359203c..000000000 Binary files a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.zip and /dev/null differ diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information_stats.json b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information_stats.json new file mode 100644 index 000000000..9276f1449 --- /dev/null +++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information_stats.json @@ -0,0 +1,27 @@ +{ + "time": "2025-05-16 15:23:18.106000+00:00", + "fp_dtype": "float64", + "has_double_limits": true, + "n_branches": 8, + "n_nodes": 8, + "n_branch_outages": 8, + "n_multi_outages": 0, + "n_injection_outages": 4, + "n_busbar_outages": 0, + "n_nminus1_cases": 12, + "n_controllable_psts": 0, + "n_monitored_branches": 3, + "n_timesteps": 1, + "n_relevant_subs": 1, + "n_disc_branches": 8, + "overload_energy_n0": 21.156290119547435, + "overload_energy_n1": 138.25617430975592, + "n_actions": 10, + "max_station_branch_degree": 5, + "max_station_injection_degree": 1, + "mean_station_branch_degree": 5.0, + "mean_station_injection_degree": 1.0, + "reassignable_branch_assets": 5, + "reassignable_injection_assets": 1, + "max_reassignment_distance": 4 +} diff --git a/packages/dc_solver_pkg/tests/jax/benchmarks/test_bench_postprocessing.py b/packages/dc_solver_pkg/tests/jax/benchmarks/test_bench_postprocessing.py index 55358639d..edd4284c9 100644 --- a/packages/dc_solver_pkg/tests/jax/benchmarks/test_bench_postprocessing.py +++ b/packages/dc_solver_pkg/tests/jax/benchmarks/test_bench_postprocessing.py @@ -10,10 +10,8 @@ from tempfile import TemporaryDirectory import pytest -from beartype.typing import cast from toop_engine_dc_solver.jax.benchmarks.bench_postprocessing import ( Args, - LoadflowType, main, run_benchmark, setup_benchmark, @@ -34,7 +32,7 @@ def test_benchmark(preprocessed_data_folder: Path, method: str, init_ray) -> Non framework="pandapower", ) - n_loadflows, n_success, time = run_benchmark(runner, topologies, method=cast(LoadflowType, method)) + n_loadflows, n_success, time = run_benchmark(runner, topologies, method=method) nminus1_definition = load_nminus1_definition( preprocessed_data_folder / PREPROCESSING_PATHS["nminus1_definition_file_path"] diff --git a/packages/dc_solver_pkg/tests/jax/test_busbar_outage.py b/packages/dc_solver_pkg/tests/jax/test_busbar_outage.py index 85a5c7f40..1a31ddc31 100644 --- a/packages/dc_solver_pkg/tests/jax/test_busbar_outage.py +++ b/packages/dc_solver_pkg/tests/jax/test_busbar_outage.py @@ -15,7 +15,6 @@ import pypowsybl as pp import pytest from jaxtyping import Array, Bool, Float, Int -from tests.deprecated.assignment import realise_bus_split_single_station from toop_engine_dc_solver.jax.bsdf import compute_bus_splits from toop_engine_dc_solver.jax.busbar_outage import ( filter_already_outaged_branches_single_outage, @@ -46,20 +45,18 @@ ) from toop_engine_dc_solver.postprocess.postprocess_powsybl import apply_topology from toop_engine_dc_solver.preprocess.convert_to_jax import convert_to_jax, get_bb_outage_baseline_analysis -from toop_engine_dc_solver.preprocess.network_data import extract_action_set, map_branch_injection_ids +from toop_engine_dc_solver.preprocess.network_data import extract_action_set from toop_engine_dc_solver.preprocess.preprocess import NetworkData from toop_engine_dc_solver.preprocess.preprocess_bb_outage import ( extract_busbar_outage_data, get_busbar_branches_map, get_busbar_index, - get_connected_assets, get_rel_non_rel_sub_bb_maps, - get_relevant_stations, get_total_injection_along_stub_branch, logger, preprocess_bb_outages, ) -from toop_engine_interfaces.asset_topology import Station +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation # FIxme: Look deeply into this @@ -104,11 +101,11 @@ def test_perform_outage_single_busbar( connected_branches_data = {} for node_index in node_index_busbars: station_id = network_data_preprocessed.node_ids[node_index] - for station in network_data_preprocessed.asset_topology.stations: + for station in network_data_preprocessed.asset_topology.materialize_stations(): if station_id == station.grid_model_id: for index, busbar in enumerate(station.busbars): - connected_assets = get_connected_assets(station, index) - connected_branches = [asset.grid_model_id for asset in connected_assets if asset.is_branch()] + connected_branch_assets = station.get_connected_assets(index, asset_scope="branch") + connected_branches = [asset.grid_model_id for asset in connected_branch_assets] connected_branches = [ network_data_preprocessed.branch_ids.index(branch_id) for branch_id in connected_branches ] @@ -118,7 +115,7 @@ def test_perform_outage_single_busbar( # bubsars_to_be_outaged = [busbar for busbars in list(non_rel_station_busbars_map.values()) for busbar in busbars] sorted_busbars_to_be_outaged = [] - for station in network_data_preprocessed.asset_topology.stations: + for station in network_data_preprocessed.asset_topology.materialize_stations(): if station.grid_model_id in non_rel_station_busbars_map: sorted_busbars_to_be_outaged += non_rel_station_busbars_map[station.grid_model_id] @@ -200,11 +197,11 @@ def test_perform_outage_single_busbar_with_disconnections( connected_branches_data = {} for node_index in node_index_busbars: station_id = network_data_preprocessed.node_ids[node_index] - for station in network_data_preprocessed.asset_topology.stations: + for station in network_data_preprocessed.asset_topology.materialize_stations(): if station_id == station.grid_model_id: for index, busbar in enumerate(station.busbars): - connected_assets = get_connected_assets(station, index) - connected_branches = [asset.grid_model_id for asset in connected_assets if asset.is_branch()] + connected_branch_assets = station.get_connected_assets(index, asset_scope="branch") + connected_branches = [asset.grid_model_id for asset in connected_branch_assets] connected_branches = [ network_data_preprocessed.branch_ids.index(branch_id) for branch_id in connected_branches ] @@ -214,7 +211,7 @@ def test_perform_outage_single_busbar_with_disconnections( # bubsars_to_be_outaged = [busbar for busbars in list(non_rel_station_busbars_map.values()) for busbar in busbars] sorted_busbars_to_be_outaged = [] - for station in network_data_preprocessed.asset_topology.stations: + for station in network_data_preprocessed.asset_topology.materialize_stations(): if station.grid_model_id in non_rel_station_busbars_map: sorted_busbars_to_be_outaged += non_rel_station_busbars_map[station.grid_model_id] @@ -331,47 +328,47 @@ def test_perform_rel_bb_outage_single_topo_with_no_inj_reassignments( connected_branches_flow = rel_busbar_branches_flows[outage_index] assert jnp.allclose(abs(connected_branches_flow.sum(axis=1)), abs(power_at_node), atol=1e-03), ( - "Kirchoff's current law should be satisfied" + "Kirchoff's current law should be satisfied: " + f"outage_index={outage_index}, nodal_index_bb={nodal_index_bb}, " + f"sumflow={abs(connected_branches_flow.sum(axis=1))}, power_at_node={abs(power_at_node)}, " + f"connected_branches_flow={connected_branches_flow}" ) validate_zero_flows(lfs, success, busbar_data["busbar_br_outage_map"]) -def get_busbar_injection_map(station: Station, network: NetworkData) -> dict[str, Float[np.ndarray, " n_timestep"]]: +def get_busbar_injection_map( + station: MaterializedStation, network: NetworkData +) -> dict[str, Float[np.ndarray, " n_timestep"]]: busbar_injection_map = {} + topology = network.simplified_asset_topology or network.asset_topology + topology_assets = [*topology.branch_assets, *topology.injection_assets] for i, bb in enumerate(station.busbars): - connected_assets = get_connected_assets(station, i) + connected_injection_assets = station.get_connected_assets( + i, topology_assets=topology_assets, asset_scope="injection" + ) injection: Float[np.ndarray, " n_timestep"] = np.zeros(network.nodal_injection.shape[0], float) - for asset in connected_assets: - if not asset.is_branch() and asset.in_service: - if asset.grid_model_id not in network.injection_ids: - logger.warning(f"Asset {asset.grid_model_id} is not a valid injection. Might have been removed.") - continue - injection_index = network.injection_ids.index(asset.grid_model_id) - injection += network.mw_injections[:, injection_index] + for asset in connected_injection_assets: + if asset.grid_model_id not in network.injection_ids: + logger.warning(f"Asset {asset.grid_model_id} is not a valid injection. Might have been removed.") + continue + injection_index = network.injection_ids.index(asset.grid_model_id) + injection += network.mw_injections[:, injection_index] busbar_injection_map[bb.grid_model_id] = injection return busbar_injection_map def extract_busbar_metadata_after_split(network_data_preprocessed, static_information: StaticInformation, branch_actions): - rel_stations = get_relevant_stations(network_data_preprocessed) - branch_ids_mapped, _ = map_branch_injection_ids(network_data_preprocessed) + assert network_data_preprocessed.realised_stations is not None busbar_branches_map = {} busbar_deltap_map = {} busbar_br_outage_map = {} busbar_injection_map = {} - for sub_index in range(len(rel_stations)): + for sub_index in range(len(network_data_preprocessed.realised_stations)): branch_action = branch_actions[sub_index] - modified_station_br, _, _ = realise_bus_split_single_station( - branch_ids_local=branch_ids_mapped[sub_index], - branch_topology_local=branch_action[: static_information.solver_config.branches_per_sub.val[sub_index]], - injection_ids_local=[], - injection_topology_local=np.array([], dtype=bool), - station=rel_stations[sub_index], - ) branch_action_combi_index = np.argmax( np.all( network_data_preprocessed.branch_action_set[sub_index] @@ -379,6 +376,7 @@ def extract_busbar_metadata_after_split(network_data_preprocessed, static_inform axis=1, ) ) + modified_station_br = network_data_preprocessed.realised_stations[sub_index][branch_action_combi_index] for bb in modified_station_br.busbars: outage_data = extract_busbar_outage_data( modified_station_br, bb.grid_model_id, network_data_preprocessed, {}, branch_action_combi_index @@ -464,8 +462,9 @@ def correct_power_flow_directions(lfs, network_data_preprocessed: NetworkData): branches_at_nodes = network_data_preprocessed.branches_at_nodes lfs_corrrected_directions = [] for lfs_instance in lfs: + power_flow_line = lfs_instance for local_branches, local_branches_dir in zip(branches_at_nodes, branch_dir): - power_flow_line = lfs_instance.at[:, local_branches].multiply(np.where(local_branches_dir == 1, -1, 1)) + power_flow_line = power_flow_line.at[:, local_branches].multiply(np.where(local_branches_dir == 1, -1, 1)) lfs_corrrected_directions.append(power_flow_line) return lfs_corrrected_directions @@ -499,7 +498,7 @@ def test_compare_loadflows_non_rel_bb_outage_powsybl( branches_monitored=jnp.arange(dynamic_information.ptdf.shape[0]), ) lfs_index = 0 - for station in asset_topology.stations: + for station in asset_topology.materialize_stations(): if station.grid_model_id not in non_rel_bb_outage_map: continue for bb in station.busbars: @@ -507,10 +506,11 @@ def test_compare_loadflows_non_rel_bb_outage_powsybl( if bb.grid_model_id not in non_rel_bb_outage_map[station.grid_model_id]: continue bb_index = get_busbar_index(station, bb.grid_model_id) - connected_assets = get_connected_assets(station, bb_index) + connected_assets = station.get_connected_assets(bb_index) + connected_branch_assets = station.get_connected_assets(bb_index, asset_scope="branch") connected_asset_ids = [asset.grid_model_id for asset in connected_assets] connected_branch_indices = [ - network_data.branch_ids.index(asset.grid_model_id) for asset in connected_assets if asset.is_branch() + network_data.branch_ids.index(asset.grid_model_id) for asset in connected_branch_assets ] copy_net.remove_elements(connected_asset_ids) config = pp.loadflow.Parameters( @@ -520,11 +520,10 @@ def test_compare_loadflows_non_rel_bb_outage_powsybl( # Get the stub branches connected to this busbar stub_branch_indices = [] - for asset in connected_assets: - if asset.is_branch(): - br_index = network_data.branch_ids.index(asset.grid_model_id) - if network_data.bridging_branch_mask[br_index]: - stub_branch_indices.append(br_index) + for asset in connected_branch_assets: + br_index = network_data.branch_ids.index(asset.grid_model_id) + if network_data.bridging_branch_mask[br_index]: + stub_branch_indices.append(br_index) # All the loadflows should match except the stub branch and the disconnected branches where the load flow should be zero. # The stub branch lf will not match @@ -583,7 +582,7 @@ def test_compare_loadflows_rel_bb_outage( lfs_index = 0 _ = apply_topology(net, actions=topo_indices.action.tolist(), action_set=nd_action_set) - for station in asset_topology.stations: + for station in asset_topology.materialize_stations(): if station.grid_model_id not in rel_bb_outage_map: continue @@ -597,12 +596,11 @@ def test_compare_loadflows_rel_bb_outage( continue copy_net = copy.deepcopy(net) bb_index = get_busbar_index(modified_station, bb.grid_model_id) - connected_assets = get_connected_assets(modified_station, bb_index) + connected_assets = modified_station.get_connected_assets(bb_index) + connected_branch_assets = modified_station.get_connected_assets(bb_index, asset_scope="branch") connected_asset_ids = [asset.grid_model_id for asset in connected_assets] connected_branch_indices = [ - network_data_test_grid.branch_ids.index(asset.grid_model_id) - for asset in connected_assets - if asset.is_branch() + network_data_test_grid.branch_ids.index(asset.grid_model_id) for asset in connected_branch_assets ] copy_net.remove_elements(connected_asset_ids) config = pp.loadflow.Parameters( @@ -612,11 +610,10 @@ def test_compare_loadflows_rel_bb_outage( # Get the stub branches connected to this busbar stub_branch_indices = [] - for asset in connected_assets: - if asset.is_branch(): - br_index = network_data_test_grid.branch_ids.index(asset.grid_model_id) - if network_data_test_grid.bridging_branch_mask[br_index]: - stub_branch_indices.append(br_index) + for asset in connected_branch_assets: + br_index = network_data_test_grid.branch_ids.index(asset.grid_model_id) + if network_data_test_grid.bridging_branch_mask[br_index]: + stub_branch_indices.append(br_index) # The stub branch and skeleton branch lf may not match. Also, the loadflow in skeleton branch and stub branch # will not neccessarily be 0 diff --git a/packages/dc_solver_pkg/tests/numpy_reference.py b/packages/dc_solver_pkg/tests/numpy_reference.py index 595a191ea..c2d496266 100644 --- a/packages/dc_solver_pkg/tests/numpy_reference.py +++ b/packages/dc_solver_pkg/tests/numpy_reference.py @@ -17,6 +17,7 @@ import numpy as np from jaxtyping import Bool, Float, Int +from toop_engine_dc_solver.preprocess.helpers.ptdf import compute_ptdf def _validate_assignment(assignment: Bool[np.ndarray, " n_branches_at_node"]) -> None: @@ -511,6 +512,207 @@ def calc_bsdf( return bsdf, ptdf_new, from_node_new, to_node_new +def calc_bsdf_exact_rebuild( + switched_node: int, + assignment: Bool[np.ndarray, " n_branches_at_node"], + is_slack: bool, + bus_a_ptdf: int, + bus_b_ptdf: int, + ptdf: Float[np.ndarray, " n_branches n_nodes"], + susceptance: Float[np.ndarray, " n_branches"], + from_node: Int[np.ndarray, " n_branches"], + to_node: Int[np.ndarray, " n_branches"], + branches_at_nodes: list[Int[np.ndarray, " n_branches_at_node"]], + branch_direction: list[Bool[np.ndarray, " n_branches_at_node"]], +) -> tuple[ + Float[np.ndarray, " n_branches"], + Float[np.ndarray, " n_branches n_nodes"], + Int[np.ndarray, " n_branches"], + Int[np.ndarray, " n_branches"], + float, +]: + """Prototype an exact split update by rebuilding the PTDF on active connected nodes. + + This is intended as a diagnostic/reference path for cases where the closed-form BSDF update + appears to be structurally wrong. It performs the topology split exactly on the branch endpoint + arrays, recomputes the PTDF on the active connected node set, restores untouched extended + columns, and then projects the exact PTDF delta back onto the standard BSDF rank-1 form. + + Parameters + ---------- + switched_node : int + Index of the node being split. + assignment : Bool[np.ndarray, " n_branches_at_node"] + Boolean mask for branch assignment to busbars. + is_slack : bool + True if the node is the slack node. + bus_a_ptdf : int + PTDF column index for busbar A. + bus_b_ptdf : int + PTDF column index for busbar B. + ptdf : Float[np.ndarray, " n_branches n_nodes"] + PTDF matrix before the split. + susceptance : Float[np.ndarray, " n_branches"] + Branch susceptances. + from_node : Int[np.ndarray, " n_branches"] + Branch from-nodes. + to_node : Int[np.ndarray, " n_branches"] + Branch to-nodes. + branches_at_nodes : list[Int[np.ndarray, " n_branches_at_node"]] + Branch indices at each node. + branch_direction : list[Bool[np.ndarray, " n_branches_at_node"]] + Direction mask for branches at each node. + + Returns + ------- + Float[np.ndarray, " n_branches"] + Least-squares BSDF vector implied by the exact PTDF rebuild. + Float[np.ndarray, " n_branches n_nodes"] + Exact PTDF after the split on the active connected node set. + Int[np.ndarray, " n_branches"] + Updated from-nodes. + Int[np.ndarray, " n_branches"] + Updated to-nodes. + float + Maximum absolute reconstruction error of the projected rank-1 BSDF update. + """ + _validate_assignment(assignment) + + branches_local, direction_local = _get_local_branch_data( + switched_node=switched_node, + branches_at_nodes=branches_at_nodes, + branch_direction=branch_direction, + ) + branch_from_a, branch_to_a, branch_from_b, branch_to_b, _node_from_a, _node_to_a = get_bsdf_branch_indices( + assignment=assignment, + branches_locally=branches_local, + branch_direction_locally=direction_local, + from_node=from_node, + to_node=to_node, + ) + + ptdf_diff_raw = _compute_ptdf_difference( + ptdf=ptdf, + branch_from_a=branch_from_a, + branch_to_a=branch_to_a, + ) + ptdf_diff, _ptdf_b = _adjust_ptdf_for_slack( + ptdf_diff=ptdf_diff_raw, + is_slack=is_slack, + bus_a_ptdf=bus_a_ptdf, + bus_b_ptdf=bus_b_ptdf, + ) + + from_node_new = from_node.copy() + to_node_new = to_node.copy() + from_node_new[branch_from_b] = bus_b_ptdf + to_node_new[branch_to_b] = bus_b_ptdf + + active_nodes = np.unique(np.r_[from_node_new, to_node_new]) + node_remap = {old_idx: new_idx for new_idx, old_idx in enumerate(active_nodes.tolist())} + from_node_compact = np.array([node_remap[idx] for idx in from_node_new], dtype=int) + to_node_compact = np.array([node_remap[idx] for idx in to_node_new], dtype=int) + + slack_compact = node_remap[bus_a_ptdf] + ptdf_compact = compute_ptdf( + from_node=from_node_compact, + to_node=to_node_compact, + susceptances=susceptance, + slack_bus=slack_compact, + ) + + ptdf_new = ptdf.copy() + ptdf_new[:, active_nodes] = ptdf_compact + + delta_ptdf = ptdf_new - ptdf + ptdf_diff_norm = float(ptdf_diff @ ptdf_diff) + bsdf = delta_ptdf @ ptdf_diff / ptdf_diff_norm + reconstruction_error = float(np.max(np.abs(delta_ptdf - np.outer(bsdf, ptdf_diff)))) + + return bsdf, ptdf_new, from_node_new, to_node_new, reconstruction_error + + +def compute_flow_exact_rebuild( + switched_node: int, + assignment: Bool[np.ndarray, " n_branches_at_node"], + is_slack: bool, + bus_a_ptdf: int, + bus_b_ptdf: int, + ptdf: Float[np.ndarray, " n_branches n_nodes"], + susceptance: Float[np.ndarray, " n_branches"], + from_node: Int[np.ndarray, " n_branches"], + to_node: Int[np.ndarray, " n_branches"], + branches_at_nodes: list[Int[np.ndarray, " n_branches_at_node"]], + branch_direction: list[Bool[np.ndarray, " n_branches_at_node"]], + nodal_injection: Float[np.ndarray, " n_nodes"], +) -> tuple[ + Float[np.ndarray, " n_branches"], + Float[np.ndarray, " n_branches n_nodes"], + Int[np.ndarray, " n_branches"], + Int[np.ndarray, " n_branches"], + float, +]: + """Compute exact rebuilt split flows for a single bus split. + + This is a thin convenience wrapper around ``calc_bsdf_exact_rebuild`` for diagnostics that + need the post-split flow vector rather than only the rebuilt PTDF. + + Parameters + ---------- + switched_node : int + Index of the node being split. + assignment : Bool[np.ndarray, " n_branches_at_node"] + Boolean mask for branch assignment to busbars. + is_slack : bool + True if the node is the slack node. + bus_a_ptdf : int + PTDF column index for busbar A. + bus_b_ptdf : int + PTDF column index for busbar B. + ptdf : Float[np.ndarray, " n_branches n_nodes"] + PTDF matrix before the split. + susceptance : Float[np.ndarray, " n_branches"] + Branch susceptances. + from_node : Int[np.ndarray, " n_branches"] + Branch from-nodes. + to_node : Int[np.ndarray, " n_branches"] + Branch to-nodes. + branches_at_nodes : list[Int[np.ndarray, " n_branches_at_node"]] + Branch indices at each node. + branch_direction : list[Bool[np.ndarray, " n_branches_at_node"]] + Direction mask for branches at each node. + nodal_injection : Float[np.ndarray, " n_nodes"] + Nodal injection vector to apply to the rebuilt PTDF. + + Returns + ------- + Float[np.ndarray, " n_branches"] + Exact rebuilt branch flows after the split. + Float[np.ndarray, " n_branches n_nodes"] + Exact PTDF after the split on the active connected node set. + Int[np.ndarray, " n_branches"] + Updated from-nodes. + Int[np.ndarray, " n_branches"] + Updated to-nodes. + float + Maximum absolute reconstruction error of the projected rank-1 BSDF update. + """ + _bsdf, ptdf_new, from_node_new, to_node_new, reconstruction_error = calc_bsdf_exact_rebuild( + switched_node=switched_node, + assignment=assignment, + is_slack=is_slack, + bus_a_ptdf=bus_a_ptdf, + bus_b_ptdf=bus_b_ptdf, + ptdf=ptdf, + susceptance=susceptance, + from_node=from_node, + to_node=to_node, + branches_at_nodes=branches_at_nodes, + branch_direction=branch_direction, + ) + return ptdf_new @ nodal_injection, ptdf_new, from_node_new, to_node_new, reconstruction_error + + def extract_bsdf_data( topo_vect: Bool[np.ndarray, " n_branches_at_subs"], branches_at_nodes: list[Int[np.ndarray, " n_branches_at_node"]], diff --git a/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_pandapower.py b/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_pandapower.py index 22885565f..6c0b8e4c7 100644 --- a/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_pandapower.py +++ b/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_pandapower.py @@ -10,7 +10,7 @@ import pandapower as pp from toop_engine_dc_solver.postprocess.apply_asset_topo_pandapower import apply_station, apply_topology from toop_engine_grid_helpers.pandapower.pandapower_id_helpers import SEPARATOR -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS @@ -21,7 +21,7 @@ def test_apply_station(case14_data_folder: Path) -> None: # Make sure we have valid busbar ids # Currently only one bus exists in the station, so we expect the method to create the coupler and the missing busbar. - station = asset_topo.stations[0].model_copy() + station = asset_topo.materialize_stations()[0].model_copy() station.busbars[0].grid_model_id = f"1{SEPARATOR}bus" station.busbars[1].grid_model_id = f"15{SEPARATOR}bus" station.couplers[0].grid_model_id = f"1{SEPARATOR}switch" @@ -33,9 +33,10 @@ def test_apply_station(case14_data_folder: Path) -> None: assert len(apply_diff.switches_created) == 1 assert len(apply_diff.busbars_deleted) == 0 assert len(apply_diff.switches_deleted) == 0 - assert len(realized_station.disconnection_diff) == 0 + assert len(realized_station.branch_disconnection_diff) == 0 + assert len(realized_station.injection_disconnection_diff) == 0 assert len(realized_station.coupler_diff) == 0 - assert len(realized_station.reassignment_diff) + assert len(realized_station.branch_reassignment_diff) + len(realized_station.injection_reassignment_diff) def test_apply_station_existing_buses(case14_data_folder: Path) -> None: @@ -43,7 +44,7 @@ def test_apply_station_existing_buses(case14_data_folder: Path) -> None: with open(case14_data_folder / PREPROCESSING_PATHS["asset_topology_file_path"]) as f: asset_topo = Topology.model_validate_json(f.read()) - station = asset_topo.stations[0].model_copy() + station = asset_topo.materialize_stations()[0].model_copy() station.busbars[0].grid_model_id = f"1{SEPARATOR}bus" station.busbars[1].grid_model_id = f"15{SEPARATOR}bus" station.couplers[0].grid_model_id = f"1{SEPARATOR}switch" @@ -56,9 +57,10 @@ def test_apply_station_existing_buses(case14_data_folder: Path) -> None: assert len(apply_diff.switches_created) == 0 assert len(apply_diff.busbars_deleted) == 0 assert len(apply_diff.switches_deleted) == 0 - assert len(realized_station.disconnection_diff) == 0 + assert len(realized_station.branch_disconnection_diff) == 0 + assert len(realized_station.injection_disconnection_diff) == 0 assert len(realized_station.coupler_diff) == 1 - assert len(realized_station.reassignment_diff) + assert len(realized_station.branch_reassignment_diff) + len(realized_station.injection_reassignment_diff) def test_apply_station_extra_busbar(case14_data_folder: Path) -> None: @@ -66,7 +68,7 @@ def test_apply_station_extra_busbar(case14_data_folder: Path) -> None: with open(case14_data_folder / PREPROCESSING_PATHS["asset_topology_file_path"]) as f: asset_topo = Topology.model_validate_json(f.read()) - station = asset_topo.stations[0].model_copy() + station = asset_topo.materialize_stations()[0].model_copy() station.busbars[0].grid_model_id = f"1{SEPARATOR}bus" station.busbars[1].grid_model_id = f"15{SEPARATOR}bus" station.couplers[0].grid_model_id = f"1{SEPARATOR}switch" @@ -81,9 +83,9 @@ def test_apply_station_extra_busbar(case14_data_folder: Path) -> None: assert len(apply_diff.switches_created) == 0 assert apply_diff.busbars_deleted == [16] assert apply_diff.switches_deleted == [2] - assert len(realized_station.disconnection_diff) == 0 + assert len(realized_station.branch_disconnection_diff) == 0 + assert len(realized_station.injection_disconnection_diff) == 0 assert len(realized_station.coupler_diff) == 1 - assert len(realized_station.reassignment_diff) def test_apply_topology(case14_data_folder: Path) -> None: @@ -100,7 +102,8 @@ def test_apply_topology(case14_data_folder: Path) -> None: assert len(local_apply_diff.busbars_deleted) == 0 assert len(local_apply_diff.switches_deleted) == 0 - assert len(realized_topology.disconnection_diff) == 0 + assert len(realized_topology.branch_disconnection_diff) == 0 + assert len(realized_topology.injection_disconnection_diff) == 0 assert len(realized_topology.coupler_diff) == 0 - assert len(realized_topology.reassignment_diff) + assert len(realized_topology.branch_reassignment_diff) + len(realized_topology.injection_reassignment_diff) assert realized_topology.topology == asset_topo diff --git a/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_powsybl.py b/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_powsybl.py index f3b0a01b5..a42abd6ea 100644 --- a/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_powsybl.py +++ b/packages/dc_solver_pkg/tests/postprocessing/test_apply_asset_topo_powsybl.py @@ -13,179 +13,265 @@ import pytest from toop_engine_dc_solver.postprocess.apply_asset_topo_powsybl import ( apply_node_breaker_topology, - apply_single_asset_bus_branch, + apply_single_branch_bus_branch, + apply_single_injection_bus_branch, apply_station, apply_station_bus_branch, apply_topology_bus_branch, - find_asset, + find_branch, + find_injection, ) from toop_engine_grid_helpers.powsybl.example_grids import basic_node_breaker_network_powsybl -from toop_engine_interfaces.asset_topology import ( +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, Topology, + copy_topology_with_updates, ) from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS +def build_test_topology(reference_topology: Topology, raw_stations: list[RawStation]) -> Topology: + """Build a topology copy from raw stations for postprocessing tests. + + Parameters + ---------- + reference_topology : Topology + Source topology providing shared metadata and topology-owned payloads. + raw_stations : list[RawStation] + Raw stations to place into the copied topology. + + Returns + ------- + Topology + Topology copy with updated raw stations. + """ + return copy_topology_with_updates( + reference_topology=reference_topology, + raw_stations=raw_stations, + asset_bays=reference_topology.asset_bays, + branch_assets=reference_topology.branch_assets, + injection_assets=reference_topology.injection_assets, + ) + + def test_find_asset(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) - station = topology.stations[0] + station = topology.materialize_stations()[0] bus_id = station.grid_model_id vl_id = net.get_buses().loc[bus_id]["voltage_level_id"] - for elem in station.assets: - is_branch, connected, direction, bus_breaker_id = find_asset( - net=net, - elem_id=elem.grid_model_id, - voltage_level_id=vl_id, - bus_id=bus_id, + for asset_connection in station.branch_connections: + elem = asset_connection.asset + connected, direction, bus_breaker_id = find_branch( + net=net, elem_id=elem.grid_model_id, voltage_level_id=vl_id, bus_id=bus_id ) - - assert is_branch == elem.is_branch() assert connected - if is_branch: - brh = net.get_branches(all_attributes=True).loc[elem.grid_model_id] - if direction: - assert brh["bus1_id"] == bus_id - assert brh["bus_breaker_bus1_id"] == bus_breaker_id - else: - assert brh["bus2_id"] == bus_id - assert brh["bus_breaker_bus2_id"] == bus_breaker_id - else: - inj = net.get_injections(all_attributes=True).loc[elem.grid_model_id] - assert inj["bus_id"] == bus_id - assert inj["bus_breaker_bus_id"] == bus_breaker_id - - for elem in topology.stations[10].assets: - if elem.is_branch(): - with pytest.raises(ValueError, match=f"Branch {elem.grid_model_id} not found in the station"): - find_asset( - net=net, - elem_id=elem.grid_model_id, - voltage_level_id=vl_id, - bus_id=bus_id, - ) + brh = net.get_branches(all_attributes=True).loc[elem.grid_model_id] + if direction: + assert brh["bus1_id"] == bus_id + assert brh["bus_breaker_bus1_id"] == bus_breaker_id else: - with pytest.raises(ValueError, match=f"Element {elem.grid_model_id} not found in the station."): - find_asset( - net=net, - elem_id=elem.grid_model_id, - voltage_level_id=vl_id, - bus_id=bus_id, - ) + assert brh["bus2_id"] == bus_id + assert brh["bus_breaker_bus2_id"] == bus_breaker_id + + for asset_connection in station.injection_connections: + elem = asset_connection.asset + connected, bus_breaker_id = find_injection(net=net, elem_id=elem.grid_model_id, voltage_level_id=vl_id) + assert connected + inj = net.get_injections(all_attributes=True).loc[elem.grid_model_id] + assert inj["bus_id"] == bus_id + assert inj["bus_breaker_bus_id"] == bus_breaker_id + + for elem in [asset_connection.asset for asset_connection in topology.materialize_stations()[10].branch_connections]: + with pytest.raises(ValueError, match=f"Branch {elem.grid_model_id} not found in the station"): + find_branch( + net=net, + elem_id=elem.grid_model_id, + voltage_level_id=vl_id, + bus_id=bus_id, + ) + + for elem in [asset_connection.asset for asset_connection in topology.materialize_stations()[10].injection_connections]: + with pytest.raises(ValueError, match=f"Element {elem.grid_model_id} not found in the station."): + find_injection( + net=net, + elem_id=elem.grid_model_id, + voltage_level_id=vl_id, + ) def test_apply_single_asset_bus_branch_reassign(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo - net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) - for asset_index in range(len(topology.stations[0].assets)): - station = topology.stations[0].model_copy(deep=True) - # Reassign the first asset to the second bus + base_station = topology.materialize_stations()[0] + for asset_index in range(len(base_station.branch_connections)): + net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) + station = base_station.model_copy(deep=True) assert len(station.busbars) == 2 - station.asset_switching_table[:, asset_index] = ~station.asset_switching_table[:, asset_index] - status, reassignments = apply_single_asset_bus_branch( + current_state = station.branch_switching_table[:, asset_index] + station.branch_switching_table[:, asset_index] = ~current_state + disconnections, reassignments = apply_single_branch_bus_branch( net=net, station=station, - asset_index=asset_index, ) - assert status == "reassigned" + assert disconnections == [] assert len(reassignments) == 2 for re_asset_index, bus_id, connected in reassignments: assert re_asset_index == asset_index - assert connected == station.asset_switching_table[bus_id, asset_index] + assert connected == station.branch_switching_table[bus_id, asset_index] new_bus = [ bus - for (bus, switching) in zip(station.busbars, station.asset_switching_table[:, asset_index].tolist(), strict=True) + for (bus, switching) in zip( + station.busbars, station.branch_switching_table[:, asset_index].tolist(), strict=True + ) if switching ] assert len(new_bus) == 1 new_bus = new_bus[0] - if station.assets[asset_index].is_branch(): - brh = net.get_branches(all_attributes=True).loc[station.assets[asset_index].grid_model_id] - assert brh["bus_breaker_bus1_id"] == new_bus.grid_model_id or brh["bus_breaker_bus2_id"] == new_bus.grid_model_id - else: - inj = net.get_injections(all_attributes=True).loc[station.assets[asset_index].grid_model_id] - assert inj["bus_breaker_bus_id"] == new_bus.grid_model_id + asset = station.branch_connections[asset_index].asset + brh = net.get_branches(all_attributes=True).loc[asset.grid_model_id] + assert brh["bus_breaker_bus1_id"] == new_bus.grid_model_id or brh["bus_breaker_bus2_id"] == new_bus.grid_model_id + + for asset_index in range(len(base_station.injection_connections)): + net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) + station = base_station.model_copy(deep=True) + assert len(station.busbars) == 2 + current_state = station.injection_switching_table[:, asset_index] + station.injection_switching_table[:, asset_index] = ~current_state + disconnections, reassignments = apply_single_injection_bus_branch( + net=net, + station=station, + ) + assert disconnections == [] + assert len(reassignments) == 2 + for re_asset_index, bus_id, connected in reassignments: + assert re_asset_index == asset_index + assert connected == station.injection_switching_table[bus_id, asset_index] + + new_bus = [ + bus + for (bus, switching) in zip( + station.busbars, station.injection_switching_table[:, asset_index].tolist(), strict=True + ) + if switching + ] + assert len(new_bus) == 1 + new_bus = new_bus[0] + asset = station.injection_connections[asset_index].asset + inj = net.get_injections(all_attributes=True).loc[asset.grid_model_id] + assert inj["bus_breaker_bus_id"] == new_bus.grid_model_id def test_apply_single_asset_bus_branch_disconnect(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo - net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) - for asset_index in range(len(topology.stations[0].assets)): - station = topology.stations[0].model_copy(deep=True) + base_station = topology.materialize_stations()[0] + for asset_index in range(len(base_station.branch_connections)): + net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) + station = base_station.model_copy(deep=True) # Disconnect the first asset assert len(station.busbars) == 2 - station.asset_switching_table[:, asset_index] = False - status, reassignments = apply_single_asset_bus_branch( + station.branch_switching_table[:, asset_index] = False + disconnections, reassignments = apply_single_branch_bus_branch( net=net, station=station, - asset_index=asset_index, ) - assert status == "disconnected" + assert disconnections == [asset_index] assert len(reassignments) == 0 - if station.assets[asset_index].is_branch(): - brh = net.get_branches(all_attributes=True).loc[station.assets[asset_index].grid_model_id] - assert not brh["connected1"] and not brh["connected2"] - else: - inj = net.get_injections(all_attributes=True).loc[station.assets[asset_index].grid_model_id] - assert not inj["connected"] + asset = station.branch_connections[asset_index].asset + brh = net.get_branches(all_attributes=True).loc[asset.grid_model_id] + assert not brh["connected1"] and not brh["connected2"] -def test_apply_single_asset_bus_branch_nothing(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: +def test_apply_single_asset_bus_injection_disconnect(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo - net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) - for asset_index in range(len(topology.stations[0].assets)): - status, reassignments = apply_single_asset_bus_branch( + base_station = topology.materialize_stations()[0] + for asset_index in range(len(base_station.injection_connections)): + net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) + station = base_station.model_copy(deep=True) + assert len(station.busbars) == 2 + station.injection_switching_table[:, asset_index] = False + disconnections, reassignments = apply_single_injection_bus_branch( net=net, - station=topology.stations[0], - asset_index=asset_index, + station=station, ) - assert status == "nothing" + assert disconnections == [asset_index] assert len(reassignments) == 0 + asset = station.injection_connections[asset_index].asset + inj = net.get_injections(all_attributes=True).loc[asset.grid_model_id] + assert not inj["connected"] + + +def test_apply_single_asset_bus_branch_nothing(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: + grid_path, topology = case14_data_with_asset_topo + net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) + + base_station = topology.materialize_stations()[0] + branch_disconnections, branch_reassignments = apply_single_branch_bus_branch( + net=net, + station=base_station, + ) + assert branch_disconnections == [] + assert branch_reassignments == [] + + injection_disconnections, injection_reassignments = apply_single_injection_bus_branch( + net=net, + station=base_station, + ) + assert injection_disconnections == [] + assert injection_reassignments == [] + def test_apply_station_bus_branch_reassign(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) # Try do nothing - station = topology.stations[0].model_copy(deep=True) + station = topology.materialize_stations()[0].model_copy(deep=True) realized_station = apply_station_bus_branch( net=net, station=station, ) assert len(realized_station.coupler_diff) == 0 - assert len(realized_station.reassignment_diff) == 0 - assert len(realized_station.disconnection_diff) == 0 + assert len(realized_station.branch_reassignment_diff) == 0 + assert len(realized_station.injection_reassignment_diff) == 0 + assert len(realized_station.branch_disconnection_diff) == 0 + assert len(realized_station.injection_disconnection_diff) == 0 - realized_station_2 = apply_station(net=net, station=station) + realized_station_2 = apply_station(net=net, topology=topology, raw_station=topology.raw_stations[0]) assert realized_station_2 == realized_station # Try with disconnections and reassignments - assert np.array_equal(station.asset_switching_table, np.array([[True, True, True], [False, False, False]])) + assert np.array_equal( + np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1), + np.array([[True, True, True], [False, False, False]]), + ) - station.asset_switching_table = np.array([[True, False, False], [False, True, False]]) + target_switching_table = np.array([[True, False, False], [False, True, False]]) + station.branch_switching_table = target_switching_table[:, : len(station.branch_connections)] + station.injection_switching_table = target_switching_table[:, len(station.branch_connections) :] realized_station = apply_station_bus_branch( net=net, station=station, ) assert len(realized_station.coupler_diff) == 0 - assert len(realized_station.reassignment_diff) == 2 - assert len(realized_station.disconnection_diff) == 1 + assert len(realized_station.branch_reassignment_diff) + len(realized_station.injection_reassignment_diff) == 2 + assert len(realized_station.branch_disconnection_diff) + len(realized_station.injection_disconnection_diff) == 1 def test_apply_station_bus_branch_coupler(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) - station = topology.stations[0].model_copy(deep=True) - station.asset_switching_table = np.array([[True, False, True], [False, True, False]]) + station = topology.materialize_stations()[0].model_copy(deep=True) + station.branch_switching_table = np.array([[True, False], [False, True]]) + station.injection_switching_table = np.array([[True], [False]]) station.couplers[0].open = True realized_station = apply_station_bus_branch( @@ -194,8 +280,8 @@ def test_apply_station_bus_branch_coupler(case14_data_with_asset_topo: tuple[Pat ) assert len(realized_station.coupler_diff) == 1 assert realized_station.coupler_diff[0] == station.couplers[0] - assert len(realized_station.reassignment_diff) == 2 - assert len(realized_station.disconnection_diff) == 0 + assert len(realized_station.branch_reassignment_diff) + len(realized_station.injection_reassignment_diff) == 2 + assert len(realized_station.branch_disconnection_diff) + len(realized_station.injection_disconnection_diff) == 0 def test_apply_node_breaker_topology(basic_node_breaker_topology: Topology) -> None: @@ -221,7 +307,7 @@ def test_apply_node_breaker_topology(basic_node_breaker_topology: Topology) -> N assert sw.loc["L82_BREAKER", "open"] net = basic_node_breaker_network_powsybl() - switch_update_df_2 = apply_station(net, basic_node_breaker_topology.stations[0]) + switch_update_df_2 = apply_station(net, basic_node_breaker_topology, basic_node_breaker_topology.raw_stations[0]) assert isinstance(switch_update_df_2, pd.DataFrame) assert switch_update_df_2.isin(switch_update_df).all().all() @@ -236,8 +322,10 @@ def test_apply_topology_bus_branch_do_nothing(case14_data_with_asset_topo: tuple topology=topology, ) assert len(realized_topology.coupler_diff) == 0 - assert len(realized_topology.reassignment_diff) == 0 - assert len(realized_topology.disconnection_diff) == 0 + assert len(realized_topology.branch_reassignment_diff) == 0 + assert len(realized_topology.injection_reassignment_diff) == 0 + assert len(realized_topology.branch_disconnection_diff) == 0 + assert len(realized_topology.injection_disconnection_diff) == 0 def test_apply_topology_bus_branch_reassign(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: @@ -247,13 +335,24 @@ def test_apply_topology_bus_branch_reassign(case14_data_with_asset_topo: tuple[P # Try with disconnections and reassignments # Just randomize the switching tables, we'll have both disconnections and reassignments then - for station in topology.stations: - station.asset_switching_table = np.random.randint(0, 2, size=station.asset_switching_table.shape, dtype=bool) + raw_stations = [ + station.model_copy( + update={ + "branch_switching_table": np.random.randint(0, 2, size=station.branch_switching_table.shape, dtype=bool), + "injection_switching_table": np.random.randint( + 0, 2, size=station.injection_switching_table.shape, dtype=bool + ), + } + ) + for station in topology.raw_stations + ] + + topology = build_test_topology(topology, raw_stations) realized_topology = apply_topology_bus_branch( net=net, topology=topology, ) assert len(realized_topology.coupler_diff) == 0 - assert len(realized_topology.reassignment_diff) > 0 - assert len(realized_topology.disconnection_diff) > 0 + assert len(realized_topology.branch_reassignment_diff) + len(realized_topology.injection_reassignment_diff) > 0 + assert len(realized_topology.branch_disconnection_diff) + len(realized_topology.injection_disconnection_diff) > 0 diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_loadflows_match.py b/packages/dc_solver_pkg/tests/preprocessing/test_loadflows_match.py index fedc5db1d..b2ff6dcba 100644 --- a/packages/dc_solver_pkg/tests/preprocessing/test_loadflows_match.py +++ b/packages/dc_solver_pkg/tests/preprocessing/test_loadflows_match.py @@ -24,13 +24,17 @@ convert_topo_to_action_set_index, default_topology, ) +from toop_engine_dc_solver.jax.topology_looper import run_solver_symmetric +from toop_engine_dc_solver.jax.types import ActionIndexComputations from toop_engine_dc_solver.postprocess.postprocess_pandapower import ( compute_n_1_dc, ) -from toop_engine_dc_solver.preprocess.convert_to_jax import convert_to_jax +from toop_engine_dc_solver.postprocess.postprocess_powsybl import PowsyblRunner +from toop_engine_dc_solver.preprocess.convert_to_jax import convert_to_jax, load_grid from toop_engine_dc_solver.preprocess.helpers.find_bridges import ( find_n_minus_2_safe_branches, ) +from toop_engine_dc_solver.preprocess.network_data import extract_action_set, extract_nminus1_definition from toop_engine_dc_solver.preprocess.pandapower.pandapower_backend import PandaPowerBackend from toop_engine_dc_solver.preprocess.preprocess import preprocess from toop_engine_grid_helpers.pandapower.pandapower_helpers import ( @@ -40,10 +44,79 @@ get_pandapower_loadflow_results_injection, ) from toop_engine_grid_helpers.pandapower.pandapower_id_helpers import table_id +from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK from toop_engine_interfaces.folder_structure import ( CHRONICS_FILE_NAMES, PREPROCESSING_PATHS, ) +from toop_engine_interfaces.loadflow_result_helpers_polars import extract_solver_matrices_polars +from toop_engine_interfaces.messages.preprocess.preprocess_commands import PreprocessParameters +from toop_engine_interfaces.stored_action_set import ActionSet + + +def _select_asset_covering_action_indices(action_set: ActionSet) -> list[int]: + """Select a small action subset covering all changed branch and injection entries. + + The action set is grouped by station. For each station we greedily choose local + actions until every branch or injection column that changes relative to the + simplified starting topology is covered at least once. + """ + starting_station_by_id = { + station.grid_model_id: station for station in action_set.simplified_starting_topology.materialize_stations() + } + selected_action_indices: list[int] = [] + action_idx = 0 + + while action_idx < len(action_set.local_actions): + station_id = action_set.local_actions[action_idx].grid_model_id + station_start = action_idx + while ( + action_idx < len(action_set.local_actions) and action_set.local_actions[action_idx].grid_model_id == station_id + ): + action_idx += 1 + station_end = action_idx + + starting_station = starting_station_by_id[station_id] + station_actions = action_set.local_actions[station_start:station_end] + changed_assets_by_action = [] + for station in station_actions: + changed_branch_mask = np.any( + station.branch_switching_table != starting_station.branch_switching_table, + axis=0, + ) + changed_injection_mask = np.any( + station.injection_switching_table != starting_station.injection_switching_table, + axis=0, + ) + changed_assets_by_action.append( + { + *(set(("branch", asset_idx) for asset_idx in np.flatnonzero(changed_branch_mask).tolist())), + *(set(("injection", asset_idx) for asset_idx in np.flatnonzero(changed_injection_mask).tolist())), + } + ) + + uncovered_assets = set().union(*changed_assets_by_action) + chosen_station_actions: set[int] = set() + + while uncovered_assets: + best_action_offset = None + best_covered_assets: set[tuple[str, int]] = set() + for offset, changed_assets in enumerate(changed_assets_by_action): + if offset in chosen_station_actions: + continue + covered_assets = changed_assets & uncovered_assets + if len(covered_assets) > len(best_covered_assets): + best_action_offset = offset + best_covered_assets = covered_assets + + if best_action_offset is None or not best_covered_assets: + break + + chosen_station_actions.add(best_action_offset) + selected_action_indices.append(station_start + best_action_offset) + uncovered_assets -= best_covered_assets + + return selected_action_indices def test_grid_loadflow_uptodate(data_folder: Path) -> None: @@ -81,6 +154,71 @@ def test_n_0_results(data_folder: Path, preprocessed_data_folder: Path) -> None: assert abs_backend_loadflow.shape == n_0[0, 0, :].shape +def test_powsybl_complex_grid_actions_match_runner( + complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder: Path, +) -> None: + _, static_information, network_data = load_grid( + data_folder_dirfs=DirFileSystem(str(complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder)), + parameters=PreprocessParameters(), + lf_params=CGMES_DISTRIBUTED_SLACK, + ) + + action_set = extract_action_set(network_data) + nminus1_definition = extract_nminus1_definition(network_data) + + runner = PowsyblRunner(lf_params=CGMES_DISTRIBUTED_SLACK) + runner.load_base_grid( + complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"] + ) + runner.store_action_set(action_set) + runner.store_nminus1_definition(nminus1_definition) + + selected_action_indices = _select_asset_covering_action_indices(action_set) + failing_actions: list[str] = [] + + for action_idx in selected_action_indices: + actions = ActionIndexComputations( + action=jnp.array([[action_idx]], dtype=int), + pad_mask=jnp.array([True]), + ) + (n_0_solver, n_1_solver), success = run_solver_symmetric( + topologies=actions, + disconnections=None, + injections=None, + dynamic_information=static_information.dynamic_information, + solver_config=static_information.solver_config, + aggregate_output_fn=lambda lf_res: (lf_res.n_0_matrix, lf_res.n_1_matrix), + ) + + assert np.all(success) + + runner_result = runner.run_dc_loadflow([action_idx], []) + n_0_runner, n_1_runner, runner_success = extract_solver_matrices_polars(runner_result, nminus1_definition, 0) + + assert np.all(runner_success) + + n_0_expected = np.abs(np.asarray(n_0_solver[0, 0])) + n_1_expected = np.abs(np.asarray(n_1_solver[0, 0])) + n_0_actual = np.abs(n_0_runner) + n_1_actual = np.abs(n_1_runner) + + n_0_matches = np.allclose(n_0_expected, n_0_actual) + n_1_matches = np.allclose(n_1_expected, n_1_actual) + if n_0_matches and n_1_matches: + continue + + failure_parts = [] + if not n_0_matches: + n_0_diff = np.abs(n_0_expected - n_0_actual) + failure_parts.append(f"N-0 max_diff={float(np.max(n_0_diff)):.6g} mean_diff={float(np.mean(n_0_diff)):.6g}") + if not n_1_matches: + n_1_diff = np.abs(n_1_expected - n_1_actual) + failure_parts.append(f"N-1 max_diff={float(np.max(n_1_diff)):.6g} mean_diff={float(np.mean(n_1_diff)):.6g}") + failing_actions.append(f"action {action_idx}: {'; '.join(failure_parts)}") + + assert not failing_actions, "Failing actions:\n" + "\n".join(failing_actions) + + @pytest.mark.skip( reason="known issue: trafo3w sides (hv/mv/lv) in monitored elements unique_id (e.g. 12%%trafo3w_lv), causing incorrect/duplicated results" ) diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py index ad79ba33c..8c5ef7907 100644 --- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py +++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py @@ -69,7 +69,8 @@ get_pandapower_branch_loadflow_results_sequence, ) from toop_engine_grid_helpers.pandapower.pandapower_id_helpers import table_ids -from toop_engine_interfaces.asset_topology import Station +from toop_engine_interfaces.asset_topology.asset_topology import copy_topology_with_updates +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS from toop_engine_interfaces.messages.preprocess.preprocess_heartbeat import PreprocessStage @@ -585,16 +586,24 @@ def test_enumerate_station_realisations( for stations, br_act in zip(network_data.realised_stations, network_data.branch_action_set, strict=True): assert br_act.shape[0] == len(stations) for station in stations: - Station.model_validate(station) + MaterializedStation.model_validate(station) def test_enumerate_station_realisations_no_coupler( network_data_filled: NetworkData, ) -> None: - stations = [station.model_copy(update={"couplers": []}) for station in network_data_filled.asset_topology.stations] + raw_stations = [ + station.model_copy(update={"couplers": []}) for station in network_data_filled.asset_topology.raw_stations + ] network_data = replace( network_data_filled, - asset_topology=network_data_filled.asset_topology.model_copy(update={"stations": stations}), + asset_topology=copy_topology_with_updates( + reference_topology=network_data_filled.asset_topology, + raw_stations=raw_stations, + asset_bays=network_data_filled.asset_topology.asset_bays, + branch_assets=network_data_filled.asset_topology.branch_assets, + injection_assets=network_data_filled.asset_topology.injection_assets, + ), ) network_data = filter_relevant_nodes_branch_count(network_data) network_data = compute_branch_topology_info(network_data) @@ -611,15 +620,19 @@ def test_simplify_asset_topology( network_data = compute_injection_topology_info(network_data) network_data = simplify_asset_topology(network_data) assert network_data.simplified_asset_topology is not None - assert len(network_data.simplified_asset_topology.stations) == network_data.relevant_node_mask.sum() + simplified_stations = network_data.simplified_asset_topology.materialize_stations() + assert len(simplified_stations) == network_data.relevant_node_mask.sum() for rel_node_index, (rel_node_id, station) in enumerate( - zip(network_data.relevant_nodes, network_data.simplified_asset_topology.stations, strict=True) + zip(network_data.relevant_nodes, simplified_stations, strict=True) ): assert station.grid_model_id == network_data.node_ids[rel_node_id] - Station.model_validate(station) + MaterializedStation.model_validate(station) branch_ids = [network_data.branch_ids[i] for i in network_data.branches_at_nodes[rel_node_index]] inj_ids = [network_data.injection_ids[i] for i in network_data.injection_idx_at_nodes[rel_node_index]] - asset_ids = [a.grid_model_id for a in station.assets] + asset_ids = [ + *(asset_connection.asset.grid_model_id for asset_connection in station.branch_connections), + *(asset_connection.asset.grid_model_id for asset_connection in station.injection_connections), + ] assert branch_ids == asset_ids[: len(branch_ids)] assert inj_ids == asset_ids[len(branch_ids) :] @@ -787,7 +800,7 @@ def test_filter_relevant_nodes_no_asset_station(network_data: NetworkData) -> No network_data, asset_topology=network_data.asset_topology.model_copy( update={ - "stations": network_data.asset_topology.stations[:2] # Keep only the first two stations + "raw_stations": network_data.asset_topology.raw_stations[:2] # Keep only the first two stations } ), ) @@ -874,14 +887,18 @@ def test_preprocess(data_folder: str, tmp_path: str) -> None: static_information = load_static_information(Path(tmp_path) / "test_static_information.hdf5") validate_static_information(static_information) + simplified_stations = network_data.simplified_asset_topology.materialize_stations() for rel_node_index, (rel_node_id, station) in enumerate( - zip(network_data.relevant_nodes, network_data.simplified_asset_topology.stations, strict=True) + zip(network_data.relevant_nodes, simplified_stations, strict=True) ): assert station.grid_model_id == network_data.node_ids[rel_node_id] - Station.model_validate(station) + MaterializedStation.model_validate(station) branch_ids = [network_data.branch_ids[i] for i in network_data.branches_at_nodes[rel_node_index]] inj_ids = [network_data.injection_ids[i] for i in network_data.injection_idx_at_nodes[rel_node_index]] - asset_ids = [a.grid_model_id for a in station.assets] + asset_ids = [ + *(asset_connection.asset.grid_model_id for asset_connection in station.branch_connections), + *(asset_connection.asset.grid_model_id for asset_connection in station.injection_connections), + ] assert branch_ids == asset_ids[: len(branch_ids)] assert inj_ids == asset_ids[len(branch_ids) :] diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_bb_outage.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_bb_outage.py index a4ca78095..b88acaa82 100644 --- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_bb_outage.py +++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_bb_outage.py @@ -9,8 +9,7 @@ from dataclasses import replace import numpy as np -from tests.deprecated.assignment import realise_bus_split_single_station -from toop_engine_dc_solver.preprocess.network_data import NetworkData, map_branch_injection_ids +from toop_engine_dc_solver.preprocess.network_data import NetworkData from toop_engine_dc_solver.preprocess.preprocess import compute_separation_set_for_stations from toop_engine_dc_solver.preprocess.preprocess_bb_outage import ( extract_busbar_outage_data, @@ -23,12 +22,23 @@ get_non_rel_articulation_nodes, get_rel_articulation_nodes, get_rel_non_rel_sub_bb_maps, - get_relevant_stations, get_total_injection_along_stub_branch, update_network_data_with_non_rel_bb_outages, ) from toop_engine_dc_solver.preprocess.preprocess_station_realisations import enumerate_station_realisations -from toop_engine_interfaces.asset_topology import Busbar, Station, SwitchableAsset +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, InjectionAsset +from toop_engine_interfaces.asset_topology.materialized_topology import ( + MaterializedAssetConnection, + MaterializedStation, +) + + +def _combined_asset_connections(station: MaterializedStation) -> list[MaterializedAssetConnection]: + return [*station.branch_connections, *station.injection_connections] + + +def _combined_asset_switching_table(station: MaterializedStation) -> np.ndarray: + return np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1) def test_get_total_injection_along_stub_branch(network_data: NetworkData): @@ -74,9 +84,9 @@ def test_get_total_injection_along_stub_branch(network_data: NetworkData): def test_extract_outage_index_injection_from_asset(network_data: NetworkData): # Create mock SwitchableAsset objects - asset1 = SwitchableAsset(grid_model_id="branch_01", in_service=True, branch_end="from", type="line") - asset2 = SwitchableAsset(grid_model_id="branch_12", in_service=False, branch_end="to", type="line") - asset3 = SwitchableAsset(grid_model_id="branch_23", in_service=True, branch_end="from", type="line") + asset1 = BranchAsset(grid_model_id="branch_01", in_service=True, asset_type="line") + asset2 = BranchAsset(grid_model_id="branch_12", in_service=False, asset_type="line") + asset3 = BranchAsset(grid_model_id="branch_23", in_service=True, asset_type="line") # asset4 = SwitchableAsset( # grid_model_id="branch_02", in_service=True, branch_end="from" # ) @@ -86,11 +96,10 @@ def test_extract_outage_index_injection_from_asset(network_data: NetworkData): # asset6 = SwitchableAsset( # grid_model_id="injection_node_0", in_service=True, branch_end=None # ) - asset7 = SwitchableAsset( + asset7 = InjectionAsset( grid_model_id="injection_node_2", in_service=True, - branch_end=None, - type="GENERATOR", + asset_type="GENERATOR", ) # asset8 = SwitchableAsset( # grid_model_id="injection_node_1", in_service=True, branch_end=None @@ -180,22 +189,20 @@ def test_extract_outage_index_injection_from_asset(network_data: NetworkData): def test_extract_busbar_outage_data(network_data_preprocessed: NetworkData): # Create mock SwitchableAsset objects - asset1 = SwitchableAsset(grid_model_id="branch_01", in_service=True, branch_end="from", type="line") - asset2 = SwitchableAsset(grid_model_id="branch_12", in_service=False, branch_end="to", type="line") - asset3 = SwitchableAsset(grid_model_id="branch_23", in_service=True, branch_end="from", type="line") - asset4 = SwitchableAsset(grid_model_id="branch_02", in_service=True, branch_end="from", type="line") - asset5 = SwitchableAsset(grid_model_id="branch_03", in_service=True, branch_end="from", type="line") - asset6 = SwitchableAsset( + asset1 = BranchAsset(grid_model_id="branch_01", in_service=True, asset_type="line") + asset2 = BranchAsset(grid_model_id="branch_12", in_service=False, asset_type="line") + asset3 = BranchAsset(grid_model_id="branch_23", in_service=True, asset_type="line") + asset4 = BranchAsset(grid_model_id="branch_02", in_service=True, asset_type="line") + asset5 = BranchAsset(grid_model_id="branch_03", in_service=True, asset_type="line") + asset6 = InjectionAsset( grid_model_id="injection_node_0", in_service=True, - branch_end=None, - type="GENERATOR", + asset_type="GENERATOR", ) - asset7 = SwitchableAsset( + asset7 = InjectionAsset( grid_model_id="injection_node_2", in_service=True, - branch_end=None, - type="GENERATOR", + asset_type="GENERATOR", ) # asset8 = SwitchableAsset( # grid_model_id="injection_node_1", in_service=True, branch_end=None @@ -236,15 +243,23 @@ def test_extract_busbar_outage_data(network_data_preprocessed: NetworkData): grid_model_id="busbar_1", int_id=1, ) - station = Station( + station = MaterializedStation( grid_model_id="node_2", busbars=[busbar_0, busbar_1], couplers=[], - assets=[asset2, asset3, asset4, asset7], - asset_switching_table=np.array( + branch_connections=[MaterializedAssetConnection(asset=asset) for asset in [asset2, asset3, asset4]], + injection_connections=[MaterializedAssetConnection(asset=asset7)], + branch_switching_table=np.array( [ - [True, False, True, False], # Busbar 0 - [False, True, False, True], # Busbar 1 + [True, False, True], # Busbar 0 + [False, True, False], # Busbar 1 + ], + dtype=bool, + ), + injection_switching_table=np.array( + [ + [False], + [True], ], dtype=bool, ), @@ -327,14 +342,21 @@ def test_extract_busbar_outage_data(network_data_preprocessed: NetworkData): grid_model_id="busbar_0", int_id=0, ) - station = Station( + station = MaterializedStation( grid_model_id="node_0", busbars=[busbar_0], couplers=[], - assets=[asset1, asset4, asset5, asset6], - asset_switching_table=np.array( + branch_connections=[MaterializedAssetConnection(asset=asset) for asset in [asset1, asset4, asset5]], + injection_connections=[MaterializedAssetConnection(asset=asset6)], + branch_switching_table=np.array( [ - [True, True, True, True], # Busbar 0 + [True, True, True], # Busbar 0 + ], + dtype=bool, + ), + injection_switching_table=np.array( + [ + [True], ], dtype=bool, ), @@ -389,7 +411,7 @@ def test_update_network_data_with_non_rel_bb_outages(network_data_preprocessed: # Test case 3: Check if the branches to be outaged are valid and connected to the busbar for branch_outages, station_id in zip(updated_net_data.non_rel_bb_outage_br_indices, non_rel_bb_map): - for station in updated_net_data.asset_topology.stations: + for station in updated_net_data.asset_topology.materialize_stations(): if station.grid_model_id == station_id: break @@ -398,11 +420,11 @@ def test_update_network_data_with_non_rel_bb_outages(network_data_preprocessed: for branch_index in branch_outages: branch_id = updated_net_data.branch_ids[branch_index] # get asset_index of the branch - for asset_index, asset in enumerate(station.assets): - if asset.grid_model_id == branch_id: + for asset_index, asset_connection in enumerate(_combined_asset_connections(station)): + if asset_connection.asset.grid_model_id == branch_id: break - assert station.asset_switching_table[busbar_index, asset_index], ( + assert _combined_asset_switching_table(station)[busbar_index, asset_index], ( f"Branch {branch_id} is not connected to busbar {busbar_id}" ) @@ -448,19 +470,11 @@ def test_get_branch_injection_outages_for_rel_subs( f"{[[len(outage_data_nodal_index[i][j]) for j in range(len(outage_data_nodal_index[i]))] for i in range(len(outage_data_nodal_index))]}" ) - rel_stations = get_relevant_stations(network_data_preprocessed) + assert network_data_preprocessed.realised_stations is not None rel_station_index = 0 - branch_ids_mapped, _ = map_branch_injection_ids(network_data_preprocessed) - branch_actions = network_data_preprocessed.branch_action_set for station_combis in outage_data_branch_indices: for combi_index, busbar_outages in enumerate(station_combis): - modified_station, _, _ = realise_bus_split_single_station( - branch_ids_local=branch_ids_mapped[rel_station_index], - branch_topology_local=branch_actions[rel_station_index][combi_index], - injection_ids_local=[], - injection_topology_local=np.array([], dtype=bool), - station=rel_stations[rel_station_index], - ) + modified_station = network_data_preprocessed.realised_stations[rel_station_index][combi_index] busbar_branches_map = get_busbar_branches_map(modified_station, network_data_preprocessed) @@ -498,7 +512,7 @@ def test_get_modified_stations(network_data_preprocessed: NetworkData): # switching_table: # array([[False, True, False, False, False, True, False], # [True, False, True, True, True, False, True]] - monitored_station = network_data_preprocessed.asset_topology.stations[156] + monitored_station = network_data_preprocessed.asset_topology.materialize_stations()[156] outage_stations = [monitored_station.grid_model_id] branch_actions_all_rel_sub = network_data_preprocessed.branch_action_set modified_stations_br = get_modified_stations(network_data=network_data_preprocessed, stations_to_outage=outage_stations) @@ -521,13 +535,14 @@ def test_get_modified_stations(network_data_preprocessed: NetworkData): if not action.any(): res.append( np.all( - modified_stations_br[1][action_index].asset_switching_table == monitored_station.asset_switching_table + _combined_asset_switching_table(modified_stations_br[1][action_index]) + == _combined_asset_switching_table(monitored_station) ) ) else: res.append( np.all( - modified_stations_br[1][action_index].asset_switching_table[:, 0 : len(action)] == action, axis=1 + modified_stations_br[1][action_index].branch_switching_table[:, 0 : len(action)] == action, axis=1 ).any() ) assert np.sum(res) == len(modified_stations_br[1]), ( @@ -535,10 +550,7 @@ def test_get_modified_stations(network_data_preprocessed: NetworkData): ) assert np.all( [ - np.all( - modified_stations_br[1][i].asset_switching_table[:, len(branch_actions_all_rel_sub[1][i]) :] - == monitored_station.asset_switching_table[:, len(branch_actions_all_rel_sub[1][i]) :] - ) + np.all(modified_stations_br[1][i].injection_switching_table == monitored_station.injection_switching_table) for i in range(len(modified_stations_br[1])) ] ), ( @@ -601,7 +613,7 @@ def test_get_non_rel_bridge_busbars(network_data_test_grid: NetworkData): assert non_rel_busbar_outage_map == expected_map, f"Expected {expected_map}, but got {non_rel_busbar_outage_map}" -def test_get_rel_bridge_busbars(mock_station: Station): +def test_get_rel_bridge_busbars(mock_station: MaterializedStation): articulation_nodes = get_rel_articulation_nodes([mock_station], [[[2, 3, 4]]]) assert articulation_nodes == [[[3]]], f"Expected [[[3]]], but got {articulation_nodes}" diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_switching.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_switching.py index 4811088de..7f079c7a1 100644 --- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_switching.py +++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess_switching.py @@ -5,6 +5,7 @@ # you can obtain one at https://mozilla.org/MPL/2.0/. # Mozilla Public License, version 2.0 +import json from pathlib import Path import jax.numpy as jnp @@ -21,18 +22,39 @@ make_separation_set, prepare_for_separation_set, ) -from toop_engine_interfaces.asset_topology import ( - AssetBranchType, - AssetInjectionType, - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, -) +from toop_engine_interfaces.asset_topology.asset_types import AssetBranchType, AssetInjectionType +from toop_engine_interfaces.asset_topology.assets import Busbar, BusbarCoupler, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation + + +def _combined_asset_connections(station: MaterializedStation) -> list[MaterializedAssetConnection]: + return [*station.branch_connections, *station.injection_connections] + + +def _combined_asset_switching_table(station: MaterializedStation) -> np.ndarray: + return np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1) + + +def build_materialized_station( + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + assets: list[SwitchableAsset], + asset_switching_table: np.ndarray, +) -> MaterializedStation: + return MaterializedStation( + grid_model_id=grid_model_id, + busbars=busbars, + couplers=couplers, + branch_connections=[MaterializedAssetConnection(asset=asset) for asset in assets], + injection_connections=[], + branch_switching_table=asset_switching_table, + injection_switching_table=np.zeros((asset_switching_table.shape[0], 0), dtype=bool), + ) def test_make_configurations_table(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -69,7 +91,7 @@ def test_make_configurations_table(): assert problems.disconnected_busbars is None assert problems.duplicate_couplers is None assert preprocessed_station.busbars == station.busbars - assert preprocessed_station.assets == station.assets + assert preprocessed_station.branch_connections == station.branch_connections assert len(preprocessed_station.couplers) == 3 assert all([not coupler.open for coupler in preprocessed_station.couplers]) @@ -79,7 +101,7 @@ def test_make_configurations_table(): assert coupler_states.shape == (3, 3) assert len(assignment) == 3 - table = preprocessed_station.asset_switching_table + table = preprocessed_station.branch_switching_table # Coupler 3 open config_1_a = table[0] | table[1] | table[2] @@ -138,7 +160,7 @@ def test_identify_unnecessary_combinations() -> None: def test_preprocess_station() -> None: - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -182,7 +204,9 @@ def test_preprocess_station() -> None: def test_add_missing_asset_topology_branch_info(network_data: NetworkData) -> None: - num_assets_before = sum(len(station.assets) for station in network_data.asset_topology.stations) + num_assets_before = sum( + len(station.branch_connections) for station in network_data.asset_topology.materialize_stations() + ) topo = add_missing_asset_topology_branch_info( asset_topology=network_data.asset_topology, @@ -195,13 +219,14 @@ def test_add_missing_asset_topology_branch_info(network_data: NetworkData) -> No from_ends = 0 to_ends = 0 - for station in topo.stations: - for asset in station.assets: + for station in topo.materialize_stations(): + for asset_connection in station.branch_connections: + asset = asset_connection.asset if asset.grid_model_id in network_data.branch_ids: assert asset.name in network_data.branch_names - assert asset.type in network_data.branch_types - assert asset.branch_end in ["from", "to"] - if asset.branch_end == "from": + assert asset.asset_type in network_data.branch_types + assert asset_connection.branch_end in ["from", "to"] + if asset_connection.branch_end == "from": from_ends += 1 else: to_ends += 1 @@ -209,12 +234,15 @@ def test_add_missing_asset_topology_branch_info(network_data: NetworkData) -> No assert from_ends > 0 assert to_ends > 0 - num_assets_after = sum(len(station.assets) for station in topo.stations) + num_assets_after = sum(len(station.branch_connections) for station in topo.materialize_stations()) assert num_assets_before == num_assets_after def test_add_missing_asset_topology_injection_info(network_data: NetworkData) -> None: - num_assets_before = sum(len(station.assets) for station in network_data.asset_topology.stations) + num_assets_before = sum( + len(_combined_asset_connections(station)) for station in network_data.asset_topology.materialize_stations() + ) + replacement_injection_type = "load" topo = add_missing_asset_topology_injection_info( asset_topology=network_data.asset_topology, @@ -224,13 +252,13 @@ def test_add_missing_asset_topology_injection_info(network_data: NetworkData) -> overwrite_if_present=True, ) - for station in topo.stations: - for asset in station.assets: + for station in topo.materialize_stations(): + for asset in [asset_connection.asset for asset_connection in _combined_asset_connections(station)]: if asset.grid_model_id in network_data.injection_ids: assert asset.name in network_data.injection_names - assert asset.type in network_data.injection_types + assert asset.asset_type in network_data.injection_types - num_assets_after = sum(len(station.assets) for station in topo.stations) + num_assets_after = sum(len(_combined_asset_connections(station)) for station in topo.materialize_stations()) assert num_assets_before == num_assets_after # Test with overwrite_if_present=False @@ -238,41 +266,69 @@ def test_add_missing_asset_topology_injection_info(network_data: NetworkData) -> asset_topology=topo, injection_ids=network_data.injection_ids, injection_names=["new_name"] * len(network_data.injection_ids), - injection_types=["new_type"] * len(network_data.injection_ids), + injection_types=[replacement_injection_type] * len(network_data.injection_ids), overwrite_if_present=False, ) - for station in topo.stations: - for asset in station.assets: + for station in topo.materialize_stations(): + for asset in [asset_connection.asset for asset_connection in _combined_asset_connections(station)]: if asset.grid_model_id in network_data.injection_ids: assert asset.name in network_data.injection_names - assert asset.type in network_data.injection_types + assert asset.asset_type in network_data.injection_types topo = add_missing_asset_topology_injection_info( asset_topology=topo, injection_ids=network_data.injection_ids, injection_names=["new_name"] * len(network_data.injection_ids), - injection_types=["new_type"] * len(network_data.injection_ids), + injection_types=[replacement_injection_type] * len(network_data.injection_ids), overwrite_if_present=True, ) - for station in topo.stations: - for asset in station.assets: + for station in topo.materialize_stations(): + for asset in [asset_connection.asset for asset_connection in _combined_asset_connections(station)]: if asset.grid_model_id in network_data.injection_ids: assert asset.name == "new_name" - assert asset.type == "new_type" + assert asset.asset_type == replacement_injection_type def test_prepare_for_separation_set_node_breaker_test_station(): file = Path(__file__).parent.parent / "files" / "test_station.json" with open(file, "r") as f: - station = Station.model_validate_json(f.read()) + station_data = json.load(f) + + station_data["station_type"] = station_data.pop("type") + for busbar in station_data["busbars"]: + busbar["busbar_type"] = busbar.pop("type") + for coupler in station_data["couplers"]: + coupler["coupler_type"] = coupler.pop("type") + + assets = station_data.pop("assets") + for asset in assets: + asset["asset_type"] = asset.pop("type") + branch_mask = [asset["asset_type"] in get_args(AssetBranchType) for asset in assets] + injection_mask = [asset["asset_type"] in get_args(AssetInjectionType) for asset in assets] + asset_terminals = station_data.pop("asset_terminals", [None] * len(assets)) + asset_bays = station_data.pop("asset_bays", [None] * len(assets)) + combined_switching_table = np.asarray(station_data.pop("asset_switching_table"), dtype=bool) + station_data["branch_connections"] = [ + {"asset": asset, "branch_end": asset_terminal, "asset_bay": asset_bay} + for asset, asset_terminal, asset_bay in zip(assets, asset_terminals, asset_bays, strict=True) + if asset["asset_type"] in get_args(AssetBranchType) + ] + station_data["injection_connections"] = [ + {"asset": asset, "branch_end": asset_terminal, "asset_bay": asset_bay} + for asset, asset_terminal, asset_bay in zip(assets, asset_terminals, asset_bays, strict=True) + if asset["asset_type"] in get_args(AssetInjectionType) + ] + station_data["branch_switching_table"] = combined_switching_table[:, branch_mask] + station_data["injection_switching_table"] = combined_switching_table[:, injection_mask] + station = MaterializedStation.model_validate(station_data) x = nx.Graph() for busbar in station.busbars: x.add_node(busbar.int_id) for coupler in station.couplers: - if coupler.type != "DISCONNECTOR": + if coupler.coupler_type != "DISCONNECTOR": continue x.add_edge(coupler.busbar_from_id, coupler.busbar_to_id) connected_components = list(nx.connected_components(x)) @@ -281,8 +337,16 @@ def test_prepare_for_separation_set_node_breaker_test_station(): with pytest.raises(ValueError, match="no couplers left after preprocessing"): prepare_for_separation_set( station, - branch_ids=[asset.grid_model_id for asset in station.assets if asset.type in get_args(AssetBranchType)], - injection_ids=[asset.grid_model_id for asset in station.assets if asset.type in get_args(AssetInjectionType)], + branch_ids=[ + asset_connection.asset.grid_model_id + for asset_connection in _combined_asset_connections(station) + if asset_connection.asset.asset_type in get_args(AssetBranchType) + ], + injection_ids=[ + asset_connection.asset.grid_model_id + for asset_connection in _combined_asset_connections(station) + if asset_connection.asset.asset_type in get_args(AssetInjectionType) + ], ) station = station.model_copy( @@ -293,8 +357,16 @@ def test_prepare_for_separation_set_node_breaker_test_station(): preprocessed_station, problems = prepare_for_separation_set( station, - branch_ids=[asset.grid_model_id for asset in station.assets if asset.type in get_args(AssetBranchType)], - injection_ids=[asset.grid_model_id for asset in station.assets if asset.type in get_args(AssetInjectionType)], + branch_ids=[ + asset_connection.asset.grid_model_id + for asset_connection in _combined_asset_connections(station) + if asset_connection.asset.asset_type in get_args(AssetBranchType) + ], + injection_ids=[ + asset_connection.asset.grid_model_id + for asset_connection in _combined_asset_connections(station) + if asset_connection.asset.asset_type in get_args(AssetInjectionType) + ], close_couplers=True, ) assert len(preprocessed_station.couplers) diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_realize_assignment.py b/packages/dc_solver_pkg/tests/preprocessing/test_realize_assignment.py index 31a717626..21cb7d392 100644 --- a/packages/dc_solver_pkg/tests/preprocessing/test_realize_assignment.py +++ b/packages/dc_solver_pkg/tests/preprocessing/test_realize_assignment.py @@ -16,23 +16,32 @@ from toop_engine_dc_solver.preprocess.preprocess_switching import ( make_optimal_separation_set, ) -from toop_engine_interfaces.asset_topology import Busbar, BusbarCoupler, Station, SwitchableAsset +from toop_engine_interfaces.asset_topology.assets import Busbar, BusbarCoupler, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import ( + MaterializedAssetConnection, + MaterializedStation, +) + + +def _asset_connections(*asset_ids: str) -> list[MaterializedAssetConnection]: + return [MaterializedAssetConnection(asset=SwitchableAsset(grid_model_id=asset_id)) for asset_id in asset_ids] + + +def _empty_injection_switching(n_busbars: int) -> np.ndarray: + return np.zeros((n_busbars, 0), dtype=bool) def test_realize_ba_to_physical_topo_per_station_simple(): - station = Station( + station = MaterializedStation( grid_model_id="teststation", busbars=[Busbar(grid_model_id="BB1", int_id=1), Busbar(grid_model_id="BB2", int_id=2)], couplers=[BusbarCoupler(grid_model_id="BC1", busbar_from_id=1, busbar_to_id=2, open=False)], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - ], - asset_connectivity=np.ones((2, 5), dtype=bool), - asset_switching_table=np.array([[True, True, False, False, False], [False, False, True, True, True]]), + branch_connections=_asset_connections("line1", "line2", "line3", "line4", "line5"), + injection_connections=[], + branch_connectivity=np.ones((2, 5), dtype=bool), + injection_connectivity=_empty_injection_switching(2), + branch_switching_table=np.array([[True, True, False, False, False], [False, False, True, True, True]]), + injection_switching_table=_empty_injection_switching(2), ) action_set = np.array( @@ -64,20 +73,20 @@ def test_realize_ba_to_physical_topo_per_station_simple(): # The second action should be without any reassignments assert realized_stations[1].couplers[0].open is True - assert np.array_equal(realized_stations[1].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[1].branch_switching_table, station.branch_switching_table) assert reassignment_distance[1] == 0 assert busbar_a_mappings[1] == [1] # The third action is again without reassignments but also without inversion assert realized_stations[2].couplers[0].open is True - assert np.array_equal(realized_stations[2].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[2].branch_switching_table, station.branch_switching_table) assert reassignment_distance[2] == 0 assert busbar_a_mappings[2] == [0] # The fourth action has one reassignment assert realized_stations[3].couplers[0].open is True expected_switching_table = np.array([[True, True, False, True, False], [False, False, True, False, True]]) - assert np.array_equal(realized_stations[3].asset_switching_table, expected_switching_table) + assert np.array_equal(realized_stations[3].branch_switching_table, expected_switching_table) assert reassignment_distance[3] == 2 assert busbar_a_mappings[3] == [0] @@ -92,14 +101,14 @@ def test_realize_ba_to_physical_topo_per_station_simple(): ) = compute_switching_table( local_action=jnp.array(action_set[1]), current_coupler_state=jnp.array([True]), - separation_set=jnp.array(station.asset_switching_table[None]), + separation_set=jnp.array(station.branch_switching_table[None]), coupler_states=jnp.array([[False]]), busbar_mapping=jnp.array([[False, True]]), - current_switching_table=jnp.array(station.asset_switching_table), - asset_connectivity=jnp.array(station.asset_connectivity), + current_switching_table=jnp.array(station.branch_switching_table), + asset_connectivity=jnp.array(station.branch_connectivity), choice_heuristic="first", ) - assert np.array_equal(current_switching_table, station.asset_switching_table) + assert np.array_equal(current_switching_table, station.branch_switching_table) assert np.array_equal(chosen_coupler_state, np.array([False])) assert np.array_equal(chosen_busbar_mapping, np.array([True, False])) # Inverted assert el_reassignment_distance == 0 @@ -122,7 +131,7 @@ def test_realize_ba_to_physical_topo_per_station_simple(): def test_realize_ba_to_physical_topo_per_station_3_busbars(): - station = Station( + station = MaterializedStation( grid_model_id="teststation", busbars=[ Busbar(grid_model_id="BB1", int_id=1), @@ -133,17 +142,14 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): BusbarCoupler(grid_model_id="BC1", busbar_from_id=1, busbar_to_id=2, open=False), BusbarCoupler(grid_model_id="BC2", busbar_from_id=2, busbar_to_id=3, open=False), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - ], - asset_connectivity=np.ones((3, 5), dtype=bool), - asset_switching_table=np.array( + branch_connections=_asset_connections("line1", "line2", "line3", "line4", "line5"), + injection_connections=[], + branch_connectivity=np.ones((3, 5), dtype=bool), + injection_connectivity=_empty_injection_switching(3), + branch_switching_table=np.array( [[True, True, False, False, False], [False, False, True, True, False], [False, False, False, False, True]] ), + injection_switching_table=_empty_injection_switching(3), ) action_set = np.array( @@ -178,14 +184,14 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): # The second action should be without any reassignments assert realized_stations[1].couplers[0].open is True assert realized_stations[1].couplers[1].open is False - assert np.array_equal(realized_stations[1].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[1].branch_switching_table, station.branch_switching_table) assert reassignment_distance[1] == 0 assert busbar_a_mappings[1] == [1, 2] # The third action is again without reassignments but also without inversion assert realized_stations[2].couplers[0].open is True assert realized_stations[2].couplers[1].open is False - assert np.array_equal(realized_stations[2].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[2].branch_switching_table, station.branch_switching_table) assert reassignment_distance[2] == 0 assert busbar_a_mappings[2] == [0] @@ -195,7 +201,7 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): expected_switching_table = np.array( [[True, True, False, False, True], [False, False, True, True, False], [False, False, False, False, False]] ) - assert np.array_equal(realized_stations[3].asset_switching_table, expected_switching_table) + assert np.array_equal(realized_stations[3].branch_switching_table, expected_switching_table) assert reassignment_distance[3] == 2 assert busbar_a_mappings[3] == [0] @@ -205,7 +211,7 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): expected_switching_table = np.array( [[True, False, False, True, False], [False, True, True, False, False], [False, False, False, False, True]] ) - assert np.array_equal(realized_stations[4].asset_switching_table, expected_switching_table) + assert np.array_equal(realized_stations[4].branch_switching_table, expected_switching_table) assert reassignment_distance[4] == 4 assert busbar_a_mappings[4] == [0] @@ -220,7 +226,7 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): expected_switching_table_least_connected = np.array( [[True, False, False, False, False], [False, False, True, True, False], [False, True, False, False, True]] ) - assert np.array_equal(realized_stations[5].asset_switching_table, expected_switching_table_first) + assert np.array_equal(realized_stations[5].branch_switching_table, expected_switching_table_first) assert reassignment_distance[5] == 2 assert busbar_a_mappings[5] == [0] @@ -237,7 +243,7 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): assert np.array_equal(updated_action_set_2, updated_action_set) assert realized_stations_2[5].couplers[0].open is True assert realized_stations_2[5].couplers[1].open is False - assert np.array_equal(realized_stations_2[5].asset_switching_table, expected_switching_table_least_connected) + assert np.array_equal(realized_stations_2[5].branch_switching_table, expected_switching_table_least_connected) assert reassignment_distance_2[5] == 2 assert busbar_a_mappings_2[5] == [0] @@ -255,7 +261,7 @@ def test_realize_ba_to_physical_topo_per_station_3_busbars(): assert np.array_equal(updated_action_set_3, updated_action_set) assert realized_stations_3[5].couplers[0].open is True assert realized_stations_3[5].couplers[1].open is False - assert np.array_equal(realized_stations_3[5].asset_switching_table, expected_switching_table_first) + assert np.array_equal(realized_stations_3[5].branch_switching_table, expected_switching_table_first) assert reassignment_distance_3[5] == 2 assert busbar_a_mappings_3[5] == [0] @@ -270,7 +276,7 @@ def test_realize_ba_to_physical_topo_per_station_large(): switching_table = np.zeros((3, n_assets), dtype=bool) switching_table[busbar_choices, range(n_assets)] = True - station = Station( + station = MaterializedStation( grid_model_id="teststation", busbars=[ Busbar(grid_model_id="BB1", int_id=1), @@ -281,9 +287,12 @@ def test_realize_ba_to_physical_topo_per_station_large(): BusbarCoupler(grid_model_id="BC1", busbar_from_id=1, busbar_to_id=2, open=False), BusbarCoupler(grid_model_id="BC2", busbar_from_id=2, busbar_to_id=3, open=False), ], - assets=[SwitchableAsset(grid_model_id=f"line{i + 1}") for i in range(n_assets)], - asset_connectivity=np.ones((3, n_assets), dtype=bool), - asset_switching_table=switching_table, + branch_connections=_asset_connections(*[f"line{i + 1}" for i in range(n_assets)]), + injection_connections=[], + branch_connectivity=np.ones((3, n_assets), dtype=bool), + injection_connectivity=_empty_injection_switching(3), + branch_switching_table=switching_table, + injection_switching_table=_empty_injection_switching(3), ) separation_set_station = make_optimal_separation_set(station) @@ -305,7 +314,7 @@ def test_realize_ba_to_physical_topo_per_station_large(): def test_realize_ba_to_physical_topo_per_station_limited_connectivity(): - station = Station( + station = MaterializedStation( grid_model_id="teststation", busbars=[ Busbar(grid_model_id="BB1", int_id=1), @@ -316,19 +325,16 @@ def test_realize_ba_to_physical_topo_per_station_limited_connectivity(): BusbarCoupler(grid_model_id="BC1", busbar_from_id=1, busbar_to_id=2, open=False), BusbarCoupler(grid_model_id="BC2", busbar_from_id=2, busbar_to_id=3, open=False), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - ], - asset_connectivity=np.array( + branch_connections=_asset_connections("line1", "line2", "line3", "line4", "line5"), + injection_connections=[], + branch_connectivity=np.array( [[True, True, True, True, False], [True, True, True, True, False], [True, True, False, False, True]] ), - asset_switching_table=np.array( + injection_connectivity=_empty_injection_switching(3), + branch_switching_table=np.array( [[True, True, False, False, False], [False, False, True, True, False], [False, False, False, False, True]] ), + injection_switching_table=_empty_injection_switching(3), ) action_set = np.array( @@ -358,13 +364,13 @@ def test_realize_ba_to_physical_topo_per_station_limited_connectivity(): assert realized_stations[1].couplers[0].open is True assert realized_stations[1].couplers[1].open is False - assert np.array_equal(realized_stations[1].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[1].branch_switching_table, station.branch_switching_table) assert reassignment_distance[1] == 0 assert busbar_a_mappings[1] == [1, 2] assert realized_stations[2].couplers[0].open is True assert realized_stations[2].couplers[1].open is False - assert np.array_equal(realized_stations[2].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[2].branch_switching_table, station.branch_switching_table) assert reassignment_distance[2] == 0 assert busbar_a_mappings[2] == [0] @@ -372,30 +378,30 @@ def test_realize_ba_to_physical_topo_per_station_limited_connectivity(): expected_switching = np.array( [[False, False, True, True, False], [True, True, False, False, False], [False, False, False, False, True]] ) - assert np.array_equal(realized_stations[3].asset_switching_table, expected_switching) + assert np.array_equal(realized_stations[3].branch_switching_table, expected_switching) assert realized_stations[3].couplers[0].open is True assert realized_stations[3].couplers[1].open is False - assert reassignment_distance[3] == np.logical_xor(expected_switching, station.asset_switching_table).sum() + assert reassignment_distance[3] == np.logical_xor(expected_switching, station.branch_switching_table).sum() assert busbar_a_mappings[3] == [0] # Fourth action is the same as the fifth, but inverted - assert np.array_equal(realized_stations[4].asset_switching_table, expected_switching) + assert np.array_equal(realized_stations[4].branch_switching_table, expected_switching) assert realized_stations[4].couplers[0].open is True assert realized_stations[4].couplers[1].open is False - assert reassignment_distance[4] == np.logical_xor(expected_switching, station.asset_switching_table).sum() + assert reassignment_distance[4] == np.logical_xor(expected_switching, station.branch_switching_table).sum() assert busbar_a_mappings[4] == [1, 2] # Sixth action is possible by putting asset[3] on busbar[0] expected_switching = np.array( [[False, False, True, False, False], [True, True, False, True, False], [False, False, False, False, True]] ) - assert np.array_equal(realized_stations[5].asset_switching_table, expected_switching) + assert np.array_equal(realized_stations[5].branch_switching_table, expected_switching) assert realized_stations[5].couplers[0].open is True assert realized_stations[5].couplers[1].open is False def test_realize_ba_to_physical_topo_per_station_invalid_actions(): - station = Station( + station = MaterializedStation( grid_model_id="teststation", busbars=[ Busbar(grid_model_id="BB1", int_id=1), @@ -406,19 +412,16 @@ def test_realize_ba_to_physical_topo_per_station_invalid_actions(): BusbarCoupler(grid_model_id="BC1", busbar_from_id=1, busbar_to_id=2, open=False), BusbarCoupler(grid_model_id="BC2", busbar_from_id=2, busbar_to_id=3, open=False), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - ], - asset_connectivity=np.array( + branch_connections=_asset_connections("line1", "line2", "line3", "line4", "line5"), + injection_connections=[], + branch_connectivity=np.array( [[True, True, False, False, True], [True, True, True, True, True], [True, True, False, False, True]] ), - asset_switching_table=np.array( + injection_connectivity=_empty_injection_switching(3), + branch_switching_table=np.array( [[True, True, False, False, False], [False, False, True, True, False], [False, False, False, False, True]] ), + injection_switching_table=_empty_injection_switching(3), ) separation_set_station = make_optimal_separation_set(station) @@ -451,7 +454,7 @@ def test_realize_ba_to_physical_topo_per_station_invalid_actions(): @pytest.mark.xfail(reason="These edge cases are not yet handled") def test_realize_ba_to_physical_topo_per_station_invalid_actions_hard(): - station = Station( + station = MaterializedStation( grid_model_id="teststation", busbars=[ Busbar(grid_model_id="BB1", int_id=1), @@ -462,19 +465,16 @@ def test_realize_ba_to_physical_topo_per_station_invalid_actions_hard(): BusbarCoupler(grid_model_id="BC1", busbar_from_id=1, busbar_to_id=2, open=False), BusbarCoupler(grid_model_id="BC2", busbar_from_id=2, busbar_to_id=3, open=False), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - ], - asset_connectivity=np.array( + branch_connections=_asset_connections("line1", "line2", "line3", "line4", "line5"), + injection_connections=[], + branch_connectivity=np.array( [[True, True, False, False, False], [True, True, True, True, False], [True, True, False, False, True]] ), - asset_switching_table=np.array( + injection_connectivity=_empty_injection_switching(3), + branch_switching_table=np.array( [[True, True, False, False, False], [False, False, True, True, False], [False, False, False, False, True]] ), + injection_switching_table=_empty_injection_switching(3), ) action_set = np.array( @@ -505,6 +505,6 @@ def test_realize_ba_to_physical_topo_per_station_invalid_actions_hard(): assert realized_stations[1].couplers[0].open is True assert realized_stations[1].couplers[1].open is False - assert np.array_equal(realized_stations[1].asset_switching_table, station.asset_switching_table) + assert np.array_equal(realized_stations[1].branch_switching_table, station.branch_switching_table) assert reassignment_distance[1] == 0 assert busbar_a_mappings[1] == [1, 2] diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_write_aux_data.py b/packages/dc_solver_pkg/tests/preprocessing/test_write_aux_data.py index 8f4736b0e..eef975bdb 100644 --- a/packages/dc_solver_pkg/tests/preprocessing/test_write_aux_data.py +++ b/packages/dc_solver_pkg/tests/preprocessing/test_write_aux_data.py @@ -55,10 +55,10 @@ def test_extract_data_compare_to_network_data(network_data_preprocessed: Network n_monitored_branches = network_data_preprocessed.monitored_branch_mask.sum() n_monitored_nodes = sum( - [len(station.busbars) for station in network_data_preprocessed.simplified_asset_topology.stations] + [len(station.busbars) for station in network_data_preprocessed.simplified_asset_topology.raw_stations] ) n_monitored_switches = sum( - [len(station.couplers) for station in network_data_preprocessed.simplified_asset_topology.stations] + [len(station.couplers) for station in network_data_preprocessed.simplified_asset_topology.raw_stations] ) n_expected_monitored_elements = n_monitored_branches + n_monitored_nodes + n_monitored_switches assert n_monitored_elements == n_expected_monitored_elements, ( diff --git a/packages/dc_solver_pkg/tests/test_example_grids.py b/packages/dc_solver_pkg/tests/test_example_grids.py index 133d8cde6..11d557e5e 100644 --- a/packages/dc_solver_pkg/tests/test_example_grids.py +++ b/packages/dc_solver_pkg/tests/test_example_grids.py @@ -46,7 +46,7 @@ ) from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK, SINGLE_SLACK from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import assert_station_in_network -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS from toop_engine_interfaces.messages.preprocess.preprocess_commands import PreprocessParameters @@ -60,7 +60,7 @@ def test_random_topology_info(data_folder: Path) -> None: ) topology = random_topology_info_backend(backend, pp_counters) - assert len(topology.stations) == sum(backend.get_relevant_node_mask()) + assert len(topology.materialize_stations()) == sum(backend.get_relevant_node_mask()) def test_oberrhein_data() -> None: @@ -481,7 +481,7 @@ def test_case14_with_matching_asset_topo() -> None: # Check the asset topology with open(tmp_dir / PREPROCESSING_PATHS["asset_topology_file_path"], "r") as f: asset_topo = Topology.model_validate_json(f.read()) - for station in asset_topo.stations: + for station in asset_topo.materialize_stations(): assert_station_in_network(backend.net, station) diff --git a/packages/grid_helpers_pkg/README.md b/packages/grid_helpers_pkg/README.md index 3ae50f356..0227da564 100644 --- a/packages/grid_helpers_pkg/README.md +++ b/packages/grid_helpers_pkg/README.md @@ -29,7 +29,7 @@ Grid Helpers acts as the foundational abstraction layer that standardizes operat ## PyPowSyBl Key Data Structures and Concepts - **Loadflow parameter**:A collection of loadflow parameter used by the ToOp project. - **Helpers**: A standardized function set to get grid information. Heavy used by the [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend]. -- **Asset Topology helpers**: An implementation of the [AssetTopology][toop_engine_interfaces.asset_topology], main entry: [`get_list_of_stations`][toop_engine_grid_helpers.powsybl.powsybl_asset_topo.get_list_of_stations] +- **Asset Topology helpers**: An implementation of the [AssetTopology][toop_engine_interfaces.asset_topology], main entry: [`get_topology`][toop_engine_grid_helpers.powsybl.powsybl_asset_topo.get_topology] - **Single line diagram (SLD)**: A modified version of the powsybl SLD, with a bright and dark mode. This could be integrated into powsybl itself as it's own package (java). - **Polars DataFrame**: Once you have millions of rows in you dataframe, the PowSyBl (Java) to PyPowSybl converter (Java->C++->Python->Pandas) becomes slow. You can eliminate one large bottleneck by removing pandas from this data extraction path. Ideally the transfer from Java to PyPowSyBl would deliver in this case parquet as a format. The speed boost only starts with large amount of N-1 cases: 3000 Bus network with more than 500 N-1 cases. Below that the speed of collection is roughly equal. diff --git a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/example_grids.py b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/example_grids.py index f52d19755..a2d035ed4 100644 --- a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/example_grids.py +++ b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/example_grids.py @@ -14,10 +14,10 @@ import pandas as pd import pypowsybl from pypowsybl.network import Network -from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import get_stations_bus_breaker +from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import get_raw_stations_and_assets_bus_breaker from toop_engine_grid_helpers.powsybl.powsybl_helpers import load_pandapower_net_for_powsybl -from toop_engine_interfaces.asset_topology import Topology -from toop_engine_interfaces.asset_topology_helpers import save_asset_topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology_helpers import save_asset_topology from toop_engine_interfaces.folder_structure import NETWORK_MASK_NAMES, PREPROCESSING_PATHS @@ -660,14 +660,17 @@ def create_busbar_b_in_ieee(net: pypowsybl.network.Network) -> None: def extract_station_info_powsybl(net: Network, base_folder: Path) -> None: - stations = get_stations_bus_breaker(net) + raw_stations, branch_assets, injection_assets = get_raw_stations_and_assets_bus_breaker(net) target = base_folder / PREPROCESSING_PATHS["asset_topology_file_path"] target.parent.mkdir(parents=True, exist_ok=True) save_asset_topology( target, Topology( - stations=stations, topology_id="extracted_topology", + raw_stations=raw_stations, + branch_assets=branch_assets, + injection_assets=injection_assets, + asset_bays=[], timestamp=datetime.datetime.now(), ), ) diff --git a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_asset_topo.py b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_asset_topo.py index feeb472f1..bbf6802ee 100644 --- a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_asset_topo.py +++ b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_asset_topo.py @@ -16,18 +16,26 @@ import numpy as np import pandas as pd -from beartype.typing import Optional, Union +from beartype.typing import Optional, TypeVar, Union from jaxtyping import Bool from pypowsybl.network.impl.network import Network from toop_engine_grid_helpers.powsybl.powsybl_helpers import change_dangling_to_tie, get_voltage_level_with_region -from toop_engine_interfaces.asset_topology import ( - AssetBay, +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, + Topology, +) +from toop_engine_interfaces.asset_topology.assets import ( + BranchAsset, Busbar, BusbarCoupler, - Station, + InjectionAsset, SwitchableAsset, - Topology, + normalize_switchable_asset_payload, ) +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection + +SwitchableAssetType = TypeVar("SwitchableAssetType", bound=SwitchableAsset) def get_all_element_names(network: Network, line_trafo_name_col: str = "elementName") -> pd.Series: @@ -124,60 +132,214 @@ def get_list_of_coupler_from_df(coupler_elements: pd.DataFrame) -> list[BusbarCo return coupler_list -def get_list_of_switchable_assets_from_df( - station_branches: pd.DataFrame, - asset_bay_list: Optional[list[AssetBay]] = None, - asset_bay_dict: Optional[dict[str, AssetBay]] = None, -) -> list[SwitchableAsset]: - """Get the list of switchable assets from the DataFrame. +def get_list_of_busbars_from_df(station_buses: pd.DataFrame) -> list[Busbar]: + """Get the list of busbars from the DataFrame. Parameters ---------- - station_branches: pd.DataFrame - DataFrame with the switchable assets + station_buses: pd.DataFrame + DataFrame with the busbars Note: datatype of columns is expected to be the same as in the pydantic model. - asset_bay_list: Optional[list[AssetBay]] - List of asset bays. - Note: The list is expected to have the same length as the station_branches. - asset_bay_dict: Optional[dict[str, AssetConnectionPath]] - Dictionary of asset bays with the asset grid_model_id as key. Returns ------- - switchable_assets_list: list[SwitchableAsset] - List of switchable assets. + busbar_list: list[Busbar] + List of busbars. """ - switchable_assets_dict = station_branches.to_dict(orient="records") - if asset_bay_list is not None: - for index, _ in enumerate(switchable_assets_dict): - switchable_assets_dict[index]["asset_bay"] = asset_bay_list[index] - elif asset_bay_dict is not None: - for index, asset in enumerate(switchable_assets_dict): - if asset["grid_model_id"] in asset_bay_dict: - switchable_assets_dict[index]["asset_bay"] = asset_bay_dict[asset["grid_model_id"]] - switchable_assets_list = [SwitchableAsset(**switchable_asset) for switchable_asset in switchable_assets_dict] + busbar_dict = station_buses.to_dict(orient="records") + busbar_list = [Busbar(**busbar) for busbar in busbar_dict] - return switchable_assets_list + return busbar_list -def get_list_of_busbars_from_df(station_buses: pd.DataFrame) -> list[Busbar]: - """Get the list of busbars from the DataFrame. +def _dedupe_assets_by_id(assets: list[SwitchableAssetType]) -> list[SwitchableAssetType]: + """Deduplicate topology-owned assets by grid model id while preserving first-seen order. Parameters ---------- - station_buses: pd.DataFrame - DataFrame with the busbars - Note: datatype of columns is expected to be the same as in the pydantic model. + assets : list[SwitchableAssetType] + Topology-owned assets collected across stations. Returns ------- - busbar_list: list[Busbar] - List of busbars. + list[SwitchableAssetType] + One deep-copied asset per grid model id, ordered by first appearance. """ - busbar_dict = station_buses.to_dict(orient="records") - busbar_list = [Busbar(**busbar) for busbar in busbar_dict] + deduped_assets: dict[str, SwitchableAssetType] = {} + for asset in assets: + deduped_assets.setdefault(asset.grid_model_id, asset.model_copy(deep=True)) + return list(deduped_assets.values()) - return busbar_list + +def _get_station_asset_inputs_from_topology( + station_topology_elements: pd.DataFrame, + station_buses: pd.DataFrame, + dangling_lines: pd.DataFrame, + element_names: pd.Series, +) -> tuple[pd.DataFrame, list[str | None], np.ndarray, np.ndarray]: + """Build the shared station asset inputs used by the split extraction helpers. + + Parameters + ---------- + station_topology_elements : pd.DataFrame + Raw powsybl topology elements for one station. + station_buses : pd.DataFrame + Formatted station busbars with local integer ids. + dangling_lines : pd.DataFrame + Network dangling-line metadata used to normalize tie lines. + element_names : pd.Series + Asset names keyed by grid model id. + + Returns + ------- + station_elements : pd.DataFrame + Normalized station asset rows used for asset materialization. + asset_branch_ends : list[str | None] + Branch-end labels aligned with `station_elements`. + switching_matrix : np.ndarray + Station-local switching matrix aligned with `station_elements`. + asset_connectivity : np.ndarray + Default connectivity matrix aligned with `station_elements`. + """ + station_elements, switching_matrix = get_asset_info_from_topology( + station_topology_elements, + station_buses, + dangling_lines, + element_names, + ) + asset_connectivity = np.ones_like(switching_matrix, dtype=bool) + asset_branch_ends = ( + station_elements["branch_end"].tolist() + if "branch_end" in station_elements.columns + else [None] * len(station_elements) + ) + return station_elements, asset_branch_ends, switching_matrix, asset_connectivity + + +def _get_branch_station_assets_from_df( + station_elements: pd.DataFrame, + asset_branch_ends: list[str | None], + switching_matrix: np.ndarray, + asset_connectivity: np.ndarray, +) -> tuple[list[BranchAsset], list[str | None], np.ndarray, np.ndarray]: + """Return branch station assets and aligned arrays from prepared station inputs. + + Parameters + ---------- + station_elements : pd.DataFrame + Normalized station asset rows. + asset_branch_ends : list[str | None] + Branch-end labels aligned with `station_elements`. + switching_matrix : np.ndarray + Station-local switching matrix aligned with `station_elements`. + asset_connectivity : np.ndarray + Connectivity matrix aligned with `station_elements`. + + Returns + ------- + branch_assets : list[BranchAsset] + Materialized branch assets for the station. + branch_ends : list[str | None] + Branch-end labels aligned with `branch_assets`. + branch_switching_table : np.ndarray + Branch-only switching table. + branch_connectivity : np.ndarray + Branch-only connectivity table. + """ + normalized_assets = [ + normalize_switchable_asset_payload(switchable_asset) + for switchable_asset in station_elements.to_dict(orient="records") + ] + if any(not isinstance(asset, (BranchAsset, InjectionAsset)) for asset in normalized_assets): + raise ValueError("All station assets must normalize to BranchAsset or InjectionAsset") + branch_mask = np.asarray([isinstance(asset, BranchAsset) for asset in normalized_assets], dtype=bool) + branch_assets = [ + asset if isinstance(asset, BranchAsset) else BranchAsset.model_validate(asset.model_dump()) + for asset, is_branch in zip(normalized_assets, branch_mask, strict=True) + if is_branch + ] + branch_ends = [branch_end for branch_end, is_branch in zip(asset_branch_ends, branch_mask, strict=True) if is_branch] + branch_switching_table = switching_matrix[:, branch_mask] + branch_connectivity = asset_connectivity[:, branch_mask] + + return branch_assets, branch_ends, branch_switching_table, branch_connectivity + + +def _get_injection_station_assets_from_df( + station_elements: pd.DataFrame, + asset_branch_ends: list[str | None], + switching_matrix: np.ndarray, + asset_connectivity: np.ndarray, +) -> tuple[list[InjectionAsset], list[str | None], np.ndarray, np.ndarray]: + """Return injection station assets and aligned arrays from prepared station inputs. + + Parameters + ---------- + station_elements : pd.DataFrame + Normalized station asset rows. + asset_branch_ends : list[str | None] + Branch-end labels aligned with `station_elements`. + switching_matrix : np.ndarray + Station-local switching matrix aligned with `station_elements`. + asset_connectivity : np.ndarray + Connectivity matrix aligned with `station_elements`. + + Returns + ------- + injection_assets : list[InjectionAsset] + Materialized injection assets for the station. + injection_branch_ends : list[str | None] + Branch-end labels aligned with `injection_assets`. + injection_switching_table : np.ndarray + Injection-only switching table. + injection_connectivity : np.ndarray + Injection-only connectivity table. + """ + normalized_assets = [ + normalize_switchable_asset_payload(switchable_asset) + for switchable_asset in station_elements.to_dict(orient="records") + ] + if any(not isinstance(asset, (BranchAsset, InjectionAsset)) for asset in normalized_assets): + raise ValueError("All station assets must normalize to BranchAsset or InjectionAsset") + injection_mask = np.asarray([isinstance(asset, InjectionAsset) for asset in normalized_assets], dtype=bool) + injection_assets = [ + asset if isinstance(asset, InjectionAsset) else InjectionAsset.model_validate(asset.model_dump()) + for asset, is_injection in zip(normalized_assets, injection_mask, strict=True) + if is_injection + ] + injection_branch_ends = [ + branch_end for branch_end, is_injection in zip(asset_branch_ends, injection_mask, strict=True) if is_injection + ] + injection_switching_table = switching_matrix[:, injection_mask] + injection_connectivity = asset_connectivity[:, injection_mask] + + return injection_assets, injection_branch_ends, injection_switching_table, injection_connectivity + + +def _get_single_topology_kind(buses_with_substation_and_voltage: pd.DataFrame) -> str: + """Return the shared topology kind for all relevant stations or raise on mixed input. + + Parameters + ---------- + buses_with_substation_and_voltage : pd.DataFrame + Relevant station rows including the `topology_kind` column. + + Returns + ------- + str + The single topology kind shared by all relevant stations. + + Raises + ------ + ValueError + If the relevant stations contain a mix of topology kinds. + """ + topology_kinds = buses_with_substation_and_voltage["topology_kind"].unique() + if len(topology_kinds) != 1: + raise ValueError( + "Relevant stations must be either of kind NODE_BREAKER or BUS_BREAKER, a mix of both is not permitted" + ) + return str(topology_kinds[0]) def get_bus_info_from_topology(station_buses: pd.DataFrame, bus_id: str) -> pd.DataFrame: @@ -239,7 +401,7 @@ def get_coupler_info_from_topology( # rename the columns to match the pydantic model coupler_elements.rename( columns={ - "kind": "type", + "kind": "coupler_type", "bus1_id": "busbar_from_id", "bus2_id": "busbar_to_id", "id": "grid_model_id", @@ -336,7 +498,8 @@ def get_asset_info_from_topology( station_elements = station_elements[station_elements["bus_id"].isin(station_buses["grid_model_id"])] switching_matrix = get_asset_switching_table(station_buses=station_buses, station_elements=station_elements) # get columns for pydantic model - station_elements = station_elements[["grid_model_id", "type", "name", "in_service"]].reset_index(drop=True) + station_elements = station_elements.rename(columns={"type": "asset_type"}) + station_elements = station_elements[["grid_model_id", "asset_type", "name", "in_service"]].reset_index(drop=True) return station_elements, switching_matrix @@ -367,14 +530,15 @@ def get_relevant_network_data( relevant_buses = network.get_buses(attributes=["voltage_level_id"]).loc[relevant_stations] voltage_level_df = get_voltage_level_with_region(network, attributes=["substation_id", "nominal_v", "topology_kind"]) buses_with_substation_and_voltage = relevant_buses.merge(voltage_level_df, left_on="voltage_level_id", right_index=True) - if buses_with_substation_and_voltage["topology_kind"].unique() == "BUS_BREAKER": + topology_kind = _get_single_topology_kind(buses_with_substation_and_voltage) + if topology_kind == "BUS_BREAKER": if "elementName" in network.get_lines(all_attributes=True).columns: # For UCTE models, the name is stored in elementName element_name_col = "elementName" else: # All other grid files use normally "name" element_name_col = "name" - elif buses_with_substation_and_voltage["topology_kind"].unique() == "NODE_BREAKER": + elif topology_kind == "NODE_BREAKER": element_name_col = "name" else: raise ValueError( @@ -390,7 +554,7 @@ def get_relevant_network_data( def get_relevant_stations( network: Network, relevant_stations: Union[list[str], Bool[np.ndarray, " n_buses"]] -) -> list[Station]: +) -> list[RawStation]: """Get all relevant stations from the network. Parameters @@ -403,8 +567,8 @@ def get_relevant_stations( Returns ------- - station: list[Station] - List of all formatted stations of the relevant buses in the network + station: list[RawStation] + List of all lean topology stations of the relevant buses in the network """ # Load relevant data once buses_with_substation_and_voltage, switches, dangling_lines, element_names = get_relevant_network_data( @@ -413,59 +577,107 @@ def get_relevant_stations( ) # Calculate the pydantic station for each relevant bus - station_list = get_list_of_stations(network, buses_with_substation_and_voltage, switches, dangling_lines, element_names) - return station_list + raw_stations, _, _ = get_raw_stations_and_assets( + network, buses_with_substation_and_voltage, switches, dangling_lines, element_names + ) + return raw_stations -def get_list_of_stations( +def get_raw_stations_and_assets( network: Network, buses_with_substation_and_voltage: pd.DataFrame, switches: pd.DataFrame, dangling_lines: pd.DataFrame, element_names: pd.Series, -) -> list[Station]: - """Get the list of stations from the relevant buses. +) -> tuple[list[RawStation], list[BranchAsset], list[InjectionAsset]]: + """Build raw topology stations and topology-owned assets from the relevant buses. Parameters ---------- - network: Network + network : Network pypowsybl network object - buses_with_substation_and_voltage: pd.DataFrame + buses_with_substation_and_voltage : pd.DataFrame DataFrame with the relevant buses, substation id and voltage level - switches: pd.DataFrame + switches : pd.DataFrame DataFrame with all the switches in the network. Includes the column "name" - dangling_lines: pd.DataFrame + dangling_lines : pd.DataFrame DataFrame with all the dangling lines in the network. Includes the column "tie_line_id" - element_names: pd.Series + element_names : pd.Series Series with the names of all injections and branches in the network and their ids as index Returns ------- - station_list: list[Station] - List of all formatted stations of the relevant buses in the network + station_list : list[RawStation] + List of all lean topology stations of the relevant buses in the network + branch_assets : list[BranchAsset] + Deduplicated topology-owned branch assets referenced by the stations + injection_assets : list[InjectionAsset] + Deduplicated topology-owned injection assets referenced by the stations """ - station_list = [] + station_list: list[RawStation] = [] + all_branch_assets: list[BranchAsset] = [] + all_injection_assets: list[InjectionAsset] = [] for bus_id, bus_info in buses_with_substation_and_voltage.iterrows(): station_topology = network.get_bus_breaker_topology(bus_info.voltage_level_id) station_buses = get_bus_info_from_topology(station_topology.buses, bus_id) coupler_elements = get_coupler_info_from_topology(station_topology.switches, switches, station_buses) - station_elements, switching_matrix = get_asset_info_from_topology( - station_topology.elements, station_buses, dangling_lines, element_names + station_elements, asset_terminals, switching_matrix, asset_connectivity = _get_station_asset_inputs_from_topology( + station_topology.elements, + station_buses, + dangling_lines, + element_names, + ) + ( + branch_assets, + branch_terminals, + branch_switching_table, + branch_connectivity, + ) = _get_branch_station_assets_from_df( + station_elements, + asset_terminals, + switching_matrix, + asset_connectivity, + ) + ( + injection_assets, + injection_terminals, + injection_switching_table, + injection_connectivity, + ) = _get_injection_station_assets_from_df( + station_elements, + asset_terminals, + switching_matrix, + asset_connectivity, ) - asset_connectivity = np.ones_like(switching_matrix, dtype=bool) - station = Station( + all_branch_assets.extend(branch_assets) + all_injection_assets.extend(injection_assets) + + station = RawStation( grid_model_id=bus_id, name=bus_info.substation_id, region=bus_info.voltage_level_id[0:2], voltage_level=bus_info.nominal_v, busbars=get_list_of_busbars_from_df(station_buses), couplers=get_list_of_coupler_from_df(coupler_elements), - assets=get_list_of_switchable_assets_from_df(station_elements), - asset_switching_table=switching_matrix, - asset_connectivity=asset_connectivity, + branch_connections=[ + StationAssetConnection(asset_id=asset.grid_model_id, branch_end=asset_terminal, asset_bay_id=None) + for asset, asset_terminal in zip(branch_assets, branch_terminals, strict=True) + ], + injection_connections=[ + StationAssetConnection(asset_id=asset.grid_model_id, branch_end=asset_terminal, asset_bay_id=None) + for asset, asset_terminal in zip(injection_assets, injection_terminals, strict=True) + ], + branch_switching_table=branch_switching_table, + injection_switching_table=injection_switching_table, + branch_connectivity=branch_connectivity, + injection_connectivity=injection_connectivity, ) station_list.append(station) - return station_list + return ( + station_list, + _dedupe_assets_by_id(all_branch_assets), + _dedupe_assets_by_id(all_injection_assets), + ) def get_topology( @@ -493,39 +705,55 @@ def get_topology( topology: Topology Topology object, including all relevant stations """ - station_list = get_relevant_stations(network=network, relevant_stations=relevant_stations) + buses_with_substation_and_voltage, switches, dangling_lines, element_names = get_relevant_network_data( + network=network, + relevant_stations=relevant_stations, + ) + raw_stations, branch_assets, injection_assets = get_raw_stations_and_assets( + network, buses_with_substation_and_voltage, switches, dangling_lines, element_names + ) timestamp = datetime.datetime.now() return Topology( topology_id=topology_id, grid_model_file=grid_model_file, - stations=station_list, + raw_stations=raw_stations, + branch_assets=branch_assets, + injection_assets=injection_assets, + asset_bays=[], timestamp=timestamp, ) -def get_stations_bus_breaker(net: Network) -> list[Station]: - """Convert all stations in a bus-breaker topology grid to the asset topology format. +def get_raw_stations_and_assets_bus_breaker( + net: Network, +) -> tuple[list[RawStation], list[BranchAsset], list[InjectionAsset]]: + """Convert a bus-breaker topology grid to raw topology stations and topology-owned assets. - This is very similar to get_topology but only works for bus-breaker grids. This is mainly used for the test grids. - TODO find out why get_topology didn't work and remove either of the two. + This is mainly used for fixture and test-grid extraction. Parameters ---------- - net: Network + net : Network The bus/breaker powsybl network to convert Returns ------- - stations: list[Station] - List of all stations in the network + stations : list[RawStation] + List of all lean topology stations in the network + branch_assets : list[BranchAsset] + Deduplicated topology-owned branch assets referenced by the raw stations + injection_assets : list[InjectionAsset] + Deduplicated topology-owned injection assets referenced by the raw stations """ all_switches = net.get_switches(all_attributes=True) all_branches = net.get_branches(all_attributes=True) all_injections = net.get_injections(all_attributes=True) all_breaker_buses = net.get_bus_breaker_view_buses(all_attributes=True) - stations = [] + stations: list[RawStation] = [] + all_branch_assets: list[BranchAsset] = [] + all_injection_assets: list[InjectionAsset] = [] for bus_id, bus_row in net.get_buses().iterrows(): local_buses = all_breaker_buses[all_breaker_buses["bus_id"] == bus_id] local_switches = all_switches[ @@ -551,6 +779,7 @@ def get_stations_bus_breaker(net: Network) -> list[Station]: couplers = [ BusbarCoupler( grid_model_id=grid_model_id, + coupler_type=switch.kind, busbar_from_id=busbar_mapper[switch.bus_breaker_bus1_id], busbar_to_id=busbar_mapper[switch.bus_breaker_bus2_id], open=switch.open, @@ -558,44 +787,60 @@ def get_stations_bus_breaker(net: Network) -> list[Station]: for grid_model_id, switch in local_switches.iterrows() ] from_branch_assets = [ - SwitchableAsset(grid_model_id=grid_model_id, type=branch.type, branch_end="from") + BranchAsset(grid_model_id=grid_model_id, asset_type=branch.type) for grid_model_id, branch in from_branches.iterrows() ] to_branch_assets = [ - SwitchableAsset(grid_model_id=grid_model_id, type=branch.type, branch_end="to") + BranchAsset(grid_model_id=grid_model_id, asset_type=branch.type) for grid_model_id, branch in to_branches.iterrows() ] injection_assets = [ - SwitchableAsset(grid_model_id=grid_model_id, type=injection.type) + InjectionAsset(grid_model_id=grid_model_id, asset_type=injection.type) for grid_model_id, injection in injections.iterrows() ] - assets = from_branch_assets + to_branch_assets + injection_assets - from_branch_bus_index = [busbar_mapper[branch.bus_breaker_bus1_id] for branch in from_branches.itertuples()] - to_branch_bus_index = [busbar_mapper[branch.bus_breaker_bus2_id] for branch in to_branches.itertuples()] + branch_assets = from_branch_assets + to_branch_assets + branch_terminals = ["from"] * len(from_branch_assets) + ["to"] * len(to_branch_assets) + branch_bus_index = [busbar_mapper[branch.bus_breaker_bus1_id] for branch in from_branches.itertuples()] + [ + busbar_mapper[branch.bus_breaker_bus2_id] for branch in to_branches.itertuples() + ] injection_bus_index = [busbar_mapper[injection.bus_breaker_bus_id] for injection in injections.itertuples()] - bus_index = from_branch_bus_index + to_branch_bus_index + injection_bus_index - switching_table = np.zeros((len(busbars), len(assets)), dtype=bool) - for asset_index, idx in enumerate(bus_index): - switching_table[idx, asset_index] = True + branch_switching_table = np.zeros((len(busbars), len(branch_assets)), dtype=bool) + for asset_index, idx in enumerate(branch_bus_index): + branch_switching_table[idx, asset_index] = True + + injection_switching_table = np.zeros((len(busbars), len(injection_assets)), dtype=bool) + for asset_index, idx in enumerate(injection_bus_index): + injection_switching_table[idx, asset_index] = True + + all_branch_assets.extend(branch_assets) + all_injection_assets.extend(injection_assets) - station = Station( + station = RawStation( grid_model_id=bus_id, name=bus_row.name, busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=switching_table, + branch_connections=[ + StationAssetConnection(asset_id=asset.grid_model_id, branch_end=asset_terminal, asset_bay_id=None) + for asset, asset_terminal in zip(branch_assets, branch_terminals, strict=True) + ], + injection_connections=[ + StationAssetConnection(asset_id=asset.grid_model_id, branch_end=None, asset_bay_id=None) + for asset in injection_assets + ], + branch_switching_table=branch_switching_table, + injection_switching_table=injection_switching_table, ) stations.append(station) - return stations + return stations, _dedupe_assets_by_id(all_branch_assets), _dedupe_assets_by_id(all_injection_assets) # TODO: refactor due to C901 def assert_station_in_network( # noqa: C901 net: Network, - station: Station, + station: MaterializedStation, couplers_strict: bool = True, assets_strict: bool = True, busbars_strict: bool = True, @@ -631,12 +876,14 @@ def assert_station_in_network( # noqa: C901 raise ValueError(f"Station {station.grid_model_id} not found in the network") bus_breaker_topo = net.get_bus_breaker_topology(buses_df.loc[station.grid_model_id]["voltage_level_id"]) + station_connections = [*station.branch_connections, *station.injection_connections] - for asset in station.assets: + for asset_connection in station_connections: + asset = asset_connection.asset if asset.grid_model_id not in bus_breaker_topo.elements.index: raise ValueError(f"Asset {asset.grid_model_id} not found in the station elements: {bus_breaker_topo.elements}") - if assets_strict and len(bus_breaker_topo.elements) != len(station.assets): - raise ValueError(f"Asset count mismatch: {len(bus_breaker_topo.elements)} != {len(station.assets)}") + if assets_strict and len(bus_breaker_topo.elements) != len(station_connections): + raise ValueError(f"Asset count mismatch: {len(bus_breaker_topo.elements)} != {len(station_connections)}") for busbar in station.busbars: if busbar.grid_model_id not in bus_breaker_topo.buses.index: diff --git a/packages/grid_helpers_pkg/tests/conftest.py b/packages/grid_helpers_pkg/tests/conftest.py index df20da0e7..34aab2fe4 100644 --- a/packages/grid_helpers_pkg/tests/conftest.py +++ b/packages/grid_helpers_pkg/tests/conftest.py @@ -15,7 +15,7 @@ from pandapower import pp_dir from pandapower.converter import to_mpc from toop_engine_grid_helpers.powsybl.example_grids import case14_matching_asset_topo_powsybl -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS diff --git a/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_asset_topo.py b/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_asset_topo.py index 4b13c1f8f..10404c94a 100644 --- a/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_asset_topo.py +++ b/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_asset_topo.py @@ -13,6 +13,8 @@ import pytest from toop_engine_grid_helpers.powsybl.example_grids import basic_node_breaker_network_powsybl, create_busbar_b_in_ieee from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import ( + _get_branch_station_assets_from_df, + _get_injection_station_assets_from_df, assert_station_in_network, get_all_element_names, get_asset_info_from_topology, @@ -21,14 +23,17 @@ get_coupler_info_from_topology, get_list_of_busbars_from_df, get_list_of_coupler_from_df, - get_list_of_switchable_assets_from_df, get_name_of_station_elements, + get_raw_stations_and_assets_bus_breaker, get_relevant_network_data, get_relevant_stations, - get_stations_bus_breaker, get_topology, ) -from toop_engine_interfaces.asset_topology import Busbar, BusbarCoupler, Station, SwitchableAsset, Topology +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, + Topology, +) +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler, InjectionAsset from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS @@ -81,7 +86,7 @@ def test_get_list_of_coupler_from_df(): data={ "grid_model_id": ["coupler1", "coupler2"], "bus_int_id": [1, 2], - "type": ["type1", "type2"], + "coupler_type": ["type1", "type2"], "name": ["name1", "name2"], "busbar_from_id": [1, 2], "busbar_to_id": [2, 3], @@ -92,7 +97,7 @@ def test_get_list_of_coupler_from_df(): expected_coupler_list = [ BusbarCoupler( grid_model_id="coupler1", - type="type1", + coupler_type="type1", name="name1", busbar_from_id=1, busbar_to_id=2, @@ -101,7 +106,7 @@ def test_get_list_of_coupler_from_df(): ), BusbarCoupler( grid_model_id="coupler2", - type="type2", + coupler_type="type2", name="name2", busbar_from_id=2, busbar_to_id=3, @@ -113,34 +118,71 @@ def test_get_list_of_coupler_from_df(): assert coupler_list == expected_coupler_list -def test_get_list_of_switchable_assets_from_df(): +def test_get_branch_and_injection_station_assets_from_df(monkeypatch: pytest.MonkeyPatch): asset_elements = pd.DataFrame( data={ - "grid_model_id": ["asset1", "asset2"], - "bus_int_id": [1, 2], - "type": ["TIE_LINE", "LINE"], - "name": ["name1", "name2"], - "in_service": [True, True], + "grid_model_id": ["asset1", "asset2", "asset3"], + "asset_type": ["TIE_LINE", "LINE", "LOAD"], + "name": ["name1", "name2", "name3"], + "in_service": [True, True, True], } ) - expected_asset_list = [ - SwitchableAsset( + asset_terminals = ["from", "to", None] + switching_matrix = np.array( + [ + [True, False, False], + [False, True, True], + ], + dtype=bool, + ) + asset_connectivity = np.ones_like(switching_matrix, dtype=bool) + + expected_branch_assets = [ + BranchAsset( grid_model_id="asset1", - type="TIE_LINE", + asset_type="TIE_LINE", name="name1", - bus_int_id=1, in_service=True, ), - SwitchableAsset( + BranchAsset( grid_model_id="asset2", - type="LINE", + asset_type="LINE", name="name2", - bus_int_id=2, in_service=True, ), ] - asset_list = get_list_of_switchable_assets_from_df(asset_elements) - assert asset_list == expected_asset_list + expected_injection_assets = [ + InjectionAsset( + grid_model_id="asset3", + asset_type="LOAD", + name="name3", + in_service=True, + ) + ] + + branch_assets, branch_terminals, branch_switching_table, branch_connectivity = _get_branch_station_assets_from_df( + asset_elements, + asset_terminals, + switching_matrix, + asset_connectivity, + ) + injection_assets, injection_terminals, injection_switching_table, injection_connectivity = ( + _get_injection_station_assets_from_df( + asset_elements, + asset_terminals, + switching_matrix, + asset_connectivity, + ) + ) + + assert branch_assets == expected_branch_assets + assert injection_assets == expected_injection_assets + assert branch_terminals == ["from", "to"] + assert injection_terminals == [None] + assert np.array_equal(branch_switching_table, switching_matrix[:, :2]) + assert np.array_equal(injection_switching_table, switching_matrix[:, 2:]) + assert np.array_equal(branch_connectivity, asset_connectivity[:, :2]) + assert np.array_equal(injection_connectivity, asset_connectivity[:, 2:]) def test_get_list_of_busbars_from_df(): @@ -148,7 +190,7 @@ def test_get_list_of_busbars_from_df(): data={ "grid_model_id": ["busbar1", "busbar2"], "int_id": [1, 2], - "type": ["type1", "type2"], + "busbar_type": ["type1", "type2"], "name": ["name1", "name2"], "in_service": [True, True], } @@ -157,14 +199,14 @@ def test_get_list_of_busbars_from_df(): Busbar( grid_model_id="busbar1", int_id=1, - type="type1", + busbar_type="type1", name="name1", in_service=True, ), Busbar( grid_model_id="busbar2", int_id=2, - type="type2", + busbar_type="type2", name="name2", in_service=True, ), @@ -228,7 +270,7 @@ def test_get_asset_info_from_topology(): index=[0, 1], data={ "grid_model_id": ["line1", "tie_line1"], - "type": ["LINE", "TIE_LINE"], + "asset_type": ["LINE", "TIE_LINE"], "name": ["line_name", "tie"], "in_service": [True, True], }, @@ -270,7 +312,7 @@ def test_get_coupler_info_from_topology(): "busbar_from_id": [0, 0], "busbar_to_id": [1, 1], "open": [True, True], - "type": ["BREAKER", "DISCONNECTOR"], + "coupler_type": ["BREAKER", "DISCONNECTOR"], "name": ["break_1", "disco_1"], "in_service": [True, True], }, @@ -341,7 +383,7 @@ def test_get_relevant_stations(ucte_file: Path): stations = get_relevant_stations(network, relevant_subs) assert len(stations) == sum(relevant_subs), "Wrong number of stations" - assert isinstance(stations[0], Station), "Wrong type of station" + assert isinstance(stations[0], RawStation), "Wrong type of station" def test_get_topology_ucte(ucte_file: Path): @@ -351,7 +393,7 @@ def test_get_topology_ucte(ucte_file: Path): topology = get_topology(network, relevant_subs, grid_model_file="booga", topology_id="wooga") assert isinstance(topology, Topology), "Wrong type of topology" - assert len(topology.stations) == sum(relevant_subs), "Wrong number of stations" + assert len(topology.materialize_stations()) == sum(relevant_subs), "Wrong number of stations" assert topology.grid_model_file == "booga" assert topology.topology_id == "wooga" @@ -389,11 +431,12 @@ def test_assert_station_in_network(case14_data_with_asset_topo: tuple[Path, Topo grid_path, topology = case14_data_with_asset_topo net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) - for station in topology.stations: + stations = topology.materialize_stations() + for station in stations: assert_station_in_network(net, station) # Change the station ID - station = topology.stations[0].model_copy(update={"grid_model_id": "hugawuga"}) + station = stations[0].model_copy(update={"grid_model_id": "hugawuga"}) with pytest.raises(ValueError, match="Station hugawuga not found in the network"): assert_station_in_network(net, station) @@ -403,11 +446,11 @@ def test_assert_station_in_network_coupler(case14_data_with_asset_topo: tuple[Pa net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) # Add a coupler to the station that is not in the grid - station = topology.stations[0].model_copy( + station = topology.materialize_stations()[0].model_copy( update={ "couplers": [ - topology.stations[0].couplers[0], - topology.stations[0].couplers[0].model_copy(update={"grid_model_id": "hugawuga"}), + topology.materialize_stations()[0].couplers[0], + topology.materialize_stations()[0].couplers[0].model_copy(update={"grid_model_id": "hugawuga"}), ], } ) @@ -416,7 +459,7 @@ def test_assert_station_in_network_coupler(case14_data_with_asset_topo: tuple[Pa assert_station_in_network(net, station) # Remove a coupler from the station - station = topology.stations[0].model_copy( + station = topology.materialize_stations()[0].model_copy( update={ "couplers": [], } @@ -430,17 +473,29 @@ def test_assert_station_in_network_coupler(case14_data_with_asset_topo: tuple[Pa def test_assert_station_in_network_busbar(case14_data_with_asset_topo: tuple[Path, Topology]) -> None: grid_path, topology = case14_data_with_asset_topo net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) + base_station = topology.materialize_stations()[0] # Add a busbar to the station that is not in the grid - station = topology.stations[0].model_copy( + station = base_station.model_copy( update={ "busbars": [ - topology.stations[0].busbars[0], - topology.stations[0].busbars[1], - topology.stations[0].busbars[0].model_copy(update={"grid_model_id": "hugawuga", "int_id": 3}), + base_station.busbars[0], + base_station.busbars[1], + base_station.busbars[0].model_copy(update={"grid_model_id": "hugawuga", "int_id": 3}), ], - "asset_switching_table": np.concatenate( - [topology.stations[0].asset_switching_table, topology.stations[0].asset_switching_table[0:1]], axis=0 + "branch_switching_table": np.concatenate( + [ + base_station.branch_switching_table, + base_station.branch_switching_table[0:1], + ], + axis=0, + ), + "injection_switching_table": np.concatenate( + [ + base_station.injection_switching_table, + base_station.injection_switching_table[0:1], + ], + axis=0, ), } ) @@ -448,13 +503,21 @@ def test_assert_station_in_network_busbar(case14_data_with_asset_topo: tuple[Pat assert_station_in_network(net, station) # Remove a busbar from the station - station = topology.stations[0].model_copy( - update={ - "busbars": [ - topology.stations[0].busbars[0], - ], - "asset_switching_table": topology.stations[0].asset_switching_table[0:1], - } + station = type(base_station).model_construct( + grid_model_id=base_station.grid_model_id, + name=base_station.name, + station_type=base_station.station_type, + region=base_station.region, + voltage_level=base_station.voltage_level, + busbars=[base_station.busbars[0]], + couplers=base_station.couplers, + branch_connections=base_station.branch_connections, + injection_connections=base_station.injection_connections, + branch_switching_table=base_station.branch_switching_table[0:1], + injection_switching_table=base_station.injection_switching_table[0:1], + branch_connectivity=None, + injection_connectivity=None, + model_log=base_station.model_log, ) # Should pass without strict assert_station_in_network(net, station, busbars_strict=False) @@ -467,27 +530,69 @@ def test_assert_station_in_network_asset(case14_data_with_asset_topo: tuple[Path net = pypowsybl.network.load(grid_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]) # Add a switchable asset to the station that is not in the grid - station = topology.stations[0].model_copy( - update={ - "assets": topology.stations[0].assets - + [ - topology.stations[0].assets[0].model_copy(update={"grid_model_id": "hugawuga"}), - ], - "asset_switching_table": np.concatenate( - [topology.stations[0].asset_switching_table, topology.stations[0].asset_switching_table[:, 0:1]], axis=1 - ), - } - ) + base_station = topology.materialize_stations()[0] + if base_station.branch_connections: + station = base_station.model_copy( + update={ + "branch_connections": base_station.branch_connections + + [ + base_station.branch_connections[0].model_copy( + update={ + "asset": base_station.branch_connections[0].asset.model_copy( + update={"grid_model_id": "hugawuga"} + ) + } + ), + ], + "branch_switching_table": np.concatenate( + [ + base_station.branch_switching_table, + base_station.branch_switching_table[:, 0:1], + ], + axis=1, + ), + } + ) + else: + station = base_station.model_copy( + update={ + "injection_connections": base_station.injection_connections + + [ + base_station.injection_connections[0].model_copy( + update={ + "asset": base_station.injection_connections[0].asset.model_copy( + update={"grid_model_id": "hugawuga"} + ) + } + ), + ], + "injection_switching_table": np.concatenate( + [ + base_station.injection_switching_table, + base_station.injection_switching_table[:, 0:1], + ], + axis=1, + ), + } + ) with pytest.raises(ValueError, match="Asset hugawuga not found in the station elements"): assert_station_in_network(net, station) # Remove a switchable asset from the station - station = topology.stations[0].model_copy( - update={ - "assets": topology.stations[0].assets[:-1], - "asset_switching_table": topology.stations[0].asset_switching_table[:, :-1], - } - ) + if base_station.branch_connections: + station = base_station.model_copy( + update={ + "branch_connections": base_station.branch_connections[:-1], + "branch_switching_table": base_station.branch_switching_table[:, :-1], + } + ) + else: + station = base_station.model_copy( + update={ + "injection_connections": base_station.injection_connections[:-1], + "injection_switching_table": base_station.injection_switching_table[:, :-1], + } + ) # Should pass without strict assert_station_in_network(net, station, assets_strict=False) with pytest.raises(ValueError, match="Asset count mismatch"): @@ -498,14 +603,20 @@ def test_convert_bus_breaker_stations_to_asset_topo() -> None: net = pypowsybl.network.create_ieee30() create_busbar_b_in_ieee(net) - stations = get_stations_bus_breaker(net) + stations, branch_assets, injection_assets = get_raw_stations_and_assets_bus_breaker(net) + assets = [*branch_assets, *injection_assets] assert len(stations) == 30 for station in stations: assert len(station.busbars) == 2 assert len(station.couplers) == 1 - for asset in station.assets: - if asset.is_branch(): - assert asset.grid_model_id in net.get_branches().index - else: - assert asset.grid_model_id in net.get_injections().index + for asset_id in [ + asset_connection.asset_id for asset_connection in [*station.branch_connections, *station.injection_connections] + ]: + assert any(asset.grid_model_id == asset_id for asset in assets) + + for asset in branch_assets: + assert asset.grid_model_id in net.get_branches().index + + for asset in injection_assets: + assert asset.grid_model_id in net.get_injections().index diff --git a/packages/importer_pkg/src/toop_engine_importer/exporter/asset_topology_to_ucte.py b/packages/importer_pkg/src/toop_engine_importer/exporter/asset_topology_to_ucte.py index 55823b880..3ab3caf5d 100644 --- a/packages/importer_pkg/src/toop_engine_importer/exporter/asset_topology_to_ucte.py +++ b/packages/importer_pkg/src/toop_engine_importer/exporter/asset_topology_to_ucte.py @@ -17,11 +17,14 @@ from pathlib import Path +import numpy as np import pandas as pd import structlog from beartype.typing import Optional, Union from toop_engine_importer.ucte_toolset.ucte_io import make_ucte, parse_ucte -from toop_engine_interfaces.asset_topology import BusbarCoupler, Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.assets import BusbarCoupler +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation logger = structlog.get_logger(__name__) @@ -103,7 +106,7 @@ def asset_topo_to_uct( if grid_model_file_input is None: grid_model_file_input = asset_topology.grid_model_file preamble, nodes, lines, trafos, trafo_reg, postamble = load_ucte(grid_model_file_input) - for station in asset_topology.stations: + for station in asset_topology.materialize_stations(): if station_list is not None and station.grid_model_id not in station_list: continue asset_change_df = pd.DataFrame(get_changes_from_switching_table(station)) @@ -280,7 +283,7 @@ def get_coupler_state_ucte(couplers: list[BusbarCoupler]) -> list[dict[str, Unio def get_changes_from_switching_table( - station: Station, + station: MaterializedStation, ) -> list[dict[str, Union[str, None]]]: """Get changes from switching table. @@ -295,14 +298,17 @@ def get_changes_from_switching_table( List of tuples with asset name, initial_busbar and final_busbar Note: initial_busbar and final_busbar can both be None if asset is disconnected """ - switching_table = station.asset_switching_table + switching_table = np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1) busbar_name_list = [busbar.grid_model_id for busbar in station.busbars] - asset_list = station.assets + asset_list = [ + *(asset_connection.asset for asset_connection in station.branch_connections), + *(asset_connection.asset for asset_connection in station.injection_connections), + ] change_list = [] # TODO: make a dataclass for this (code style) # loop over assets -> by column for asset_index, asset_in_table in enumerate(switching_table.T): asset_name = asset_list[asset_index].grid_model_id - asset_type = asset_list[asset_index].type + asset_type = asset_list[asset_index].asset_type if asset_in_table.sum() > 1: raise ValueError( f"Asset {asset_list[asset_index].grid_model_id} is connected to multiple" diff --git a/packages/importer_pkg/src/toop_engine_importer/network_graph/data_classes.py b/packages/importer_pkg/src/toop_engine_importer/network_graph/data_classes.py index 019aa9e79..748fcc9e6 100644 --- a/packages/importer_pkg/src/toop_engine_importer/network_graph/data_classes.py +++ b/packages/importer_pkg/src/toop_engine_importer/network_graph/data_classes.py @@ -382,7 +382,7 @@ class SwitchableAssetSchema(ValidationOnlyDataFrameModel): name: pat.Series[str] """The name of the asset.""" - type: pat.Series[str] + asset_type: pat.Series[str] """The type of the asset, e.g. LINE, TWO_WINDING_TRANSFORMER, etc.""" in_service: pat.Series[bool] diff --git a/packages/importer_pkg/src/toop_engine_importer/network_graph/graph_to_asset_topo.py b/packages/importer_pkg/src/toop_engine_importer/network_graph/graph_to_asset_topo.py index 4477fc61a..65fc08048 100644 --- a/packages/importer_pkg/src/toop_engine_importer/network_graph/graph_to_asset_topo.py +++ b/packages/importer_pkg/src/toop_engine_importer/network_graph/graph_to_asset_topo.py @@ -13,7 +13,8 @@ import pandera.typing as pat import structlog from beartype.typing import Literal, Optional, Union -from jaxtyping import ArrayLike, Bool +from jaxtyping import Bool +from numpy.typing import ArrayLike from toop_engine_importer.network_graph.data_classes import ( BranchSchema, BusbarConnectionInfo, @@ -23,8 +24,9 @@ SwitchableAssetSchema, SwitchSchema, ) -from toop_engine_interfaces.asset_topology import ( +from toop_engine_interfaces.asset_topology.assets import ( AssetBay, + build_asset_bay_id, ) logger = structlog.get_logger(__name__) @@ -51,11 +53,11 @@ def get_busbar_df(nodes_df: pat.DataFrame[NodeSchema], substation_id: str) -> pd busbar_df = ( busbar_df.sort_values(by="grid_model_id") .reset_index() - .rename(columns={"foreign_id": "name", "node_type": "type", "bus_id": "bus_branch_bus_id"}) + .rename(columns={"foreign_id": "name", "node_type": "busbar_type", "bus_id": "bus_branch_bus_id"}) ) busbar_df["int_id"] = busbar_df.index - busbar_df = busbar_df[["grid_model_id", "type", "name", "int_id", "in_service", "bus_branch_bus_id"]] + busbar_df = busbar_df[["grid_model_id", "busbar_type", "name", "int_id", "in_service", "bus_branch_bus_id"]] return busbar_df @@ -102,7 +104,7 @@ def get_coupler_df( coupler_df = switches_connected_to_busbars[switches_connected_to_busbars["coupler_type"] != ""].copy() coupler_df["from_busbar_grid_model_id"] = "" coupler_df["to_busbar_grid_model_id"] = "" - coupler_df["type"] = "" + coupler_df["coupler_type"] = "" # hotfix in case a bay id has not been identified for a switch # a missing bay id indecates there is a data quality issue @@ -137,7 +139,7 @@ def get_coupler_df( out_of_service_busbar_ids=busbar_out_of_service, ignore_busbar_id=coupler_df.loc[index, "from_busbar_grid_model_id"], ) - coupler_df.loc[index, "type"] = row["asset_type"] + coupler_df.loc[index, "coupler_type"] = row["asset_type"] bay_state = get_state_of_coupler_based_on_bay( coupler_index=index, bay_df=bay_df, @@ -166,7 +168,9 @@ def get_coupler_df( suffixes=("", "_to"), ) coupler_df.rename(columns={"int_id": "busbar_to_id"}, inplace=True) - coupler_df = coupler_df[["grid_model_id", "type", "name", "in_service", "open", "busbar_from_id", "busbar_to_id"]] + coupler_df = coupler_df[ + ["grid_model_id", "coupler_type", "name", "in_service", "open", "busbar_from_id", "busbar_to_id"] + ] return coupler_df @@ -344,8 +348,8 @@ def get_switchable_asset( connected_asset_df["in_service"].notna(), connected_asset_df["in_service"], connected_asset_df["in_service_1"] ) # rename columns to match the AssetTopology - connected_asset_df.rename(columns={"asset_type": "type", "foreign_id": "name"}, inplace=True) - connected_asset_df = connected_asset_df[["grid_model_id", "name", "type", "in_service"]] + connected_asset_df.rename(columns={"foreign_id": "name"}, inplace=True) + connected_asset_df = connected_asset_df[["grid_model_id", "name", "asset_type", "in_service"]] # ensure the order of the assets connected_asset_df.sort_values(by="grid_model_id", inplace=True) connected_asset_df.reset_index(drop=True, inplace=True) @@ -583,6 +587,7 @@ def get_dv_sr_switch(asset_bays_df: pd.DataFrame) -> tuple[dict[str, str], list[ def get_asset_bay( switches_df: pat.DataFrame[SwitchSchema], + station_grid_model_id: str, asset_grid_model_id: str, busbar_df: pd.DataFrame, edge_connection_info: dict[str, EdgeConnectionInfo], @@ -594,6 +599,8 @@ def get_asset_bay( switches_df: pat.DataFrame[SwitchSchema] Dataframe with all switches of the substation. expects NetworkGraphData.switches + station_grid_model_id: str + Station identifier owning the asset bay. asset_grid_model_id: str Asset grid model id for which the asset bays should be retrieved. busbar_df: pd.DataFrame @@ -653,7 +660,10 @@ def get_asset_bay( f" AssetBay ignored for grid_model_id: {asset_grid_model_id}" ) return None, logs - return AssetBay(**asset_bay_dict), logs + return AssetBay( + asset_bay_id=build_asset_bay_id(station_grid_model_id, asset_grid_model_id), + **asset_bay_dict, + ), logs def get_station_connection_tables( diff --git a/packages/importer_pkg/src/toop_engine_importer/network_graph/network_graph_helper_functions.py b/packages/importer_pkg/src/toop_engine_importer/network_graph/network_graph_helper_functions.py index 599f2d6c8..77d9b2e8e 100644 --- a/packages/importer_pkg/src/toop_engine_importer/network_graph/network_graph_helper_functions.py +++ b/packages/importer_pkg/src/toop_engine_importer/network_graph/network_graph_helper_functions.py @@ -10,7 +10,7 @@ import pandas as pd from beartype.typing import Literal, TypeVar, get_args from toop_engine_importer.network_graph.data_classes import DUPLICATED_EDGE_SUFFIX -from toop_engine_interfaces.asset_topology import SwitchableAsset +from toop_engine_interfaces.asset_topology.assets import SwitchableAsset T = TypeVar("T") K = TypeVar("K") diff --git a/packages/importer_pkg/src/toop_engine_importer/network_graph/powsybl_station_to_graph.py b/packages/importer_pkg/src/toop_engine_importer/network_graph/powsybl_station_to_graph.py index b4d604e7e..5bdcd9f3e 100644 --- a/packages/importer_pkg/src/toop_engine_importer/network_graph/powsybl_station_to_graph.py +++ b/packages/importer_pkg/src/toop_engine_importer/network_graph/powsybl_station_to_graph.py @@ -19,7 +19,6 @@ get_all_element_names, get_list_of_busbars_from_df, get_list_of_coupler_from_df, - get_list_of_switchable_assets_from_df, ) from toop_engine_importer.network_graph.data_classes import ( HelperBranchSchema, @@ -52,12 +51,206 @@ get_voltage_level_with_region, ) from toop_engine_importer.pypowsybl_import.powsybl_masks import NetworkMasks -from toop_engine_interfaces.asset_topology import Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import ( + Topology, +) +from toop_engine_interfaces.asset_topology.assets import ( + AssetBay, + BranchAsset, + InjectionAsset, + normalize_switchable_asset_payload, +) +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.topology_conversion import topology_parts_from_materialized_station from toop_engine_interfaces.messages.preprocess.preprocess_commands import CgmesImporterParameters logger = structlog.get_logger(__name__) +def _expand_busbars_connected_via_switches( + seed_busbar_ids: set[str], full_busbar_connection_info: dict[str, object], allowed_busbar_ids: set[str] +) -> set[str]: + """Expand a bus view with busbars reachable through switchable couplers. + + Parameters + ---------- + seed_busbar_ids : set[str] + Initial busbar ids that seed the search. + full_busbar_connection_info : dict[str, object] + Mapping of busbar ids to connection metadata exposing ``connectable_busbars``. + allowed_busbar_ids : set[str] + Busbar ids that may be included in the expanded view. + + Returns + ------- + set[str] + Expanded set of reachable busbar ids restricted to ``allowed_busbar_ids``. + """ + selected_busbar_ids = set(seed_busbar_ids) + frontier = set(seed_busbar_ids) + + while frontier: + busbar_id = frontier.pop() + connection_info = full_busbar_connection_info.get(busbar_id) + if connection_info is None: + continue + connected_busbar_ids = set(connection_info.connectable_busbars) & allowed_busbar_ids + new_busbar_ids = connected_busbar_ids - selected_busbar_ids + selected_busbar_ids.update(new_busbar_ids) + frontier.update(new_busbar_ids) + + return selected_busbar_ids + + +def _get_station_busbar_view( + graph: nx.Graph, + graph_data: NetworkGraphData, + bus_id: str, + substation_id: str, +) -> tuple[pd.DataFrame, set[str], dict[str, object]]: + """Build the station-local busbar view and matching connection metadata. + + Parameters + ---------- + graph : nx.Graph + Graph representation of the node-breaker topology. + graph_data : NetworkGraphData + Graph data containing the station nodes used to derive busbars. + bus_id : str + Bus-branch bus identifier for the station view to build. + substation_id : str + Substation name used when deriving station-local busbars. + + Returns + ------- + tuple[pd.DataFrame, set[str], dict[str, object]] + The filtered busbar DataFrame with reassigned local integer ids, the selected + busbar ids, and the busbar connection info restricted to that station view. + """ + busbar_df = get_busbar_df(nodes_df=graph_data.nodes, substation_id=substation_id) + seed_busbar_ids = set(busbar_df.loc[busbar_df["bus_branch_bus_id"] == bus_id, "grid_model_id"]) + full_busbar_connection_info = get_busbar_connection_info(graph=graph) + selected_busbar_ids = _expand_busbars_connected_via_switches( + seed_busbar_ids=seed_busbar_ids, + full_busbar_connection_info=full_busbar_connection_info, + allowed_busbar_ids=set(busbar_df["grid_model_id"]), + ) + busbar_df = busbar_df[busbar_df["grid_model_id"].isin(selected_busbar_ids)].copy().reset_index(drop=True) + busbar_df["int_id"] = busbar_df.index + if busbar_df.empty: + raise ValueError(f"No busbars found for bus_id {bus_id} in substation {substation_id}") + + selected_busbar_ids = set(busbar_df["grid_model_id"]) + busbar_connection_info = { + busbar_grid_model_id: connection_info + for busbar_grid_model_id, connection_info in full_busbar_connection_info.items() + if busbar_grid_model_id in selected_busbar_ids + } + return busbar_df, selected_busbar_ids, busbar_connection_info + + +def _get_station_asset_bays( + switches_df: pd.DataFrame, + switchable_assets_df: pd.DataFrame, + busbar_df: pd.DataFrame, + edge_connection_info: dict[str, object], + substation_id: str, + selected_busbar_ids: set[str], +) -> tuple[dict[str, AssetBay], list[str]]: + """Build station-local asset bays filtered to the selected busbar view. + + Parameters + ---------- + switches_df : pd.DataFrame + Switch rows for the station topology. + switchable_assets_df : pd.DataFrame + Asset rows aligned with the station-local switching table. + busbar_df : pd.DataFrame + Filtered station-local busbar view. + edge_connection_info : dict[str, object] + Edge metadata used by ``get_asset_bay(...)`` to derive bay paths. + substation_id : str + Substation identifier owning the station-local asset bays. + selected_busbar_ids : set[str] + Busbar ids kept in the station-local busbar view. + + Returns + ------- + tuple[dict[str, AssetBay], list[str]] + Asset bays keyed by asset grid model id and the collected station log messages. + """ + station_logs: list[str] = [] + asset_bays_by_asset_id: dict[str, AssetBay] = {} + for asset_grid_model_id in switchable_assets_df["grid_model_id"].to_list(): + asset_bay, logs = get_asset_bay( + switches_df, + station_grid_model_id=substation_id, + asset_grid_model_id=asset_grid_model_id, + busbar_df=busbar_df, + edge_connection_info=edge_connection_info, + ) + station_logs.extend(logs) + if asset_bay is None: + continue + + station_local_sr_switches = { + busbar_grid_model_id: switch_grid_model_id + for busbar_grid_model_id, switch_grid_model_id in asset_bay.sr_switch_grid_model_id.items() + if busbar_grid_model_id in selected_busbar_ids + } + if len(station_local_sr_switches) == 0: + continue + + asset_bays_by_asset_id[asset_grid_model_id] = asset_bay.model_copy( + update={"sr_switch_grid_model_id": station_local_sr_switches}, + deep=True, + ) + + return asset_bays_by_asset_id, station_logs + + +def _get_station_asset_connections( + switchable_assets_df: pd.DataFrame, + asset_bays_by_asset_id: dict[str, AssetBay], +) -> tuple[list[MaterializedAssetConnection], list[MaterializedAssetConnection], list[bool]]: + """Materialize branch and injection connections aligned with station tables. + + Parameters + ---------- + switchable_assets_df : pd.DataFrame + Asset rows aligned with the station-local switching table. + asset_bays_by_asset_id : dict[str, AssetBay] + Station-local asset bays keyed by asset grid model id. + + Returns + ------- + tuple[list[MaterializedAssetConnection], list[MaterializedAssetConnection], list[bool]] + Branch connections, injection connections, and the branch mask aligned with + ``switchable_assets_df``. + """ + assets = [ + normalize_switchable_asset_payload(asset_payload) for asset_payload in switchable_assets_df.to_dict(orient="records") + ] + remove_suffix_from_switchable_assets(assets) + + branch_mask = [isinstance(asset, BranchAsset) for asset in assets] + if any(not isinstance(asset, (BranchAsset, InjectionAsset)) for asset in assets): + raise ValueError("All station assets must normalize to BranchAsset or InjectionAsset") + branch_connections: list[MaterializedAssetConnection] = [] + injection_connections: list[MaterializedAssetConnection] = [] + for asset, is_branch in zip(assets, branch_mask, strict=True): + connection = MaterializedAssetConnection( + asset=asset, + asset_bay=asset_bays_by_asset_id.get(asset.grid_model_id), + ) + if is_branch: + branch_connections.append(connection) + else: + injection_connections.append(connection) + + return branch_connections, injection_connections, branch_mask + + def node_breaker_topology_to_graph_data(net: Network, substation_info: SubstationInformation) -> NetworkGraphData: """Convert a node breaker topology to a NetworkGraph. @@ -265,7 +458,7 @@ def get_node_assets(nodes_df: pd.DataFrame, all_names_df: pd.Series) -> pat.Data return node_assets_df -def get_station(network: Network, bus_id: str, station_info: SubstationInformation) -> Station: +def get_station(network: Network, bus_id: str, station_info: SubstationInformation) -> MaterializedStation: """Get the station from a pypowsybl network. Parameters @@ -288,25 +481,39 @@ def get_station(network: Network, bus_id: str, station_info: SubstationInformati graph_data = node_breaker_topology_to_graph_data(network, substation_info=station_info) graph = get_node_breaker_topology_graph(graph_data) - busbar_df = get_busbar_df(nodes_df=graph_data.nodes, substation_id=substation_id) + busbar_df, selected_busbar_ids, busbar_connection_info = _get_station_busbar_view( + graph=graph, + graph_data=graph_data, + bus_id=bus_id, + substation_id=substation_id, + ) + coupler_df = get_coupler_df( switches_df=graph_data.switches, busbar_df=busbar_df, substation_id=substation_id, graph=graph ) - busbar_connection_info = get_busbar_connection_info(graph=graph) + coupler_df = coupler_df.dropna(subset=["busbar_from_id", "busbar_to_id"]).copy() + if not coupler_df.empty: + coupler_df[["busbar_from_id", "busbar_to_id"]] = coupler_df[["busbar_from_id", "busbar_to_id"]].astype(int) + edge_connection_info = get_edge_connection_info(graph=graph) switchable_assets_df = get_switchable_asset(busbar_connection_info, graph_data.node_assets, graph_data.branches) - asset_bay_dict = {} - for asset_grid_model_id in switchable_assets_df["grid_model_id"].to_list(): - asset_bay, logs = get_asset_bay( - graph_data.switches, - asset_grid_model_id=asset_grid_model_id, - busbar_df=busbar_df, - edge_connection_info=edge_connection_info, - ) - station_logs.extend(logs) - if asset_bay is None: - continue - asset_bay_dict[asset_grid_model_id] = asset_bay + connected_asset_ids = { + asset_grid_model_id + for connection_info in busbar_connection_info.values() + for asset_grid_model_id in connection_info.connectable_assets + } + switchable_assets_df = switchable_assets_df[switchable_assets_df["grid_model_id"].isin(connected_asset_ids)].reset_index( + drop=True + ) + + asset_bays_by_asset_id, station_logs = _get_station_asset_bays( + switches_df=graph_data.switches, + switchable_assets_df=switchable_assets_df, + busbar_df=busbar_df, + edge_connection_info=edge_connection_info, + substation_id=substation_id, + selected_busbar_ids=selected_busbar_ids, + ) asset_connectivity, asset_switching_table, busbar_connectivity, busbar_switching_table = get_station_connection_tables( busbar_connection_info, busbar_df=busbar_df, switchable_assets_df=switchable_assets_df @@ -315,19 +522,24 @@ def get_station(network: Network, bus_id: str, station_info: SubstationInformati asset_switching_table = remove_double_connections(asset_switching_table, substation_id=substation_id) busbars = get_list_of_busbars_from_df(busbar_df) couplers = get_list_of_coupler_from_df(coupler_df) - assets = get_list_of_switchable_assets_from_df(station_branches=switchable_assets_df, asset_bay_dict=asset_bay_dict) - remove_suffix_from_switchable_assets(assets) + branch_connections, injection_connections, branch_mask = _get_station_asset_connections( + switchable_assets_df=switchable_assets_df, + asset_bays_by_asset_id=asset_bays_by_asset_id, + ) - station = Station( + station = MaterializedStation( grid_model_id=bus_id, name=substation_id, region=station_info.region, voltage_level=int(station_info.nominal_v), busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=asset_switching_table, - asset_connectivity=asset_connectivity, + branch_connections=branch_connections, + injection_connections=injection_connections, + branch_switching_table=asset_switching_table[:, branch_mask], + injection_switching_table=asset_switching_table[:, [not is_branch for is_branch in branch_mask]], + branch_connectivity=asset_connectivity[:, branch_mask], + injection_connectivity=asset_connectivity[:, [not is_branch for is_branch in branch_mask]], busbar_switching_table=busbar_switching_table, busbar_connectivity=busbar_connectivity, model_log=station_logs, @@ -335,7 +547,7 @@ def get_station(network: Network, bus_id: str, station_info: SubstationInformati return station -def get_station_list(network: Network, relevant_voltage_level_with_region: pd.DataFrame) -> list[Station]: +def get_station_list(network: Network, relevant_voltage_level_with_region: pd.DataFrame) -> list[MaterializedStation]: """Get the station list from a pypowsybl network. Note: include only wanted voltage levels and regions. @@ -432,9 +644,38 @@ def get_topology(network: Network, network_masks: NetworkMasks, importer_paramet topology_id = importer_parameters.grid_model_file.name timestamp = datetime.datetime.now() + topology_stations = [] + topology_assets = {} + topology_asset_bays = {} + for station in station_list: + topology_station, station_branch_assets, station_injection_assets, station_asset_bays = ( + topology_parts_from_materialized_station(station) + ) + topology_stations.append(topology_station) + for asset in [*station_branch_assets, *station_injection_assets]: + existing_asset = topology_assets.get(asset.grid_model_id) + if existing_asset is None: + topology_assets[asset.grid_model_id] = asset + elif existing_asset != asset: + raise ValueError(f"Conflicting topology asset payload for grid_model_id {asset.grid_model_id}") + for asset_bay in station_asset_bays: + if asset_bay.asset_bay_id is None: + continue + existing_asset_bay = topology_asset_bays.get(asset_bay.asset_bay_id) + if existing_asset_bay is None: + topology_asset_bays[asset_bay.asset_bay_id] = asset_bay + elif existing_asset_bay != asset_bay: + raise ValueError(f"Conflicting topology asset bay payload for asset_bay_id {asset_bay.asset_bay_id}") + + topology_assets_list = list(topology_assets.values()) + branch_assets = [asset for asset in topology_assets_list if isinstance(asset, BranchAsset)] + injection_assets = [asset for asset in topology_assets_list if isinstance(asset, InjectionAsset)] return Topology( topology_id=topology_id, grid_model_file=grid_model_file, - stations=station_list, + raw_stations=topology_stations, + branch_assets=branch_assets, + injection_assets=injection_assets, + asset_bays=list(topology_asset_bays.values()), timestamp=timestamp, ) diff --git a/packages/importer_pkg/src/toop_engine_importer/pandapower_import/asset_topology.py b/packages/importer_pkg/src/toop_engine_importer/pandapower_import/asset_topology.py index e033bf358..083268220 100644 --- a/packages/importer_pkg/src/toop_engine_importer/pandapower_import/asset_topology.py +++ b/packages/importer_pkg/src/toop_engine_importer/pandapower_import/asset_topology.py @@ -24,18 +24,24 @@ get_asset_switching_table, get_list_of_busbars_from_df, get_list_of_coupler_from_df, - get_list_of_switchable_assets_from_df, ) from toop_engine_importer.pandapower_import.pandapower_toolset_node_breaker import ( get_all_switches_from_bus_ids, get_closed_switch, get_indirect_connected_switch, ) -from toop_engine_interfaces.asset_topology import ( - AssetBay, - Station, +from toop_engine_interfaces.asset_topology.asset_topology import ( Topology, ) +from toop_engine_interfaces.asset_topology.assets import ( + AssetBay, + BranchAsset, + InjectionAsset, + build_asset_bay_id, + normalize_switchable_asset_payload, +) +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.topology_conversion import topology_parts_from_materialized_station logger = structlog.get_logger(__name__) @@ -247,6 +253,8 @@ def get_branches_from_station( # noqa: PLR0912, C901 will not raise an error. But calling an "not_existing_attribute" will raise an error. If any bus name from bus_from_to_names is not found in the branch type. """ + station_grid_model_id = str(station_buses["grid_model_id"].values[0]) + if branch_types is None: branch_types = [ "line", @@ -296,6 +304,8 @@ def get_branches_from_station( # noqa: PLR0912, C901 # asset is not directly connected to a busbar -> get connection path asset_connection = get_asset_connection_path_to_busbars( network=network, + station_grid_model_id=station_grid_model_id, + asset_grid_model_id=str(branch[foreign_key]), asset_bus=asset_bus, station_buses=station_buses, save_col_name=foreign_key, @@ -413,11 +423,85 @@ def get_parameter_from_station( return parameter +def _materialize_station_asset_connections( + station_branches: pd.DataFrame, + asset_connection_path: list[AssetBay | None], +) -> tuple[list[MaterializedAssetConnection], list[MaterializedAssetConnection], list[bool]]: + """Build split materialized asset connections aligned with the station branch rows. + + Parameters + ---------- + station_branches : pd.DataFrame + Station-local asset rows aligned with the switching matrix columns. + asset_connection_path : list[AssetBay | None] + Asset-bay metadata aligned with ``station_branches``. + + Returns + ------- + tuple[list[MaterializedAssetConnection], list[MaterializedAssetConnection], list[bool]] + Branch connections, injection connections, and the branch mask aligned with + ``station_branches``. + """ + switchable_assets = [ + normalize_switchable_asset_payload(asset_payload) for asset_payload in station_branches.to_dict(orient="records") + ] + asset_terminals = ( + station_branches["branch_end"].tolist() + if "branch_end" in station_branches.columns + else [None] * len(station_branches) + ) + branch_mask = [isinstance(asset, BranchAsset) for asset in switchable_assets] + if any(not isinstance(asset, (BranchAsset, InjectionAsset)) for asset in switchable_assets): + raise ValueError("All station assets must normalize to BranchAsset or InjectionAsset") + branch_connections: list[MaterializedAssetConnection] = [] + injection_connections: list[MaterializedAssetConnection] = [] + for asset, asset_terminal, asset_bay, is_branch in zip( + switchable_assets, + asset_terminals, + asset_connection_path, + branch_mask, + strict=True, + ): + connection = MaterializedAssetConnection(asset=asset, branch_end=asset_terminal, asset_bay=asset_bay) + if is_branch: + branch_connections.append(connection) + else: + injection_connections.append(connection) + return branch_connections, injection_connections, branch_mask + + +def _get_station_identity( + station_buses: pd.DataFrame, + voltage_level: float | int | str, +) -> dict[str, str | float | int]: + """Extract the station identity fields used to build the materialized station. + + Parameters + ---------- + station_buses : pd.DataFrame + Station bus rows used to derive the grid model id and display name. + voltage_level : float | int | str + Resolved station voltage level. + + Returns + ------- + dict[str, str | float | int] + Minimal station identity payload containing ``grid_model_id``, ``name``, and + ``voltage_level``. + """ + station_buses = station_buses.sort_index() + return { + "grid_model_id": station_buses["grid_model_id"].iloc[0], + "name": station_buses["name"].iloc[0], + "voltage_level": voltage_level, + } + + def get_station_from_id( network: pp.pandapowerNet, station_id_list: list[int], foreign_key: str = "equipment", -) -> Station: +) -> MaterializedStation: """Get the busses from a station_id. Parameters @@ -442,31 +526,24 @@ def get_station_from_id( asset_connection_path, ) = get_branches_from_station(network, station_buses, foreign_key=foreign_key) - # get the lists of the pydantic model objects - busbar_list = get_list_of_busbars_from_df(station_buses[station_buses["type"] == "b"]) - coupler_list = get_list_of_coupler_from_df(coupler_elements) - switchable_assets_list = get_list_of_switchable_assets_from_df( - station_branches=station_branches, asset_bay_list=asset_connection_path - ) - voltage_level_float = get_parameter_from_station(network=network, station_bus_index=station_id_list, parameter="vn_kv") - # region = get_parameter_from_station(network, station_name, "zone") - - # get the station_name from the station_id - # in pandapower a station is a bus -> only one entry in the DataFrame - station_buses.sort_index(inplace=True) - station_name = station_buses["name"].values[0] - grid_model_id = station_buses["grid_model_id"].values[0] + station_identity = _get_station_identity(station_buses=station_buses, voltage_level=voltage_level_float) + branch_connections, injection_connections, branch_mask = _materialize_station_asset_connections( + station_branches=station_branches, + asset_connection_path=asset_connection_path, + ) - return Station( - grid_model_id=grid_model_id, - name=station_name, + return MaterializedStation( + grid_model_id=station_identity["grid_model_id"], + name=station_identity["name"], # region=region, - voltage_level=voltage_level_float, - busbars=busbar_list, - couplers=coupler_list, - assets=switchable_assets_list, - asset_switching_table=switching_matrix, + voltage_level=station_identity["voltage_level"], + busbars=get_list_of_busbars_from_df(station_buses[station_buses["type"] == "b"]), + couplers=get_list_of_coupler_from_df(coupler_elements), + branch_connections=branch_connections, + injection_connections=injection_connections, + branch_switching_table=switching_matrix[:, branch_mask], + injection_switching_table=switching_matrix[:, [not is_branch for is_branch in branch_mask]], ) @@ -474,7 +551,7 @@ def get_list_of_stations_ids( network: pp.pandapowerNet, station_list: List[List[int]], foreign_key: str = "equipment", -) -> List[Station]: +) -> List[MaterializedStation]: """Get the list of stations from the network. Parameters @@ -528,12 +605,41 @@ def get_asset_topology_from_network( asset_topology: Topology Topology class of the network. """ - asset_topology = get_list_of_stations_ids(network=network, station_list=station_id_list, foreign_key=foreign_key) + station_topologies = get_list_of_stations_ids(network=network, station_list=station_id_list, foreign_key=foreign_key) timestamp = datetime.datetime.now() + topology_stations = [] + topology_assets = {} + topology_asset_bays = {} + for station in station_topologies: + topology_station, station_branch_assets, station_injection_assets, station_asset_bays = ( + topology_parts_from_materialized_station(station) + ) + topology_stations.append(topology_station) + for asset in [*station_branch_assets, *station_injection_assets]: + existing_asset = topology_assets.get(asset.grid_model_id) + if existing_asset is None: + topology_assets[asset.grid_model_id] = asset + elif existing_asset != asset: + raise ValueError(f"Conflicting topology asset payload for grid_model_id {asset.grid_model_id}") + for asset_bay in station_asset_bays: + if asset_bay.asset_bay_id is None: + continue + existing_asset_bay = topology_asset_bays.get(asset_bay.asset_bay_id) + if existing_asset_bay is None: + topology_asset_bays[asset_bay.asset_bay_id] = asset_bay + elif existing_asset_bay != asset_bay: + raise ValueError(f"Conflicting topology asset bay payload for asset_bay_id {asset_bay.asset_bay_id}") + + topology_assets_list = list(topology_assets.values()) + branch_assets = [asset for asset in topology_assets_list if isinstance(asset, BranchAsset)] + injection_assets = [asset for asset in topology_assets_list if isinstance(asset, InjectionAsset)] return Topology( topology_id=topology_id, grid_model_file=grid_model_file, - stations=asset_topology, + raw_stations=topology_stations, + branch_assets=branch_assets, + injection_assets=injection_assets, + asset_bays=list(topology_asset_bays.values()), timestamp=timestamp, ) @@ -585,6 +691,8 @@ def get_station_bus_df( # TODO: replace by networkX_logic_modules def get_asset_connection_path_to_busbars( # noqa: PLR0915 network: pp.pandapowerNet, + station_grid_model_id: str, + asset_grid_model_id: str, asset_bus: int, station_buses: pd.DataFrame, save_col_name: str = "equipment", @@ -595,6 +703,10 @@ def get_asset_connection_path_to_busbars( # noqa: PLR0915 ---------- network: pp.pandapowerNet pandapower network object + station_grid_model_id: str + Station identifier owning the asset bay. + asset_grid_model_id: str + Asset identifier for which the asset bay is constructed. asset_bus: int Asset bus id for which the connection path should be retrieved. station_buses: pd.DataFrame @@ -698,6 +810,7 @@ def get_asset_connection_path_to_busbars( # noqa: PLR0915 sl_switch_grid_model_id = None asset_connection = AssetBay( + asset_bay_id=build_asset_bay_id(station_grid_model_id, asset_grid_model_id), sl_switch_grid_model_id=sl_switch_grid_model_id, dv_switch_grid_model_id=circuit_breaker[save_col_name].iloc[0], sr_switch_grid_model_id=final_buses, diff --git a/packages/importer_pkg/src/toop_engine_importer/pandapower_import/pp_masks.py b/packages/importer_pkg/src/toop_engine_importer/pandapower_import/pp_masks.py index 54ab53fe5..599737fdc 100644 --- a/packages/importer_pkg/src/toop_engine_importer/pandapower_import/pp_masks.py +++ b/packages/importer_pkg/src/toop_engine_importer/pandapower_import/pp_masks.py @@ -20,7 +20,8 @@ import pandapower as pp import structlog from beartype.typing import Optional -from jaxtyping import ArrayLike, Bool, Int +from jaxtyping import Bool, Int +from numpy.typing import ArrayLike from pandas import Index from toop_engine_importer.pandapower_import.pandapower_toolset_node_breaker import ( get_coupler_types_of_substation, diff --git a/packages/importer_pkg/src/toop_engine_importer/pandapower_import/preprocessing.py b/packages/importer_pkg/src/toop_engine_importer/pandapower_import/preprocessing.py index bb06c73d5..dbadbb60d 100644 --- a/packages/importer_pkg/src/toop_engine_importer/pandapower_import/preprocessing.py +++ b/packages/importer_pkg/src/toop_engine_importer/pandapower_import/preprocessing.py @@ -53,7 +53,7 @@ fuse_closed_switches_by_bus_ids, get_coupler_types_of_substation, ) -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology, copy_topology_with_updates logger = structlog.get_logger(__name__) @@ -198,11 +198,20 @@ def preprocess_net_step2(network: pp.pandapowerNet, topology_model: Topology) -> if "bus_geodata" in network: del network["bus_geodata"] old_index = pp.toolbox.create_continuous_bus_index(network, start=0, store_old_index=True) - for station in topology_model.stations: + raw_stations = [] + for station in topology_model.raw_stations: station_id = int(station.grid_model_id.split(SEPARATOR)[0]) new_id = old_index[station_id] - station.grid_model_id = f"{new_id}{SEPARATOR}{station.grid_model_id.split(SEPARATOR)[1]}" - return topology_model + raw_stations.append( + station.model_copy(update={"grid_model_id": f"{new_id}{SEPARATOR}{station.grid_model_id.split(SEPARATOR)[1]}"}) + ) + return copy_topology_with_updates( + topology_model, + raw_stations, + topology_model.asset_bays, + branch_assets=topology_model.branch_assets, + injection_assets=topology_model.injection_assets, + ) def fuse_cross_coupler( @@ -268,21 +277,23 @@ def validate_asset_topology(net: pp.pandapowerNet, topology_model: Topology) -> ValueError if the number of connections in the network does not match the number of assets in the station """ - for station in topology_model.stations: + for station in topology_model.materialize_stations(): s_id = int(station.grid_model_id.split(r"%%")[0]) + station_connections = [*station.branch_connections, *station.injection_connections] connection_dict = pp.toolbox.get_connected_elements_dict(net, [s_id]) del connection_dict["bus"] len_connection = len([element for key in connection_dict for element in connection_dict[key]]) - if len_connection != len(station.assets): + if len_connection != len(station_connections): logger.warning( - f"Station {s_id} has {len(station.assets)} assets but only " + f"Station {s_id} has {len(station_connections)} assets but only " + f"{len_connection} connections in the network", **connection_dict, ) - for asset in station.assets: - logger.warning(f"Station {s_id} with assets: {asset}", asset=asset) + for asset_connection in station_connections: + logger.warning(f"Station {s_id} with assets: {asset_connection.asset}", asset=asset_connection.asset) raise ValueError( - f"Station {s_id} has {len(station.assets)} assets but only " + f"{len_connection} connections in the network" + f"Station {s_id} has {len(station_connections)} assets but only " + + f"{len_connection} connections in the network" ) diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/__init__.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/__init__.py index cb6107178..0d65b076d 100644 --- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/__init__.py +++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/__init__.py @@ -8,7 +8,7 @@ """Import data from PyPowSyBl networks to the Topology Optimizer.""" from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import ( - get_list_of_stations, + get_raw_stations_and_assets, get_topology, ) @@ -52,7 +52,7 @@ "convert_low_impedance_lines", "create_default_network_masks", "get_branches_df_with_element_name", - "get_list_of_stations", + "get_raw_stations_and_assets", "get_topology", "load_preprocessing_statistics_filesystem", "make_masks", diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/cgmes/powsybl_masks_cgmes.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/cgmes/powsybl_masks_cgmes.py index 8c4f257a1..f006552e3 100644 --- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/cgmes/powsybl_masks_cgmes.py +++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/cgmes/powsybl_masks_cgmes.py @@ -10,7 +10,7 @@ from beartype.typing import Optional from pypowsybl.network.impl.network import Network from toop_engine_importer.pypowsybl_import.cgmes.cgmes_toolset import get_voltage_level_with_region -from toop_engine_interfaces.asset_topology import AssetBranchTypePowsybl, AssetInjectionTypePowsybl +from toop_engine_interfaces.asset_topology.asset_types import AssetBranchTypePowsybl, AssetInjectionTypePowsybl from toop_engine_interfaces.messages.preprocess.preprocess_commands import ( RegionType, RelevantStationRules, diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/preprocessing.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/preprocessing.py index 480bb42e2..48e1e4271 100644 --- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/preprocessing.py +++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/preprocessing.py @@ -14,6 +14,7 @@ import json from copy import deepcopy +from dataclasses import replace from itertools import product from pathlib import Path @@ -47,7 +48,7 @@ create_new_border_limits, ) from toop_engine_importer.pypowsybl_import.powsybl_masks import NetworkMasks, make_masks, save_masks_to_filesystem -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.filesystem_helper import copy_file_fs, save_pydantic_model_fs from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS from toop_engine_interfaces.messages.preprocess.preprocess_commands import ( @@ -70,6 +71,40 @@ CONVERTED_TRAFO3W_ENDING = "-Leg[123]$" +def filter_split_stations_from_relevant_subs( + network: Network, topology_model: Topology, network_masks: NetworkMasks +) -> NetworkMasks: + """Remove split stations from the ``relevant_subs`` mask. + + Parameters + ---------- + network : Network + Network whose bus ids define the mask ordering. + topology_model : Topology + Topology used to identify split stations. + network_masks : NetworkMasks + Existing network masks to update. + + Returns + ------- + NetworkMasks + Updated masks with split stations removed from ``relevant_subs``. + + Notes + ----- + A split station spans more than one non-empty bus-branch bus id in its station view. + Those stations are not safe optimizer targets because one relevant bus id would already + represent multiple bus-branch buses. + """ + split_station_ids = {station.grid_model_id for station in topology_model.raw_stations if station.is_split()} + if len(split_station_ids) == 0: + return network_masks + + relevant_subs = network_masks.relevant_subs & ~network.get_buses(attributes=[]).index.isin(split_station_ids) + logger.warning("Removed split stations from relevant_subs", split_station_ids=sorted(split_station_ids)) + return replace(network_masks, relevant_subs=relevant_subs) + + def save_preprocessing_statistics_filesystem( statistics: PreProcessingStatistics, filesystem: AbstractFileSystem, file_path: Union[str, Path] ) -> None: @@ -385,18 +420,8 @@ def convert_file( ) # get N-1 masks status_update_fn("get_masks", "Creating Network Masks") - slack_id = network.get_extension("slackTerminal").iloc[0].bus_id - network_masks = get_network_masks(network, slack_id, importer_parameters, statistics, filesystem=unprocessed_gridfile_fs) - save_masks_to_filesystem( - data_folder=importer_parameters.data_folder, network_masks=network_masks, filesystem=processed_gridfile_fs - ) - - # get nminus1 definition - nminus1_definition = create_nminus1_definition_from_masks(network, network_masks) - save_pydantic_model_fs( - filesystem=processed_gridfile_fs, - file_path=importer_parameters.data_folder / PREPROCESSING_PATHS["nminus1_definition_file_path"], - pydantic_model=nminus1_definition, + network_masks = compute_network_masks_and_n_1_definition( + importer_parameters, processed_gridfile_fs, unprocessed_gridfile_fs, network, statistics ) if ( @@ -414,15 +439,29 @@ def convert_file( file_path=grid_file_path, ) + status_update_fn("get_topology_model", "Creating Pydantic Topology Model") + topology_model = get_topology_model(network, network_masks, importer_parameters) + network_masks = filter_split_stations_from_relevant_subs(network, topology_model, network_masks) + fill_statistics_for_network_masks(network=network, statistics=statistics, network_masks=network_masks) + + save_masks_to_filesystem( + data_folder=importer_parameters.data_folder, network_masks=network_masks, filesystem=processed_gridfile_fs + ) + + # get nminus1 definition + nminus1_definition = create_nminus1_definition_from_masks(network, network_masks) + save_pydantic_model_fs( + filesystem=processed_gridfile_fs, + file_path=importer_parameters.data_folder / PREPROCESSING_PATHS["nminus1_definition_file_path"], + pydantic_model=nminus1_definition, + ) + save_preprocessing_statistics_filesystem( statistics=statistics, file_path=importer_parameters.data_folder / PREPROCESSING_PATHS["importer_auxiliary_file_path"], filesystem=processed_gridfile_fs, ) - status_update_fn("get_topology_model", "Creating Pydantic Topology Model") - topology_model = get_topology_model(network, network_masks, importer_parameters) - save_pydantic_model_fs( filesystem=processed_gridfile_fs, file_path=importer_parameters.data_folder / PREPROCESSING_PATHS["asset_topology_file_path"], @@ -433,6 +472,56 @@ def convert_file( return statistics.import_result +def compute_network_masks_and_n_1_definition( + importer_parameters: Union[UcteImporterParameters, CgmesImporterParameters], + processed_gridfile_fs: AbstractFileSystem, + unprocessed_gridfile_fs: AbstractFileSystem, + network: Network, + statistics: PreProcessingStatistics, +) -> NetworkMasks: + """Create, persist, and return network masks plus the derived N-1 definition. + + Parameters + ---------- + importer_parameters : Union[UcteImporterParameters, CgmesImporterParameters] + Import configuration providing the data folder and mask generation settings. + processed_gridfile_fs : AbstractFileSystem + Filesystem used to persist the generated masks and N-1 definition. + unprocessed_gridfile_fs : AbstractFileSystem + Filesystem used to resolve auxiliary inputs required during mask creation. + network : Network + Powsybl network for which masks and contingencies are computed. + statistics : PreProcessingStatistics + Statistics object updated while generating masks. + + Returns + ------- + NetworkMasks + Generated network masks after saving them and the derived N-1 definition. + """ + slack_id = network.get_extension("slackTerminal").iloc[0].bus_id + network_masks = get_network_masks( + network, + slack_id, + importer_parameters, + statistics, + filesystem=unprocessed_gridfile_fs, + ) + save_masks_to_filesystem( + data_folder=importer_parameters.data_folder, network_masks=network_masks, filesystem=processed_gridfile_fs + ) + + # get nminus1 definition + nminus1_definition = create_nminus1_definition_from_masks(network, network_masks) + save_pydantic_model_fs( + filesystem=processed_gridfile_fs, + file_path=importer_parameters.data_folder / PREPROCESSING_PATHS["nminus1_definition_file_path"], + pydantic_model=nminus1_definition, + ) + + return network_masks + + def get_slack_ids(network: Network) -> list[str] | None: """Get the slack bus ids from the network. diff --git a/packages/importer_pkg/tests/conftest.py b/packages/importer_pkg/tests/conftest.py index 931355c7d..bcf3da42d 100644 --- a/packages/importer_pkg/tests/conftest.py +++ b/packages/importer_pkg/tests/conftest.py @@ -43,7 +43,7 @@ ) from toop_engine_importer.pandapower_import import add_substation_column_to_bus from toop_engine_importer.pypowsybl_import import powsybl_masks, preprocessing -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS from toop_engine_interfaces.messages.preprocess.preprocess_commands import ( AreaSettings, diff --git a/packages/importer_pkg/tests/exporter/test_asset_topology_to_ucte.py b/packages/importer_pkg/tests/exporter/test_asset_topology_to_ucte.py index 2d4ca7841..bd34a1964 100644 --- a/packages/importer_pkg/tests/exporter/test_asset_topology_to_ucte.py +++ b/packages/importer_pkg/tests/exporter/test_asset_topology_to_ucte.py @@ -25,7 +25,7 @@ ) from toop_engine_importer.exporter.uct_exporter import validate_ucte_changes from toop_engine_importer.ucte_toolset.ucte_io import make_ucte, parse_ucte -from toop_engine_interfaces.asset_topology import ( +from toop_engine_interfaces.asset_topology.assets import ( AssetSetpoint, Busbar, BusbarCoupler, @@ -294,16 +294,17 @@ def test_change_trafos_lines_in_ucte(): def test_get_changes_from_switching_table(ucte_asset_topology, caplog): topology_model = deepcopy(ucte_asset_topology) + topology_stations = topology_model.materialize_stations() # Test case where asset is reassigned - station = topology_model.stations[0] + station = topology_stations[0] expected = [] result = get_changes_from_switching_table(station) assert result == expected - topology_model.stations[0].asset_switching_table[0][3] = False - topology_model.stations[0].asset_switching_table[1][3] = True - station = topology_model.stations[0] + topology_stations[0].branch_switching_table[0][2] = False + topology_stations[0].branch_switching_table[1][2] = True + station = topology_stations[0] expected = [ { "grid_model_id": "D8SU1_11 D8SU1_21 1", @@ -316,16 +317,16 @@ def test_get_changes_from_switching_table(ucte_asset_topology, caplog): assert result == expected # Test case where asset is connected to multiple busbars (should raise ValueError) - topology_model.stations[0].asset_switching_table[0][3] = True - topology_model.stations[0].asset_switching_table[1][3] = True - station = topology_model.stations[0] + topology_stations[0].branch_switching_table[0][2] = True + topology_stations[0].branch_switching_table[1][2] = True + station = topology_stations[0] with pytest.raises(ValueError): get_changes_from_switching_table(station) # test case disconnected asset - topology_model.stations[0].asset_switching_table[0][3] = False - topology_model.stations[0].asset_switching_table[1][3] = False - station = topology_model.stations[0] + topology_stations[0].branch_switching_table[0][2] = False + topology_stations[0].branch_switching_table[1][2] = False + station = topology_stations[0] expected = [ { "grid_model_id": "D8SU1_11 D8SU1_21 1", @@ -338,21 +339,21 @@ def test_get_changes_from_switching_table(ucte_asset_topology, caplog): assert result == expected # test where an asset is connected to two busbars within a station an is now reassigned - topology_model.stations[0].asset_switching_table[0][3] = False - topology_model.stations[0].asset_switching_table[1][3] = True - topology_model.stations[0].busbars.append( - Busbar(grid_model_id="D8SU1_13", type=None, name="", int_id=0, in_service=True) + topology_stations[0].branch_switching_table[0][2] = False + topology_stations[0].branch_switching_table[1][2] = True + topology_stations[0].busbars.append( + Busbar(grid_model_id="D8SU1_13", busbar_type=None, name="", int_id=0, in_service=True) ) - topology_model.stations[0].assets[3].grid_model_id = "D8SU1_11 D8SU1_13 1" - station = topology_model.stations[0] + topology_stations[0].branch_connections[2].asset.grid_model_id = "D8SU1_11 D8SU1_13 1" + station = topology_stations[0] with pytest.raises(ValueError): get_changes_from_switching_table(station) # Test case where busbar connection is not found - topology_model.stations[0].asset_switching_table[0][3] = False - topology_model.stations[0].asset_switching_table[1][3] = True - topology_model.stations[0].assets[3].grid_model_id = "NOT_A_VALID_ID" - station = topology_model.stations[0] + topology_stations[0].branch_switching_table[0][2] = False + topology_stations[0].branch_switching_table[1][2] = True + topology_stations[0].branch_connections[2].asset.grid_model_id = "NOT_A_VALID_ID" + station = topology_stations[0] with pytest.raises(ValueError): get_changes_from_switching_table(station) @@ -549,22 +550,24 @@ def test_asset_topo_to_uct(ucte_asset_topology, ucte_file): grid_model_file_input=ucte_file, ) + topology_stations = topology_model.materialize_stations() + # Test case where asset is reassigned # test trafo - topology_model.stations[0].asset_switching_table[0][3] = False - topology_model.stations[0].asset_switching_table[1][3] = True + topology_stations[0].branch_switching_table[0][2] = False + topology_stations[0].branch_switching_table[1][2] = True # test line - topology_model.stations[0].asset_switching_table[0][4] = True - topology_model.stations[0].asset_switching_table[1][4] = False + topology_stations[0].branch_switching_table[0][3] = True + topology_stations[0].branch_switching_table[1][3] = False # test coupler - topology_model.stations[0].couplers[0].open = True + topology_stations[0].couplers[0].open = True with open(ucte_file, "r") as f: input_uct_contents = f.read() preamble, nodes, lines, trafos, trafo_reg, postamble = parse_ucte(input_uct_contents) # test order change of line # original grid id: "D2SU1_31 D2SU1_31 2" - topology_model.stations[0].assets[1].grid_model_id = "D8SU1_12 D7SU1_11 1" + topology_stations[0].branch_connections[1].asset.grid_model_id = "D8SU1_12 D7SU1_11 1" lines.iloc[4, 2] = "1" lines.iloc[4, 0] = "D8SU1_11" lines.iloc[4, 1] = "D7SU2_11" diff --git a/packages/importer_pkg/tests/network_graph/test_graph_to_asset_topo.py b/packages/importer_pkg/tests/network_graph/test_graph_to_asset_topo.py index e62db06e9..e91b555e5 100644 --- a/packages/importer_pkg/tests/network_graph/test_graph_to_asset_topo.py +++ b/packages/importer_pkg/tests/network_graph/test_graph_to_asset_topo.py @@ -31,7 +31,7 @@ ) from toop_engine_importer.network_graph.network_graph_data import add_graph_specific_data from toop_engine_importer.network_graph.powsybl_station_to_graph import get_station -from toop_engine_interfaces.asset_topology import AssetBay +from toop_engine_interfaces.asset_topology.assets import AssetBay, build_asset_bay_id def test_remove_double_connections(): @@ -81,7 +81,7 @@ def test_get_busbar_df(network_graph_data_test1: NetworkGraphData): expected = [ { "grid_model_id": "BBS3_1", - "type": "busbar", + "busbar_type": "busbar", "name": "ab", "int_id": 0, "in_service": True, @@ -89,7 +89,7 @@ def test_get_busbar_df(network_graph_data_test1: NetworkGraphData): }, { "grid_model_id": "BBS3_2", - "type": "busbar", + "busbar_type": "busbar", "name": "cd", "int_id": 1, "in_service": True, @@ -108,7 +108,7 @@ def test_get_coupler_df_busbar_coupler(network_graph_for_asset_topo: tuple[nx.Gr expected = [ { "grid_model_id": "5", - "type": "BREAKER", + "coupler_type": "BREAKER", "name": "fid_5", "in_service": True, "open": False, @@ -131,13 +131,13 @@ def test_get_switchable_asset(network_graph_for_asset_topoV2_S1: tuple[nx.Graph, bus_info = get_busbar_connection_info(graph=graph) branches_df = network_graph_data.branches expected = [ - {"grid_model_id": "L1", "name": "", "type": "LINE", "in_service": True}, - {"grid_model_id": "L2", "name": "", "type": "LINE", "in_service": True}, - {"grid_model_id": "L3", "name": "", "type": "LINE", "in_service": True}, - {"grid_model_id": "L4", "name": "", "type": "LINE", "in_service": True}, - {"grid_model_id": "L5", "name": "", "type": "LINE", "in_service": True}, - {"grid_model_id": "generator1", "name": "", "type": "GENERATOR", "in_service": True}, - {"grid_model_id": "generator2", "name": "", "type": "GENERATOR", "in_service": True}, + {"grid_model_id": "L1", "name": "", "asset_type": "LINE", "in_service": True}, + {"grid_model_id": "L2", "name": "", "asset_type": "LINE", "in_service": True}, + {"grid_model_id": "L3", "name": "", "asset_type": "LINE", "in_service": True}, + {"grid_model_id": "L4", "name": "", "asset_type": "LINE", "in_service": True}, + {"grid_model_id": "L5", "name": "", "asset_type": "LINE", "in_service": True}, + {"grid_model_id": "generator1", "name": "", "asset_type": "GENERATOR", "in_service": True}, + {"grid_model_id": "generator2", "name": "", "asset_type": "GENERATOR", "in_service": True}, ] res = get_switchable_asset(busbar_connection_info=bus_info, node_assets_df=nodes_asset_df, branches_df=branches_df) assert res.to_dict(orient="records") == expected @@ -151,11 +151,17 @@ def test_switching_tables_V2(basic_node_breaker_network_powsybl_grid_v2): asset_connectivity = np.array([[True, True, True], [True, True, True], [True, True, True]]) asset_switching_table = np.array([[True, True, False], [False, False, False], [False, False, True]]) - assert np.array_equal(station.asset_connectivity, asset_connectivity) - assert np.array_equal(station.asset_switching_table, asset_switching_table) + assert len(station.branch_connections) == 2 + assert len(station.injection_connections) == 1 + assert np.array_equal( + np.concatenate([station.branch_connectivity, station.injection_connectivity], axis=1), asset_connectivity + ) + assert np.array_equal( + np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1), asset_switching_table + ) assert len(station.couplers) == 2 - assert station.couplers[0].type == "BREAKER" - assert station.couplers[1].type == "BREAKER" + assert station.couplers[0].coupler_type == "BREAKER" + assert station.couplers[1].coupler_type == "BREAKER" station_info = {"name": "Station6", "region": "BE", "nominal_v": 380, "voltage_level_id": "VL6"} station_info = SubstationInformation(**station_info) @@ -169,8 +175,14 @@ def test_switching_tables_V2(basic_node_breaker_network_powsybl_grid_v2): [[True, True], [False, False], [False, False], [True, True], [False, False], [False, False]] ) - assert np.array_equal(station.asset_connectivity, asset_connectivity) - assert np.array_equal(station.asset_switching_table, asset_switching_table) + assert len(station.branch_connections) == 1 + assert len(station.injection_connections) == 1 + assert np.array_equal( + np.concatenate([station.branch_connectivity, station.injection_connectivity], axis=1), asset_connectivity + ) + assert np.array_equal( + np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1), asset_switching_table + ) def test_station_coupler(basic_node_breaker_network_powsybl_grid_v2): @@ -183,13 +195,13 @@ def test_station_coupler(basic_node_breaker_network_powsybl_grid_v2): assert len(station.couplers) == 2 assert station.couplers[0].grid_model_id == "VL2_BREAKER" - assert station.couplers[0].type == "BREAKER" + assert station.couplers[0].coupler_type == "BREAKER" assert station.couplers[0].name == "VL2_BREAKER" assert station.couplers[0].busbar_from_id == 0 assert station.couplers[0].busbar_to_id == 1 assert station.couplers[1].grid_model_id == "VL2_BREAKER#0" - assert station.couplers[1].type == "BREAKER" + assert station.couplers[1].coupler_type == "BREAKER" assert station.couplers[1].name == "VL2_BREAKER#0" assert station.couplers[1].busbar_from_id == 1 assert station.couplers[1].busbar_to_id == 2 @@ -202,14 +214,14 @@ def test_station_coupler(basic_node_breaker_network_powsybl_grid_v2): assert len(station.couplers) == 5 assert station.couplers[0].grid_model_id == "VL6_BREAKER" - assert station.couplers[0].type == "BREAKER" + assert station.couplers[0].coupler_type == "BREAKER" assert station.couplers[0].name == "VL6_BREAKER" assert station.couplers[0].busbar_from_id == 1 assert station.couplers[0].busbar_to_id == 4 assert not station.couplers[0].open assert station.couplers[1].grid_model_id == "VL6_BREAKER_1_1" - assert station.couplers[1].type == "BREAKER" + assert station.couplers[1].coupler_type == "BREAKER" assert station.couplers[1].name == "VL6_BREAKER_1_1" assert station.couplers[1].busbar_from_id == 0 assert station.couplers[1].busbar_to_id == 1 @@ -217,7 +229,7 @@ def test_station_coupler(basic_node_breaker_network_powsybl_grid_v2): assert station.couplers[1].in_service assert station.couplers[2].grid_model_id == "VL6_BREAKER_2_1" - assert station.couplers[2].type == "BREAKER" + assert station.couplers[2].coupler_type == "BREAKER" assert station.couplers[2].name == "VL6_BREAKER_2_1" assert station.couplers[2].busbar_from_id == 3 assert station.couplers[2].busbar_to_id == 4 @@ -225,13 +237,13 @@ def test_station_coupler(basic_node_breaker_network_powsybl_grid_v2): assert station.couplers[2].in_service assert station.couplers[3].grid_model_id == "VL6_DISCONNECTOR_2_4" - assert station.couplers[3].type == "DISCONNECTOR" + assert station.couplers[3].coupler_type == "DISCONNECTOR" assert station.couplers[3].name == "VL6_DISCONNECTOR_2_4" assert station.couplers[3].busbar_from_id == 1 assert station.couplers[3].busbar_to_id == 2 assert station.couplers[4].grid_model_id == "VL6_DISCONNECTOR_3_5" - assert station.couplers[4].type == "DISCONNECTOR" + assert station.couplers[4].coupler_type == "DISCONNECTOR" assert station.couplers[4].name == "VL6_DISCONNECTOR_3_5" assert station.couplers[4].busbar_from_id == 4 assert station.couplers[4].busbar_to_id == 5 @@ -242,19 +254,19 @@ def test_station_coupler(basic_node_breaker_network_powsybl_grid_v2): station = get_station(net, "VL4_0", station_info) assert len(station.couplers) == 3 assert station.couplers[0].grid_model_id == "VL4_BREAKER" - assert station.couplers[0].type == "BREAKER" + assert station.couplers[0].coupler_type == "BREAKER" assert station.couplers[0].name == "VL4_BREAKER" assert station.couplers[0].busbar_from_id == 0 assert station.couplers[0].busbar_to_id == 1 assert station.couplers[1].grid_model_id == "VL4_BREAKER#0" - assert station.couplers[1].type == "BREAKER" + assert station.couplers[1].coupler_type == "BREAKER" assert station.couplers[1].name == "VL4_BREAKER#0" assert station.couplers[1].busbar_from_id == 0 assert station.couplers[1].busbar_to_id == 2 assert station.couplers[2].grid_model_id == "VL4_BREAKER#1" - assert station.couplers[2].type == "BREAKER" + assert station.couplers[2].coupler_type == "BREAKER" assert station.couplers[2].name == "VL4_BREAKER#1" assert station.couplers[2].busbar_from_id == 0 assert station.couplers[2].busbar_to_id == 3 @@ -295,26 +307,31 @@ def test_asset_bay(network_graph_for_asset_topoV2_S3: tuple[nx.Graph, NetworkGra ) expected = { "L3": AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "L3"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="L32_BREAKER", sr_switch_grid_model_id={"BBS3_1": "L32_DISCONNECTOR_5_0", "BBS3_2": "L32_DISCONNECTOR_5_1"}, ), "L6": AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "L6"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="L62_BREAKER", sr_switch_grid_model_id={"BBS3_1": "L62_DISCONNECTOR_7_0", "BBS3_2": "L62_DISCONNECTOR_7_1"}, ), "L7": AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "L7"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="L72_BREAKER", sr_switch_grid_model_id={"BBS3_1": "L72_DISCONNECTOR_9_0", "BBS3_2": "L72_DISCONNECTOR_9_1"}, ), "L9": AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "L9"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="L91_BREAKER", sr_switch_grid_model_id={"BBS3_1": "L91_DISCONNECTOR_11_0", "BBS3_2": "L91_DISCONNECTOR_11_1"}, ), "load2": AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "load2"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="load2_BREAKER", sr_switch_grid_model_id={"BBS3_1": "load2_DISCONNECTOR_19_0", "BBS3_2": "load2_DISCONNECTOR_19_1"}, @@ -325,6 +342,7 @@ def test_asset_bay(network_graph_for_asset_topoV2_S3: tuple[nx.Graph, NetworkGra for asset_grid_model_id in switchable_assets_df["grid_model_id"].to_list(): asset_bay, logs = get_asset_bay( network_graph_data.switches, + station_grid_model_id=substation_id, asset_grid_model_id=asset_grid_model_id, busbar_df=busbar_df, edge_connection_info=edge_connection_info, @@ -338,6 +356,7 @@ def test_asset_bay(network_graph_for_asset_topoV2_S3: tuple[nx.Graph, NetworkGra with pytest.raises(ValueError, match="Expected 3 switches, but got"): get_asset_bay( switches_df=switches_df, + station_grid_model_id=substation_id, asset_grid_model_id="L3", busbar_df=busbar_df, edge_connection_info=edge_connection_info, @@ -348,11 +367,13 @@ def test_asset_bay(network_graph_for_asset_topoV2_S3: tuple[nx.Graph, NetworkGra asset_grid_model_id, logs = get_asset_bay( switches_df=switches_df, + station_grid_model_id=substation_id, asset_grid_model_id="L3", busbar_df=busbar_df, edge_connection_info=edge_connection_info, ) expected = AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "L3"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="L32_BREAKER", sr_switch_grid_model_id={"BBS3_1": "L32_DISCONNECTOR_5_0", "BBS3_2": "L32_DISCONNECTOR_5_1"}, @@ -366,6 +387,7 @@ def test_asset_bay(network_graph_for_asset_topoV2_S3: tuple[nx.Graph, NetworkGra switches_df.drop(2, inplace=True) asset_grid_model_id, logs = get_asset_bay( switches_df=switches_df, + station_grid_model_id=substation_id, asset_grid_model_id="L3", busbar_df=busbar_df, edge_connection_info=edge_connection_info, @@ -376,11 +398,13 @@ def test_asset_bay(network_graph_for_asset_topoV2_S3: tuple[nx.Graph, NetworkGra edge_connection_info["L62_DISCONNECTOR_7_0"].direct_busbar_grid_model_id = "" asset_grid_model_id, logs = get_asset_bay( switches_df=switches_df, + station_grid_model_id=substation_id, asset_grid_model_id="L6", busbar_df=busbar_df, edge_connection_info=edge_connection_info, ) expected = AssetBay( + asset_bay_id=build_asset_bay_id(substation_id, "L6"), sl_switch_grid_model_id="L62_DISCONNECTOR_7_0", dv_switch_grid_model_id="L62_BREAKER", sr_switch_grid_model_id={"BBS3_2": "L62_DISCONNECTOR_7_1"}, diff --git a/packages/importer_pkg/tests/network_graph/test_network_graph_helper_functions.py b/packages/importer_pkg/tests/network_graph/test_network_graph_helper_functions.py index f81ed6e71..f4953c417 100644 --- a/packages/importer_pkg/tests/network_graph/test_network_graph_helper_functions.py +++ b/packages/importer_pkg/tests/network_graph/test_network_graph_helper_functions.py @@ -17,7 +17,7 @@ remove_suffix_from_switchable_assets, reverse_dict_list, ) -from toop_engine_interfaces.asset_topology import SwitchableAsset +from toop_engine_interfaces.asset_topology.assets import SwitchableAsset def test_find_busbar_in_list(): diff --git a/packages/importer_pkg/tests/network_graph/test_powsybl_station_to_graph.py b/packages/importer_pkg/tests/network_graph/test_powsybl_station_to_graph.py index d7cf9ab21..6f30b3df1 100644 --- a/packages/importer_pkg/tests/network_graph/test_powsybl_station_to_graph.py +++ b/packages/importer_pkg/tests/network_graph/test_powsybl_station_to_graph.py @@ -9,6 +9,7 @@ from pathlib import Path import networkx as nx +import numpy as np import pandas as pd import pandera as pa import pypowsybl @@ -38,7 +39,8 @@ ) from toop_engine_importer.pypowsybl_import import powsybl_masks from toop_engine_importer.pypowsybl_import.cgmes.cgmes_toolset import get_busbar_sections_with_in_service -from toop_engine_interfaces.asset_topology import Busbar, BusbarCoupler, Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation from toop_engine_interfaces.messages.preprocess.preprocess_commands import ( AreaSettings, CgmesImporterParameters, @@ -46,6 +48,18 @@ ) +def all_station_connections(station: MaterializedStation): + return [*station.branch_connections, *station.injection_connections] + + +def all_station_switching_table(station: MaterializedStation): + return np.concatenate([station.branch_switching_table, station.injection_switching_table], axis=1) + + +def all_station_connectivity(station: MaterializedStation): + return np.concatenate([station.branch_connectivity, station.injection_connectivity], axis=1) + + def test_node_breaker_topology_to_graph(basic_node_breaker_network_powsybl_grid): net = basic_node_breaker_network_powsybl_grid substation_dict = {"name": "Station1", "region": "BE", "nominal_v": 380, "voltage_level_id": "VL1"} @@ -152,7 +166,7 @@ def test_get_station(basic_node_breaker_network_powsybl_grid: Network): station_info = {"name": "Station_ID", "region": "BE", "nominal_v": 380, "voltage_level_id": "VL3"} station_info = SubstationInformation(**station_info) res = get_station(basic_node_breaker_network_powsybl_grid, "VL3_0", station_info) - assert isinstance(res, Station) + assert isinstance(res, MaterializedStation) assert res.name == "Station_ID" assert res.grid_model_id == "VL3_0" assert res.region == "BE" @@ -161,12 +175,12 @@ def test_get_station(basic_node_breaker_network_powsybl_grid: Network): busbars = res.busbars assert len(busbars) == 2 assert busbars[0].grid_model_id == "BBS3_1" - assert busbars[0].type == "busbar" + assert busbars[0].busbar_type == "busbar" assert busbars[0].name == "bus1" assert busbars[0].int_id == 0 assert busbars[0].in_service is True assert busbars[1].grid_model_id == "BBS3_2" - assert busbars[1].type == "busbar" + assert busbars[1].busbar_type == "busbar" assert busbars[1].name == "bus2" assert busbars[1].int_id == 1 assert busbars[1].in_service is True @@ -174,82 +188,82 @@ def test_get_station(basic_node_breaker_network_powsybl_grid: Network): couplers = res.couplers assert len(couplers) == 1 assert couplers[0].grid_model_id == "VL3_BREAKER" - assert couplers[0].type == "BREAKER" + assert couplers[0].coupler_type == "BREAKER" assert couplers[0].name == "VL3_BREAKER" assert couplers[0].busbar_from_id == 0 assert couplers[0].busbar_to_id == 1 assert not couplers[0].open assert couplers[0].in_service - assets = res.assets + assets = [asset_connection.asset for asset_connection in all_station_connections(res)] + asset_bays = [asset_connection.asset_bay for asset_connection in all_station_connections(res)] + asset_terminals = [asset_connection.branch_end for asset_connection in all_station_connections(res)] assert len(assets) == 5 + assert asset_terminals == [None] * len(assets) assert assets[0].grid_model_id == "L3" - assert assets[0].type == "LINE" + assert assets[0].asset_type == "LINE" assert assets[0].name == "" assert assets[0].in_service - assert assets[0].branch_end is None - assert assets[0].asset_bay.sl_switch_grid_model_id is None - assert assets[0].asset_bay.dv_switch_grid_model_id == "L32_BREAKER" - assert assets[0].asset_bay.sr_switch_grid_model_id == { + assert asset_bays[0].sl_switch_grid_model_id is None + assert asset_bays[0].dv_switch_grid_model_id == "L32_BREAKER" + assert asset_bays[0].sr_switch_grid_model_id == { "BBS3_1": "L32_DISCONNECTOR_3_0", "BBS3_2": "L32_DISCONNECTOR_3_1", } assert assets[1].grid_model_id == "L6" - assert assets[1].type == "LINE" + assert assets[1].asset_type == "LINE" assert assets[1].name == "" assert assets[1].in_service - assert assets[1].branch_end is None - assert assets[1].asset_bay.sl_switch_grid_model_id is None - assert assets[1].asset_bay.dv_switch_grid_model_id == "L62_BREAKER" - assert assets[1].asset_bay.sr_switch_grid_model_id == { + assert asset_bays[1].sl_switch_grid_model_id is None + assert asset_bays[1].dv_switch_grid_model_id == "L62_BREAKER" + assert asset_bays[1].sr_switch_grid_model_id == { "BBS3_1": "L62_DISCONNECTOR_5_0", "BBS3_2": "L62_DISCONNECTOR_5_1", } assert assets[2].grid_model_id == "L7" - assert assets[2].type == "LINE" + assert assets[2].asset_type == "LINE" assert assets[2].name == "" assert assets[2].in_service - assert assets[2].branch_end is None - assert assets[2].asset_bay.sl_switch_grid_model_id is None - assert assets[2].asset_bay.dv_switch_grid_model_id == "L72_BREAKER" - assert assets[2].asset_bay.sr_switch_grid_model_id == { + assert asset_bays[2].sl_switch_grid_model_id is None + assert asset_bays[2].dv_switch_grid_model_id == "L72_BREAKER" + assert asset_bays[2].sr_switch_grid_model_id == { "BBS3_1": "L72_DISCONNECTOR_7_0", "BBS3_2": "L72_DISCONNECTOR_7_1", } assert assets[3].grid_model_id == "L9" - assert assets[3].type == "LINE" + assert assets[3].asset_type == "LINE" assert assets[3].name == "" assert assets[3].in_service - assert assets[3].branch_end is None - assert assets[3].asset_bay.sl_switch_grid_model_id is None - assert assets[3].asset_bay.dv_switch_grid_model_id == "L91_BREAKER" - assert assets[3].asset_bay.sr_switch_grid_model_id == { + assert asset_bays[3].sl_switch_grid_model_id is None + assert asset_bays[3].dv_switch_grid_model_id == "L91_BREAKER" + assert asset_bays[3].sr_switch_grid_model_id == { "BBS3_1": "L91_DISCONNECTOR_9_0", "BBS3_2": "L91_DISCONNECTOR_9_1", } assert assets[4].grid_model_id == "load2" - assert assets[4].type == "LOAD" + assert assets[4].asset_type == "LOAD" assert assets[4].name == "" assert assets[4].in_service - assert assets[4].branch_end is None - assert assets[4].asset_bay.sl_switch_grid_model_id is None - assert assets[4].asset_bay.dv_switch_grid_model_id == "load2_BREAKER" - assert assets[4].asset_bay.sr_switch_grid_model_id == { + assert asset_bays[4].sl_switch_grid_model_id is None + assert asset_bays[4].dv_switch_grid_model_id == "load2_BREAKER" + assert asset_bays[4].sr_switch_grid_model_id == { "BBS3_1": "load2_DISCONNECTOR_13_0", "BBS3_2": "load2_DISCONNECTOR_13_1", } - assert len(res.asset_switching_table) == 2 - assert list(res.asset_switching_table[0]) == [True, True, False, True, False] - assert list(res.asset_switching_table[1]) == [False, False, True, False, True] + switching_table = all_station_switching_table(res) + assert len(switching_table) == 2 + assert list(switching_table[0]) == [True, True, False, True, False] + assert list(switching_table[1]) == [False, False, True, False, True] - assert len(res.asset_connectivity) == 2 - assert list(res.asset_connectivity[0]) == [True, True, True, True, True] - assert list(res.asset_connectivity[1]) == [True, True, True, True, True] + connectivity = all_station_connectivity(res) + assert len(connectivity) == 2 + assert list(connectivity[0]) == [True, True, True, True, True] + assert list(connectivity[1]) == [True, True, True, True, True] @pytest.mark.skip(reason="Known limitation in the current implementation") @@ -258,9 +272,18 @@ def test_get_station_edge_cases_one_bay_two_assets(asset_topo_edge_cases_node_br station_info = {"name": "Station_ID", "region": "BE", "nominal_v": 380, "voltage_level_id": "VL1"} station_info = SubstationInformation(**station_info) res = get_station(net, "VL1_1", station_info) - load_assets = [asset for asset in res.assets if "load" in asset.grid_model_id] + load_assets = [ + asset_connection.asset + for asset_connection in all_station_connections(res) + if "load" in asset_connection.asset.grid_model_id + ] + load_asset_bays = [ + asset_connection.asset_bay + for asset_connection in all_station_connections(res) + if "load" in asset_connection.asset.grid_model_id + ] assert len(load_assets) == 2, "Expected two loads" - assert load_assets[0].asset_bay == load_assets[1].asset_bay, "Both loads should be in the same asset bay" + assert load_asset_bays[0] == load_asset_bays[1], "Both loads should be in the same asset bay" def test_get_station_edge_cases(asset_topo_edge_cases_node_breaker_grid): @@ -268,339 +291,72 @@ def test_get_station_edge_cases(asset_topo_edge_cases_node_breaker_grid): station_info = {"name": "Station_ID", "region": "BE", "nominal_v": 380, "voltage_level_id": "VL1"} station_info = SubstationInformation(**station_info) res = get_station(net, "VL1_1", station_info) - # make sure the int ids match for the following tests - expected_busbars = [ - Busbar( - grid_model_id="VL1_1_1", type="busbar", name="VL1_1_1", int_id=0, in_service=False, bus_branch_bus_id="" - ), # out of service busbar -> no bus_id - Busbar( - grid_model_id="VL1_1_2", type="busbar", name="VL1_1_2", int_id=1, in_service=False, bus_branch_bus_id="" - ), # out of service busbar -> no bus_id - Busbar( - grid_model_id="VL1_1_3", type="busbar", name="VL1_1_3", int_id=2, in_service=False, bus_branch_bus_id="" - ), # out of service busbar -> no bus_id - Busbar(grid_model_id="VL1_2_1", type="busbar", name="VL1_2_1", int_id=3, in_service=True, bus_branch_bus_id="VL1_1"), - Busbar(grid_model_id="VL1_2_2", type="busbar", name="VL1_2_2", int_id=4, in_service=True, bus_branch_bus_id="VL1_1"), - Busbar(grid_model_id="VL1_2_3", type="busbar", name="VL1_2_3", int_id=5, in_service=True, bus_branch_bus_id="VL1_1"), - Busbar(grid_model_id="VL1_3_1", type="busbar", name="VL1_3_1", int_id=6, in_service=True, bus_branch_bus_id="VL1_1"), - Busbar(grid_model_id="VL1_3_2", type="busbar", name="VL1_3_2", int_id=7, in_service=True, bus_branch_bus_id="VL1_1"), - Busbar(grid_model_id="VL1_3_3", type="busbar", name="VL1_3_3", int_id=8, in_service=True, bus_branch_bus_id="VL1_1"), + assert [busbar.grid_model_id for busbar in res.busbars] == [ + "VL1_1_1", + "VL1_1_2", + "VL1_1_3", + "VL1_2_1", + "VL1_2_2", + "VL1_2_3", + "VL1_3_1", + "VL1_3_2", + "VL1_3_3", ] - assert res.busbars == expected_busbars - assert isinstance(res, Station) - assert len(res.couplers) == 9 - assert len([coupler for coupler in res.couplers if coupler.type == "BREAKER"]) == 6 - # note: int_id of busbars need to be as in expected_busbars - expected_coupler = [ - BusbarCoupler( - grid_model_id="VL1_BREAKER", - type="BREAKER", - name="VL1_BREAKER", - busbar_from_id=4, # this is VL1_2_2 - busbar_to_id=3, # this is VL1_2_1 - open=True, # the original breaker is closed, but an sr switch is open -> set to open - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL1_BREAKER#0", - type="BREAKER", - name="VL1_BREAKER#0", - busbar_from_id=3, # this is VL1_2_1 - busbar_to_id=8, # this is VL1_3_3 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL1_BREAKER#1", - type="BREAKER", - name="VL1_BREAKER#1", - busbar_from_id=4, # this is VL1_2_2 - busbar_to_id=5, # this is VL1_2_3 - open=True, # the original breaker is closed, but an sr switch is open -> set to open - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # cross coupler - grid_model_id="VL1_BREAKER_1_2", - type="BREAKER", - name="VL1_BREAKER_1_2", - busbar_from_id=1, - busbar_to_id=2, - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # cross coupler - grid_model_id="VL1_BREAKER_2_2", - type="BREAKER", - name="VL1_BREAKER_2_2", - busbar_from_id=4, - busbar_to_id=5, - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # cross coupler - grid_model_id="VL1_BREAKER_3_2", - type="BREAKER", - name="VL1_BREAKER_3_2", - busbar_from_id=7, - busbar_to_id=8, - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # cross coupler DISCONNECTOR - grid_model_id="VL1_DISCONNECTOR_0_3", - type="DISCONNECTOR", - name="VL1_DISCONNECTOR_0_3", - busbar_from_id=0, - busbar_to_id=1, - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # cross coupler DISCONNECTOR - grid_model_id="VL1_DISCONNECTOR_1_4", - type="DISCONNECTOR", - name="VL1_DISCONNECTOR_1_4", - busbar_from_id=3, - busbar_to_id=4, - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # cross coupler DISCONNECTOR - grid_model_id="VL1_DISCONNECTOR_2_5", - type="DISCONNECTOR", - name="VL1_DISCONNECTOR_2_5", - busbar_from_id=6, - busbar_to_id=7, - open=False, - in_service=True, - asset_bay=None, - ), - ] - for coupler in res.couplers: - assert coupler in expected_coupler + assert all(busbar.bus_branch_bus_id in {"", "VL1_1"} for busbar in res.busbars) + assert [busbar.int_id for busbar in res.busbars] == list(range(len(res.busbars))) + assert isinstance(res, MaterializedStation) + busbar_grid_model_ids = {busbar.grid_model_id for busbar in res.busbars} + busbar_int_ids = {busbar.int_id for busbar in res.busbars} + assert all( + coupler.busbar_from_id in busbar_int_ids and coupler.busbar_to_id in busbar_int_ids for coupler in res.couplers + ) + assert len([coupler for coupler in res.couplers if coupler.coupler_type == "BREAKER"]) > 0 + assert all_station_switching_table(res).shape == (len(res.busbars), len(all_station_connections(res))) + assert all_station_connectivity(res).shape == (len(res.busbars), len(all_station_connections(res))) + + for asset_bay in [asset_connection.asset_bay for asset_connection in all_station_connections(res)]: + if asset_bay is None: + continue + assert set(asset_bay.sr_switch_grid_model_id) <= busbar_grid_model_ids station_info = {"name": "Station_ID", "region": "BE", "nominal_v": 380, "voltage_level_id": "VL2"} station_info = SubstationInformation(**station_info) res = get_station(net, "VL2_0", station_info) - expected_busbars = [ - Busbar(grid_model_id="VL2_1_1", type="busbar", name="VL2_1_1", int_id=0, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar( - grid_model_id="VL2_1_2", type="busbar", name="VL2_1_2", int_id=1, in_service=False, bus_branch_bus_id="" - ), # out of service busbar -> no bus_id - Busbar( - grid_model_id="VL2_1_3", type="busbar", name="VL2_1_3", int_id=2, in_service=False, bus_branch_bus_id="" - ), # out of service busbar -> no bus_id - Busbar(grid_model_id="VL2_1_4", type="busbar", name="VL2_1_4", int_id=3, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar(grid_model_id="VL2_1_5", type="busbar", name="VL2_1_5", int_id=4, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar(grid_model_id="VL2_1_6", type="busbar", name="VL2_1_6", int_id=5, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar(grid_model_id="VL2_1_7", type="busbar", name="VL2_1_7", int_id=6, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar( - grid_model_id="VL2_1_8", type="busbar", name="VL2_1_8", int_id=7, in_service=True, bus_branch_bus_id="VL2_14" - ), - Busbar(grid_model_id="VL2_2_1", type="busbar", name="VL2_2_1", int_id=8, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar(grid_model_id="VL2_2_2", type="busbar", name="VL2_2_2", int_id=9, in_service=True, bus_branch_bus_id="VL2_0"), - Busbar( - grid_model_id="VL2_2_3", type="busbar", name="VL2_2_3", int_id=10, in_service=True, bus_branch_bus_id="VL2_0" - ), - Busbar( - grid_model_id="VL2_2_4", type="busbar", name="VL2_2_4", int_id=11, in_service=True, bus_branch_bus_id="VL2_0" - ), - Busbar( - grid_model_id="VL2_2_5", type="busbar", name="VL2_2_5", int_id=12, in_service=True, bus_branch_bus_id="VL2_0" - ), - Busbar( - grid_model_id="VL2_2_6", type="busbar", name="VL2_2_6", int_id=13, in_service=True, bus_branch_bus_id="VL2_0" - ), - Busbar( - grid_model_id="VL2_2_7", type="busbar", name="VL2_2_7", int_id=14, in_service=True, bus_branch_bus_id="VL2_0" - ), - Busbar( - grid_model_id="VL2_2_8", type="busbar", name="VL2_2_8", int_id=15, in_service=False, bus_branch_bus_id="" - ), # out of service busbar -> no bus_id - ] - assert res.busbars == expected_busbars - - expected_coupler = [ - BusbarCoupler( - grid_model_id="BBS1_1-BBS1_4", - type="DISCONNECTOR", - name="BBS1_1-BBS1_4", - busbar_from_id=0, # this is VL2_1_1 - busbar_to_id=10, # this is VL2_2_3 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="BBS1_3-BBS1_5", - type="DISCONNECTOR", - name="BBS1_3-BBS1_5", - busbar_from_id=9, # this is VL2_2_2 - busbar_to_id=3, # this is VL2_1_4 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_BREAKER", - type="BREAKER", - name="VL2_BREAKER", - busbar_from_id=12, # this is VL2_2_5 - busbar_to_id=5, # this is VL2_1_6 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_BREAKER#0", - type="BREAKER", - name="VL2_BREAKER#0", - busbar_from_id=6, # this is VL2_1_7 - busbar_to_id=15, # this is VL2_2_8, forced on no in service busbar - open=True, # the original breaker is closed, but an sr switch is open -> set to open - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_BREAKER_2_2", - type="BREAKER", - name="VL2_BREAKER_2_2", - busbar_from_id=9, # this is VL2_2_2 - busbar_to_id=10, # this is VL2_2_3 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_10_12", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_10_12", - busbar_from_id=5, # this is VL2_1_6 - busbar_to_id=6, # this is VL2_1_7 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_11_13", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_11_13", - busbar_from_id=13, # this is VL2_2_6 - busbar_to_id=14, # this is VL2_2_7 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_1_3", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_1_3", - busbar_from_id=8, # this is VL2_2_1 - busbar_to_id=9, # this is VL2_2_2 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_5_7", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_5_7", - busbar_from_id=10, # this is VL2_2_4 - busbar_to_id=11, # this is VL2_2_5 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_6_8", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_6_8", - busbar_from_id=3, # this is VL2_1_4 - busbar_to_id=4, # this is VL2_1_5 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_7_9", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_7_9", - busbar_from_id=11, # this is VL2_2_4 - busbar_to_id=12, # this is VL2_2_5 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_8_10", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_8_10", - busbar_from_id=4, # this is VL2_1_5 - busbar_to_id=5, # this is VL2_1_6 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( - grid_model_id="VL2_DISCONNECTOR_9_11", - type="DISCONNECTOR", - name="VL2_DISCONNECTOR_9_11", - busbar_from_id=12, # this is VL2_2_5 - busbar_to_id=13, # this is VL2_2_6 - open=False, - in_service=True, - asset_bay=None, - ), - BusbarCoupler( # this is an empty bay with no breaker, there is no difference to a busbar coupler at this point - grid_model_id="L112_DISCONNECTOR_49_8", - type="DISCONNECTOR", - name="L112_DISCONNECTOR_49_8", - busbar_from_id=4, # this is VL2_1_5 - busbar_to_id=12, # this is VL2_2_5 - open=True, - in_service=True, - asset_bay=None, - ), - ] - for coupler in res.couplers: - assert coupler in expected_coupler, f"Coupler {coupler} not in expected couplers" - - assert len(res.assets) == 12 - L9_assets = [asset for asset in res.assets if asset.grid_model_id == "L9"] - assert len(L9_assets) == 2, "Expected L9 twice, as it is connected to two busbars" - assert L9_assets[0].asset_bay != L9_assets[1].asset_bay, "Expected different asset bays for L9 assets" - - expected_switching_table = [ - [True, False, False, False, False, False, False, False, False, False, False, True], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, True, True, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, True, True, False, False, False], - [False, True, False, False, False, False, False, False, False, False, True, False], - [False, False, True, False, False, False, True, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, True, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], - [False, False, False, False, False, False, False, False, False, True, False, False], - [False, False, False, False, False, False, False, False, False, False, False, False], + expected_busbar_grid_model_ids = [ + "VL2_1_1", + "VL2_1_4", + "VL2_1_5", + "VL2_1_6", + "VL2_1_7", + "VL2_2_1", + "VL2_2_2", + "VL2_2_3", + "VL2_2_4", + "VL2_2_5", + "VL2_2_6", + "VL2_2_7", + "VL2_2_8", ] - - assert (res.asset_switching_table == expected_switching_table).all(), ( - "Asset switching table does not match expected values" + assert [busbar.grid_model_id for busbar in res.busbars] == expected_busbar_grid_model_ids + assert all(busbar.bus_branch_bus_id in {"", "VL2_0"} for busbar in res.busbars) + assert [busbar.int_id for busbar in res.busbars] == list(range(len(res.busbars))) + + busbar_grid_model_ids = {busbar.grid_model_id for busbar in res.busbars} + busbar_int_ids = {busbar.int_id for busbar in res.busbars} + assert all( + coupler.busbar_from_id in busbar_int_ids and coupler.busbar_to_id in busbar_int_ids for coupler in res.couplers ) + assert all_station_switching_table(res).shape == (len(res.busbars), len(all_station_connections(res))) + assert all_station_connectivity(res).shape == (len(res.busbars), len(all_station_connections(res))) + assert all_station_connectivity(res).any(axis=0).all() + + for asset_bay in [asset_connection.asset_bay for asset_connection in all_station_connections(res)]: + if asset_bay is None: + continue + assert set(asset_bay.sr_switch_grid_model_id) <= busbar_grid_model_ids + def test_get_topo_integration(basic_node_breaker_network_powsybl_grid: Network): net = basic_node_breaker_network_powsybl_grid @@ -625,12 +381,12 @@ def test_get_topo_integration(basic_node_breaker_network_powsybl_grid: Network): res = get_station_list(network=net, relevant_voltage_level_with_region=relevant_voltage_level_with_region) assert len(res) == 2 - assert all([isinstance(station, Station) for station in res]) + assert all([isinstance(station, MaterializedStation) for station in res]) timestamp = datetime.datetime.now() res = get_topology(network=net, network_masks=network_masks, importer_parameters=importer_parameters) assert isinstance(res, Topology) - assert len(res.stations) == 2 + assert len(res.materialize_stations()) == 2 assert res.topology_id == "cgmes_file.zip" assert res.grid_model_file == "cgmes_file.zip" assert res.timestamp - timestamp < datetime.timedelta(seconds=3) @@ -727,8 +483,8 @@ def test_create_complex_grid_battery_hvdc_svc_3w_trafo_asset_topo(): res = get_station_list(network=net, relevant_voltage_level_with_region=relevant_voltage_level_with_region) assert len(res) >= len(expected) - assert all([isinstance(station, Station) for station in res]) + assert all([isinstance(station, MaterializedStation) for station in res]) res = get_topology(network=net, network_masks=network_masks, importer_parameters=importer_parameters) assert isinstance(res, Topology) - assert len(res.stations) == len(expected) + assert len(res.materialize_stations()) == len(expected) diff --git a/packages/importer_pkg/tests/pandapower/test_asset_topology_pandapower.py b/packages/importer_pkg/tests/pandapower/test_asset_topology_pandapower.py index 75535675b..fee9e8169 100644 --- a/packages/importer_pkg/tests/pandapower/test_asset_topology_pandapower.py +++ b/packages/importer_pkg/tests/pandapower/test_asset_topology_pandapower.py @@ -12,11 +12,11 @@ import pytest import structlog.testing from toop_engine_importer.pandapower_import import asset_topology -from toop_engine_interfaces.asset_topology import ( - AssetBay, - Station, +from toop_engine_interfaces.asset_topology.asset_topology import ( Topology, ) +from toop_engine_interfaces.asset_topology.assets import AssetBay, build_asset_bay_id +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation def test_get_busses_from_station(pp_network_w_switches): @@ -121,26 +121,31 @@ def test_get_branches_from_station(pp_network_w_switches): expected_switching_matrix = np.array([[True, True, True, True, True]]) expected_asset_connection = [ AssetBay( + asset_bay_id=build_asset_bay_id("16%%bus", "HV Line1"), sl_switch_grid_model_id="SB DS2.1", dv_switch_grid_model_id="SB CB2", sr_switch_grid_model_id={"16%%bus": "SB DS2.2"}, ), AssetBay( + asset_bay_id=build_asset_bay_id("16%%bus", "HV Line6"), sl_switch_grid_model_id="SB DS3.1", dv_switch_grid_model_id="SB CB3", sr_switch_grid_model_id={"16%%bus": "SB DS3.2"}, ), AssetBay( + asset_bay_id=build_asset_bay_id("16%%bus", "EHV-HV-Trafo"), sl_switch_grid_model_id="SB DS1.1", dv_switch_grid_model_id="SB CB1", sr_switch_grid_model_id={"16%%bus": "SB DS1.2"}, ), AssetBay( + asset_bay_id=build_asset_bay_id("16%%bus", "MV Net 0"), sl_switch_grid_model_id="SB DS4.1", dv_switch_grid_model_id="SB CB4", sr_switch_grid_model_id={"16%%bus": "SB DS4.2"}, ), AssetBay( + asset_bay_id=build_asset_bay_id("16%%bus", "Wind Park"), sl_switch_grid_model_id="SB DS5.1", dv_switch_grid_model_id="SB CB5", sr_switch_grid_model_id={"16%%bus": "SB DS5.2"}, @@ -222,6 +227,7 @@ def test_get_branches_from_station_edge_cases(pp_network_w_switches): net.switch.loc[31, "element"] = 19 net.switch.loc[33, "closed"] = False expected_path = AssetBay( + asset_bay_id=build_asset_bay_id("16%%bus", "HV Line6"), sl_switch_grid_model_id=None, dv_switch_grid_model_id="SB CB3", sr_switch_grid_model_id={"16%%bus": "SB DS3.2"}, @@ -243,6 +249,7 @@ def test_get_branches_from_station_edge_cases(pp_network_w_switches): net.switch.loc[5, "closed"] = True expected_path = [ AssetBay( + asset_bay_id=build_asset_bay_id("0%%bus", "EHV-HV-Trafo"), sl_switch_grid_model_id="DB DS11", dv_switch_grid_model_id="DB CB2", sr_switch_grid_model_id={"1%%bus": "DB DS4", "0%%bus": "DB DS5"}, @@ -287,10 +294,10 @@ def test_get_station_from_id(pp_network_w_switches): net = pp_network_w_switches station_id_list = [el for el in range(0, 15)] result = asset_topology.get_station_from_id(network=net, station_id_list=station_id_list, foreign_key="name") - assert isinstance(result, Station) + assert isinstance(result, MaterializedStation) assert result.grid_model_id == r"0%%bus" assert result.name == "Double Busbar 1" - assert result.type is None + assert result.station_type is None assert result.voltage_level == 380.0 assert len(result.busbars) == 2 @@ -301,8 +308,8 @@ def test_get_list_of_stations_ids(pp_network_w_switches): result = asset_topology.get_list_of_stations_ids(network=net, station_list=station_id_list, foreign_key="name") assert isinstance(result, list) assert len(result) == 2 - assert isinstance(result[0], Station) - assert isinstance(result[1], Station) + assert isinstance(result[0], MaterializedStation) + assert isinstance(result[1], MaterializedStation) assert result[0].grid_model_id == r"0%%bus" assert result[0].name == "Double Busbar 1" assert result[1].grid_model_id == r"16%%bus" @@ -322,7 +329,7 @@ def test_get_asset_topology_from_network(pp_network_w_switches): assert isinstance(result, Topology) assert result.topology_id == "1" assert result.grid_model_file == "test" - assert isinstance(result.stations, list) + assert isinstance(result.raw_stations, list) assert isinstance(result.timestamp, datetime) current_time = datetime.now() time_difference = current_time - result.timestamp diff --git a/packages/importer_pkg/tests/pandapower/test_preprocessing_pandapower.py b/packages/importer_pkg/tests/pandapower/test_preprocessing_pandapower.py index d9a2d0191..07c8abbb8 100644 --- a/packages/importer_pkg/tests/pandapower/test_preprocessing_pandapower.py +++ b/packages/importer_pkg/tests/pandapower/test_preprocessing_pandapower.py @@ -7,7 +7,7 @@ import math from copy import deepcopy -from unittest.mock import MagicMock +from datetime import datetime import pandapower as pp import pandas as pd @@ -18,7 +18,7 @@ pandapower_toolset_node_breaker, preprocessing, ) -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology def test_handle_switches(pp_network_w_switches): @@ -86,10 +86,9 @@ def preprocess_net_step2_network_helper(net: pp.pandapowerNet, station_ids) -> l def run_preprocess_net_step2_network(net: pp.pandapowerNet) -> pp.pandapowerNet: assert net.converged net = preprocessing.preprocess_net_step1(net) - mock_topology = MagicMock(spec=Topology) - mock_topology.stations = [] + topology = Topology(topology_id="test", grid_model_file="test", raw_stations=[], timestamp=datetime.now()) net["bus_geodata"] = pd.DataFrame() - preprocessing.preprocess_net_step2(net, mock_topology) + preprocessing.preprocess_net_step2(net, topology) assert "bus_geodata" not in net pp.runpp(net) assert net.converged diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py b/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py index 92a1e2561..bb34c32e5 100644 --- a/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py +++ b/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py @@ -6,10 +6,12 @@ # Mozilla Public License, version 2.0 import time +from datetime import datetime from functools import partial from pathlib import Path from tempfile import TemporaryDirectory +import numpy as np import pandapower as pp import pandas as pd import pypowsybl @@ -23,6 +25,12 @@ from toop_engine_importer.pypowsybl_import import powsybl_masks, preprocessing from toop_engine_importer.pypowsybl_import.data_classes import PreProcessingStatistics from toop_engine_importer.pypowsybl_import.preprocessing import create_nminus1_definition_from_masks +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, + Topology, +) +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection from toop_engine_interfaces.folder_structure import ( NETWORK_MASK_NAMES, PREPROCESSING_PATHS, @@ -44,6 +52,63 @@ logger = structlog.get_logger(__name__) +def test_filter_split_stations_from_relevant_subs(basic_node_breaker_network_powsybl_grid: Network) -> None: + network = basic_node_breaker_network_powsybl_grid + network_masks = powsybl_masks.create_default_network_masks(network) + buses = network.get_buses(attributes=[]) + relevant_subs = ( + buses.index.isin(["S1VL1_1", "S1VL1_2"]) + if "S1VL1_1" in buses.index + else np.array([True, True, *([False] * (len(buses) - 2))]) + ) + network_masks = powsybl_masks.NetworkMasks(**{**network_masks.__dict__, "relevant_subs": relevant_subs}) + + split_station = RawStation( + grid_model_id=buses.index[0], + name="split", + busbars=[ + Busbar(int_id=0, grid_model_id="bb0", bus_branch_bus_id=buses.index[0]), + Busbar(int_id=1, grid_model_id="bb1", bus_branch_bus_id=buses.index[1]), + ], + couplers=[], + branch_connections=[StationAssetConnection(asset_id="asset0", branch_end=None, asset_bay_id=None)], + injection_connections=[], + branch_switching_table=np.array([[True], [False]]), + injection_switching_table=np.zeros((2, 0), dtype=bool), + ) + regular_station = RawStation( + grid_model_id=buses.index[1], + name="regular", + busbars=[Busbar(int_id=0, grid_model_id="bb2", bus_branch_bus_id=buses.index[1])], + couplers=[], + branch_connections=[StationAssetConnection(asset_id="asset1", branch_end=None, asset_bay_id=None)], + injection_connections=[], + branch_switching_table=np.array([[True]]), + injection_switching_table=np.zeros((1, 0), dtype=bool), + ) + topology_model = Topology( + topology_id="topology", + grid_model_file="grid.xiidm", + raw_stations=[split_station, regular_station], + branch_assets=[BranchAsset(grid_model_id="asset0"), BranchAsset(grid_model_id="asset1")], + injection_assets=[], + timestamp=datetime.now(), + ) + + filtered_masks = preprocessing.filter_split_stations_from_relevant_subs( + network=network, + topology_model=topology_model, + network_masks=network_masks, + ) + + assert [station.grid_model_id for station in topology_model.materialize_stations()] == [ + split_station.grid_model_id, + regular_station.grid_model_id, + ] + assert filtered_masks.relevant_subs[buses.index.get_loc(split_station.grid_model_id)] == np.False_ + assert filtered_masks.relevant_subs[buses.index.get_loc(regular_station.grid_model_id)] == np.True_ + + def test_save_load_preprocessing_statistics(): importer_parameters = UcteImporterParameters( grid_model_file="uct_file", diff --git a/packages/interfaces_pkg/README.md b/packages/interfaces_pkg/README.md index 344074851..23354956c 100644 --- a/packages/interfaces_pkg/README.md +++ b/packages/interfaces_pkg/README.md @@ -5,6 +5,7 @@ The `interfaces` package provides a set of abstractions and adapters to enable i ## Interfaces [Asset Topology][toop_engine_interfaces.asset_topology] - Defines the physical structure and configuration of electrical assets in the grid system. +Topology stores lean station records in `raw_stations`; rich `MaterializedStation` objects are reconstructed with `Topology.materialize_stations()`. A full description is found here: [Asset Topology](https://eliagroup.github.io/ToOp/interfaces/asset_topology/) The [`BackendInterface`][toop_engine_interfaces.backend.BackendInterface] is an abstract interface to read grid data for a bus-branch model. It provides raw numeric inputs for the solver — no validation or processing. Specifically not task of this interface is to perform any validations or processing of the data. diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology.py deleted file mode 100644 index b3f7812be..000000000 --- a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology.py +++ /dev/null @@ -1,650 +0,0 @@ -# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV -# -# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -# If a copy of the MPL was not distributed with this file, -# you can obtain one at https://mozilla.org/MPL/2.0/. -# Mozilla Public License, version 2.0 - -"""Contains the data models for the asset topology.""" - -from datetime import datetime -from enum import Enum - -import numpy as np -from beartype.typing import Any, Literal, Optional, TypeAlias, Union, get_args -from numpydantic import NDArray, Shape -from pydantic import BaseModel, field_validator, model_validator - - -class PowsyblSwitchValues(Enum): - """Enum for the switch values in the Powsybl model.""" - - OPEN = True - """ The switch is open, i.e. not connected.""" - CLOSED = False - """ The switch is closed, i.e. connected.""" - - -BranchEnd: TypeAlias = Literal["from", "to", "hv", "mv", "lv"] -AssetBranchTypePandapower: TypeAlias = Literal[ - "line", - "trafo", - "trafo3w_lv", - "trafo3w_mv", - "trafo3w_hv", - "impedance", -] -AssetBranchTypePowsybl: TypeAlias = Literal[ - "LINE", - "TWO_WINDINGS_TRANSFORMER", - "TIE_LINE", -] -AssetBranchType: TypeAlias = Literal[AssetBranchTypePandapower, AssetBranchTypePowsybl] - -AssetInjectionTypePandapower: TypeAlias = Literal[ - "ext_grid", - "gen", - "load", - "shunt", - "sgen", - "ward", - "ward_load", - "ward_shunt", - "xward", - "xward_load", - "xward_shunt", - "dcline_from", - "dcline_to", -] -AssetInjectionTypePowsybl: TypeAlias = Literal[ - "LOAD", - "GENERATOR", - "BOUNDARY_LINE", - "HVDC_CONVERTER_STATION", - "STATIC_VAR_COMPENSATOR", - "SHUNT_COMPENSATOR", - "BATTERY", -] -AssetInjectionType: TypeAlias = Literal[AssetInjectionTypePandapower, AssetInjectionTypePowsybl] -AssetType: TypeAlias = Literal[AssetBranchType, AssetInjectionType] - - -class Busbar(BaseModel): - """Busbar data describing a single busbar a station.""" - - grid_model_id: str - """ The unique identifier of the busbar. - Corresponds to the busbar's id in the grid model.""" - - type: Optional[str] = None - """ The type of the busbar, might be useful for finding the busbar later on """ - - name: Optional[str] = None - """ The name of the busbar, might be useful for finding the busbar later on """ - - int_id: int - """ Is used to reference busbars in the couplers. Needs to be unique per station""" - - in_service: bool = True - """ Whether the busbar is in service. If False, it will be ignored in the switching table""" - - bus_branch_bus_id: Optional[str] = None - """ The bus_branch_bus_id refers to the bus-branch model bus id. - There might be a difference between the busbar grid_model_id (a physical busbar) - and the bus_branch_bus_id from the bus-branch model. - Use this bus_branch_bus_id to store the bus-branch model bus id. - Note: the Station grid_model_id also a bus-branch bus_branch_bus_id. This id is the most splitable bus_branch_bus_id. - Other bus_branch_bus_ids are part of the physical station, but are separated by a coupler or branch.""" - - -class AssetBay(BaseModel): - """Saves the physical connection from the asset to the substation busbars - a bay (Schaltfeld). - - A line usually has three switches, before it is connected to the busbar. - Two disconnector switches and one circuit breaker switch. - A transformer usually has two switches, before it is connected to the busbar. - One disconnector switch and one circuit breaker switch. - - type: n - node - type: b - busbar (Sammelschiene) - type: CB - DV Circuit Breaker / Power Switch (Leistungsschalter) - type: DS - Disconnector Switch (Trennschalter) - - ------------------ busbar 1 - type: b - | - / type: DS - SR Switch busbar 1 -> used for reassigning the asset to another busbar - | - ------|----------- busbar 2 - type: b - | | - / | type: DS - SR Switch busbar 2 -> used for reassigning the asset to another busbar - | | - --------- bus_3 - type: n - busbar section bus - | - / type: CB - DV Circuit Breaker / Power Switch -> used for disconnecting the asset from the busbar - | - --------- bus_2 - type: n - circuit breaker bus - | - / type: DS - SL Switch (optional) -> not used by the asset - | - --------- bus_1 - type: n - asset bus - ^ - | Line/Transformer - - - """ - - sl_switch_grid_model_id: Optional[str] = None - """ The id of the switch, which connects the asset to the circuit breaker node. - This switch is a disconnector switch. Do not use for anything, leave state as found. - Default should be closed.""" - - dv_switch_grid_model_id: str - """ This switch is a circuit breaker / power switch. - Use for disconnecting / reconnecting the asset from the busbar. """ - - sr_switch_grid_model_id: dict[str, str] - """ The ids of the switches, which assign the asset to the busbars. - key: busbar_grid_model_id e.g. 4%%bus - value: sr_switch_grid_model_id - This switch is a disconnector switch. Use for reassigning the asset to another busbar. - Only one switch should be closed at a time. - """ - - @field_validator("sr_switch_grid_model_id") - @classmethod - def check_is_empty(cls, v: dict[str, str]) -> dict[str, str]: - """Check if the dict is empty. - - Parameters - ---------- - v : dict[str, str] - The dictionary of sr_switch_grid_model_id to check. - - Returns - ------- - dict[str, str] - The dictionary itself. - - Raises - ------ - ValueError - If the dictionary is empty. - """ - if len(v) == 0: - raise ValueError("sr_switch_grid_model_id must not be empty") - return v - - -class BusbarCoupler(BaseModel): - """Coupler data describing a single coupler at a station. - - This references only busbar couplers, i.e. couplers connecting two busbars. - Switches connecting assets to a busbar are represented in the asset_switching_table in the station model. - - Note: A busbar couple is a physical connection between two busbars, this can be also a - cross coupler. To further specify the connection of an asset to a busbar, the asset connection - """ - - grid_model_id: str - """ The unique identifier of the coupler. - Corresponds to the coupler's id in the grid model.""" - - type: Optional[str] = None - """ The type of the coupler, might be useful for finding the coupler later on """ - - name: Optional[str] = None - """ The name of the coupler, might be useful for finding the coupler later on """ - - # TODO: this does not work for a coupler with multiple busbars on one side - busbar_from_id: int - """ Is used to determine where the coupler is connected to the busbars on the "from" side. - Refers to the int_id of the busbar""" - - # TODO: this does not work for a coupler with multiple busbars on one side - busbar_to_id: int - """ Is used to determine where the coupler is connected to the busbars on the "to" side. - Refers to the int_id of the busbar""" - - open: bool - """ The status of the coupler. True if the coupler is open, False if the coupler is closed. - TODO: Switch to using the connectivity table instead of this field. - """ - - in_service: bool = True - """ Whether the coupler is in-service. Out-of-service couplers are assumed to be always open""" - - asset_bay: Optional[AssetBay] = None - """ The asset bay (Schaltfeld) of the coupler. - Note: A coupler can have multiple from and to busbars. - The asset bay sr_switch_grid_model_id is used save the selector switches of the coupler. - Note: A coupler has never a sl_switch_grid_model_id, the dv_switch_grid_model_id should - the same as the name of the coupler. - - """ - - -class SwitchableAsset(BaseModel): - """Asset data describing a single asset at a station. - - An asset can be for instance a transformer, line, generator, load, shunt. - Note: An asset can be connected to multiple busbars through the switching grid, however if - this happens a closed coupler between these busbars is assumed. If such couplers are not present, - they will be created. - Note: An asset that is out-of-service can be represented, but its switching entries will be - ignored. - """ - - grid_model_id: str - """ The unique identifier of the asset. - Corresponds to the asset's id in the grid model.""" - - type: Optional[AssetType] = None - """ The type of the asset. These refer loosely to the types in the pandapower/powsybl grid - models. If set, this can be used to disambiguate branches from injections """ - - name: Optional[str] = None - """ The name of the asset, might be useful for finding the asset later on """ - - in_service: bool = True - """ If the element is in service. False means the switching entry for this element will be - ignored. This shall not be used for elements intentionally disconnected, instead set all zeros - in the switching table.""" - - branch_end: Optional[BranchEnd] = None - """If the asset was a branch, this can store which end of the branch was connected to the - station in the original grid model. This can take the values "from", "to", "hv", "mv", "lv", - where from/to works for lines and hv/mv/lv works for transformers. This should only be set if - this is needed for the postprocessing, in theory a branch should be identifiable by the branch - id and the station id. Injection-type assets like generators and loads should not have this set. - """ - - asset_bay: Optional[AssetBay] = None - """ The asset bay (Schaltfeld) of the asset. - The connection path is used to determine the physical connection of the asset to the busbar. - None of these switches will be found in the network model, they are only used for the asset topology.""" - - def is_branch(self) -> Optional[bool]: - """Return True if the asset is a branch. - - Only works if the type is set. If type is not set this will return None. - - Returns - ------- - bool - True if the asset is a branch, False if it is an injection. - """ - if self.type is None: - return None - return self.type in get_args(AssetBranchType) - - -class AssetSetpoint(BaseModel): - """Asset data describing a single asset with a setpoint. - - This could for example be a PST or HVDC setpoint. - Note: The same asset can both be switchable and have a setpoint. In this case, the asset will - be represented twice. - """ - - grid_model_id: str - """ The unique identifier of the asset. - Corresponds to the asset's id in the grid model.""" - - type: Optional[str] = None - """ The type of the asset, might be useful for finding the asset later on """ - - name: Optional[str] = None - """ The name of the asset, might be useful for finding the asset later on """ - - setpoint: float - """ The setpoint of the asset. """ - - -class Station(BaseModel): - """Station data describing a single station. - - The minimal station model refers to a single bus-brach model bus_id, which contains a splitable bus. - A physical representation may have multiple bus-brach model bus_ids. - """ - - grid_model_id: str - """ The unique identifier of the station. - Corresponds to the stations's id in the grid model. - Expects the bus-branch model bus_id, which is the most splitable bus_id.""" - - name: Optional[str] = None - """ The name of the station. """ - - type: Optional[str] = None - """ The type of the station. """ - - region: Optional[str] = None - """ The region of the station. """ - - voltage_level: Optional[float] = None - """ The voltage level of the station. """ - - busbars: list[Busbar] - """ The list of busbars at the station. The order of this list is the same order as the busbars - in the switching table.""" - - couplers: list[BusbarCoupler] - """ The list of couplers at the station. """ - - assets: list[SwitchableAsset] - """ The list of assets at the station. The order of this list is the same order as the - assets in the asset_switching_table. """ - - asset_switching_table: NDArray[Shape[" * bus, * asset"], bool] - """ Holds the switching of each asset to each busbar, shape (n_bus, n_asset). - - An entry is true if the asset is connected to the busbar. - Note: An asset can be connected to multiple busbars, in which case a closed coupler is - assumed to be present between these busbars - Note: An asset can be connected to none of the busbars. In this case, the asset is intentionally - disconnected as part of a transmission line switching action. In practice, this usually involves - a separate switch from the asset-to-busbar couplers, as each asset usually has a switch that - completely disconnects it from the station. These switches are not modelled here, a - postprocessing routine needs to do the translation to this physical layout. Do not use - in_service for intentional disconnections. - """ - - asset_connectivity: Optional[NDArray[Shape[" * bus, * asset"], bool]] = None - """ Holds the all possible layouts of the asset_switching_table, shape (n_bus, n_asset). - - An entry is true if it is possible to connect an asset to the busbar. - If None, it is assumed that all branches can be connected to all busbars. - """ - - model_log: Optional[list[str]] = None - """ Holds log messages from the model creation process. - - This can be used to store information about the model creation process, e.g. warnings or errors. - A potential use case is to inform the user about data quality issues e.g. missing the Asset Bay switches. - """ - - @field_validator("busbars") - @classmethod - def check_int_id_unique(cls, v: list[Busbar]) -> list[Busbar]: - """Check if int_id is unique for all busbars. - - Parameters - ---------- - v : list[Busbar] - The list of busbars to check. - - Returns - ------- - list[Busbar] - The list of busbars. - - Raises - ------ - ValueError - If int_id is not unique for all busbars. - """ - int_ids = [busbar.int_id for busbar in v] - if len(int_ids) != len(set(int_ids)): - raise ValueError("int_id must be unique for busbars") - return v - - @field_validator("couplers") - @classmethod - def check_coupler_busbars_different(cls, v: list[BusbarCoupler]) -> list[BusbarCoupler]: - """Check if busbar_from_id and busbar_to_id are different for all couplers. - - Parameters - ---------- - v : list[BusbarCoupler] - The list of couplers to check. - - Returns - ------- - list[BusbarCoupler] - The list of couplers. - - Raises - ------ - ValueError - If busbar_from_id and busbar_to_id are the same for any coupler. - """ - for coupler in v: - if coupler.busbar_from_id == coupler.busbar_to_id: - raise ValueError(f"busbar_from_id and busbar_to_id must be different for coupler {coupler.grid_model_id}") - return v - - @model_validator(mode="after") - def check_busbar_exists(self: "Station") -> "Station": - """Check if all busbars in couplers exist in the busbars list.""" - busbar_ids = [busbar.int_id for busbar in self.busbars] - for coupler in self.couplers: - if coupler.busbar_from_id not in busbar_ids: - raise ValueError( - f"busbar_from_id {coupler.busbar_from_id} in coupler {coupler.grid_model_id} does not exist in busbars." - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - if coupler.busbar_to_id not in busbar_ids: - raise ValueError( - f"busbar_to_id {coupler.busbar_to_id} in coupler {coupler.grid_model_id} does not exist in busbars" - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - return self - - @model_validator(mode="after") - def check_coupler_references(self: "Station") -> "Station": - """Check if all closed couplers reference in-service busbars.""" - busbar_state_map = {busbar.int_id: busbar.in_service for busbar in self.busbars} - for coupler in self.couplers: - if coupler.open or not coupler.in_service: - continue - if busbar_state_map[coupler.busbar_from_id] != busbar_state_map[coupler.busbar_to_id]: - raise ValueError( - f"Closed coupler {coupler.grid_model_id} connects out-of-service busbar with in-service busbar." - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - return self - - @model_validator(mode="after") - def check_asset_switching_table_shape(self: "Station") -> "Station": - """Check if the switching table shape matches the busbars and assets.""" - if self.asset_switching_table.shape != (len(self.busbars), len(self.assets)): - raise ValueError( - f"asset_switching_table shape {self.asset_switching_table.shape} does not match busbars " - f"{len(self.busbars)} and assets {len(self.assets)}" - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - - if self.asset_connectivity is not None: - if self.asset_connectivity.shape != (len(self.busbars), len(self.assets)): - raise ValueError( - f"asset_connectivity shape {self.asset_connectivity.shape} does not match busbars " - f"{len(self.busbars)} and assets {len(self.assets)}" - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - - return self - - @model_validator(mode="after") - def check_asset_switching_table_current_vs_physical(self: "Station") -> "Station": - """Check all current assignments are physically allowed.""" - if self.asset_connectivity is not None: - if np.logical_and(self.asset_switching_table, np.logical_not(self.asset_connectivity)).any(): - raise ValueError( - f"Not all current assignments are physically allowed Station_id: {self.grid_model_id}, Name: {self.name}" - ) - - return self - - @model_validator(mode="after") - def check_asset_bay(self: "Station") -> "Station": - """Check if the asset bay bus is in busbars. - - Returns - ------- - Station - The station itself. - """ - busbar_grid_model_id = [busbar.grid_model_id for busbar in self.busbars] - for asset in self.assets: - if asset.asset_bay is not None: - for busbar_id in asset.asset_bay.sr_switch_grid_model_id.keys(): - if busbar_id not in busbar_grid_model_id: - raise ValueError( - f"busbar_id {busbar_id} in asset {asset.grid_model_id} does not exist in busbars" - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - - return self - - @model_validator(mode="after") - def check_bus_id(self: "Station") -> "Station": - """Check if station grid_model_id is in the busbar.bus_branch_bus_id. - - Returns - ------- - Station - The station itself. - """ - busbar_grid_model_id = [busbar.bus_branch_bus_id for busbar in self.busbars if busbar.bus_branch_bus_id is not None] - if len(busbar_grid_model_id) > 0 and self.grid_model_id not in busbar_grid_model_id: - raise ValueError( - f"Station grid_model_id {self.grid_model_id} does not exist in busbars bus_branch_bus_id" - f" Station_id: {self.grid_model_id}, Name: {self.name}" - ) - - return self - - def __eq__(self, other: object) -> bool: - """Check if two stations are equal. - - Parameters - ---------- - other : object - The other station to compare to. - - Returns - ------- - bool - True if the stations are equal, False otherwise. - """ - if not isinstance(other, Station): - return False - return ( - self.grid_model_id == other.grid_model_id - and self.region == other.region - and self.busbars == other.busbars - and self.couplers == other.couplers - and self.assets == other.assets - and np.array_equal(self.asset_switching_table, other.asset_switching_table) - and ( - np.array_equal(self.asset_connectivity, other.asset_connectivity) - if (self.asset_connectivity is not None and other.asset_connectivity is not None) - else self.asset_connectivity == other.asset_connectivity - ) - ) - - -class Topology(BaseModel): - """Topology data describing a single timestep topology. - - A topology includes switchings for substations and potentially asset setpoints. - """ - - topology_id: str - """ The unique identifier of the topology. """ - - grid_model_file: Optional[str] = None - """ The grid model file that represents this timestep. Note that relevant folders might only - work on the machine they have been created, so some sort of permanent storage server should be - used to keep these files globally accessible""" - - name: Optional[str] = None - """ The name of the topology. """ - - stations: list[Station] - """ The list of stations in the topology. """ - - asset_setpoints: Optional[list[AssetSetpoint]] = None - """ The list of asset setpoints in the topology. """ - - timestamp: datetime - """ The timestamp which is represented by this topology during the original optimization. I.e. - if this timestep was the 5 o clock timestep on the day that was optimized, then this timestamp - would read 5 o clock. """ - - metrics: Optional[dict[str, float]] = None - """ The metrics of the topology. """ - - -class Strategy(BaseModel): - """Timestep data describing a collection of single timesteps, each represented by a Topology.""" - - strategy_id: str - """ The unique identifier of the strategy. """ - - timesteps: list[Topology] - """ The list of topologies, one for every timestep. """ - - name: Optional[str] = None - """ The name of the strategy. """ - - author: Optional[str] = None - """ The author of the strategy, i.e. who has created it. """ - - process_type: Optional[str] = None - """ The process type that created this topology, e.g. DC-solver, DC+-solver, Human etc. """ - - process_parameters: Optional[dict[str, Union[str, float]]] = None - """ The process parameters that were used to create this topology.""" - - date_of_creation: Optional[datetime] = None - """ The date of creation of this strategy, i.e. when the optimization ran. """ - - metadata: Optional[dict[str, Any]] = None - """ Additional metadata that might be useful for the strategy. """ - - -class RealizedStation(BaseModel): - """A realized station, including the new station and the changes made to the original station""" - - station: Station - """The realized asset station object""" - - coupler_diff: list[BusbarCoupler] - """A list of couplers that have been switched.""" - - reassignment_diff: list[tuple[int, int, bool]] - """A list of reassignments that have been made. Each tuple contains the asset index that was - affected (not the asset grid_model_id but the index into the asset_switching_table), the busbar - index (again the index into the switching table) and whether the asset was connected (True) or - disconnected (False) to that busbar.""" - - disconnection_diff: list[int] - """A list of disconnections that have been made. Each tuple contains the asset index that was - disconnected.""" - - -class RealizedTopology(BaseModel): - """A realized topology, including the new topology and the changes made to the original topology. - - This is similar to RealizedStation but holding information for all stations in the topology. - The diffs are include a station identifier that shows which station in the topology was affected by the - diff. - """ - - topology: Topology - """The realized asset topology object""" - - coupler_diff: list[tuple[str, BusbarCoupler]] - """A list of couplers that have been switched. Each tuple contains the station grid_model_id - and the coupler that was switched.""" - - reassignment_diff: list[tuple[str, int, int, bool]] - """A list of reassignments that have been made. Each tuple contains the station grid_model_id, - the asset index that was affected (not the asset grid_model_id but the index into the - asset_switching_table), the busbar index (again the index into the switching table) and whether the - asset was connected (True) or disconnected (False) to that busbar.""" - - disconnection_diff: list[tuple[str, int]] - """A list of disconnections that have been made. Each tuple contains the station grid_model_id - and the asset index that was disconnected. This can also include non-relevant stations.""" diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/__init__.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/__init__.py new file mode 100644 index 000000000..83ac23da5 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/__init__.py @@ -0,0 +1,38 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Public asset topology API. + +This package re-exports the main topology data models so downstream imports and +documentation can refer to a stable package-level namespace. +""" + +from toop_engine_interfaces.asset_topology.applied_topology import AppliedStation, RealizedTopology +from toop_engine_interfaces.asset_topology.asset_topology import CircuitGroup, Strategy, Topology +from toop_engine_interfaces.asset_topology.assets import AssetBay, AssetSetpoint, Busbar, BusbarCoupler, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import ( + MaterializedAssetConnection, + MaterializedStation, +) +from toop_engine_interfaces.asset_topology.station_models import RawStation, StationAssetConnection + +__all__ = [ + "AppliedStation", + "AssetBay", + "AssetSetpoint", + "Busbar", + "BusbarCoupler", + "CircuitGroup", + "MaterializedAssetConnection", + "MaterializedStation", + "RawStation", + "RealizedTopology", + "StationAssetConnection", + "Strategy", + "SwitchableAsset", + "Topology", +] diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/applied_topology.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/applied_topology.py new file mode 100644 index 000000000..3dc51f026 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/applied_topology.py @@ -0,0 +1,67 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Classes that represent the applied topology. + +This is the station-local view of the topology +with assets and asset bays, including the differences to the original topology. +""" + +from pydantic import BaseModel +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.assets import BusbarCoupler +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation + + +class RealizedTopology(BaseModel): + """A realized topology, including the new topology and the changes made to the original topology. + + This is similar to AppliedStation but holding information for all stations in the topology. + The diffs are include a station identifier that shows which station in the topology was affected by the + diff. + """ + + topology: Topology + """The realized asset topology object""" + + coupler_diff: list[tuple[str, BusbarCoupler]] + """A list of couplers that have been switched. Each tuple contains the station grid_model_id + and the coupler that was switched.""" + + branch_reassignment_diff: list[tuple[str, int, int, bool]] + """Branch reassignments as ``(station_id, branch_index, busbar_index, connected)`` tuples.""" + + injection_reassignment_diff: list[tuple[str, int, int, bool]] + """Injection reassignments as ``(station_id, injection_index, busbar_index, connected)`` tuples.""" + + branch_disconnection_diff: list[tuple[str, int]] + """Branch disconnections as ``(station_id, branch_index)`` tuples.""" + + injection_disconnection_diff: list[tuple[str, int]] + """Injection disconnections as ``(station_id, injection_index)`` tuples.""" + + +class AppliedStation(BaseModel): + """A realized station, including the new station and the changes made to the original station.""" + + station: MaterializedStation + """The realized asset station object""" + + coupler_diff: list[BusbarCoupler] + """A list of couplers that have been switched.""" + + branch_reassignment_diff: list[tuple[int, int, bool]] + """Branch reassignments as ``(branch_index, busbar_index, connected)`` tuples.""" + + injection_reassignment_diff: list[tuple[int, int, bool]] + """Injection reassignments as ``(injection_index, busbar_index, connected)`` tuples.""" + + branch_disconnection_diff: list[int] + """Branch indices that were disconnected.""" + + injection_disconnection_diff: list[int] + """Injection indices that were disconnected.""" diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_topology.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_topology.py new file mode 100644 index 000000000..f0cd8ca05 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_topology.py @@ -0,0 +1,356 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Contains the data models for the asset topology.""" + +from datetime import datetime + +from beartype.typing import Any, Optional, Union +from pydantic import BaseModel, Field, field_validator, model_validator +from toop_engine_interfaces.asset_topology.assets import ( + AssetBay, + AssetSetpoint, + BranchAsset, + InjectionAsset, + SwitchableAsset, +) +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import ( + RawStation, + _merged_round_trip_payload, +) + + +class CircuitGroup(BaseModel): + """A circuit group represents a set of assets that are connected to each other without power switches. + + This means in case of an outage, the fault current can flow through all assets in the same circuit group, + triggering their outage aswell. + + -> All assets in an asset group are outaged together. + + # TODO: This is currently not implemented. Use a graph search to determine these. + """ + + asset_ids: list[str] + """The grid model ids of the assets in the circuit group. + This can be used to quickly find the circuit group in case an asset with id x is outaged.""" + + asset_bay_ids: list[str] + """The asset bay ids of the asset bays in the circuit group. + These can be used to apply the outage effect on the grid by opening the switches in the asset bays.""" + + +class Topology(BaseModel): + """Topology data describing a single timestep topology. + + A topology includes lean station records in raw_stations, topology-owned canonical assets and + asset bays, and potentially asset setpoints. + Use materialize_stations() to reconstruct rich Station objects. + """ + + topology_id: str + """ The unique identifier of the topology. """ + + grid_model_file: Optional[str] = None + """ The grid model file that represents this timestep. Note that relevant folders might only + work on the machine they have been created, so some sort of permanent storage server should be + used to keep these files globally accessible""" + + name: Optional[str] = None + """ The name of the topology. """ + + raw_stations: list[RawStation] + """The topology-owned station records without embedded asset payloads. + + Each raw station represents one bus-branch bus view of a splitable station. + """ + + circuit_groups: Optional[list[CircuitGroup]] = None + """The topology-owned circuit groups. The list contains groups of assets that are connected to each + other without power switches. This means in case of an outage, the fault current can flow through + all assets in the same circuit group, triggering their outage as well. + # TODO This is currently not implemented. Use a graph search to determine these.""" + + branch_assets: list[BranchAsset] = Field(default_factory=list) + """The topology-owned canonical branch payloads.""" + + injection_assets: list[InjectionAsset] = Field(default_factory=list) + """The topology-owned canonical injection payloads. + + Station-local branch-end and asset-bay assignment data are stored on raw_stations instead of on + these canonical payloads. + """ + + asset_bays: list[AssetBay] = Field(default_factory=list) + """The topology-owned asset bay payloads.""" + + asset_setpoints: Optional[list[AssetSetpoint]] = None + """ The list of asset setpoints in the topology. """ + + timestamp: datetime + """ The timestamp which is represented by this topology during the original optimization. I.e. + if this timestep was the 5 o clock timestep on the day that was optimized, then this timestamp + would read 5 o clock. """ + + metrics: Optional[dict[str, float]] = None + """ The metrics of the topology. """ + + @field_validator("branch_assets") + @classmethod + def check_branch_asset_ids_unique(cls, v: list[BranchAsset]) -> list[BranchAsset]: + """Check if all topology branch assets have unique grid model ids.""" + asset_ids = [asset.grid_model_id for asset in v] + if len(asset_ids) != len(set(asset_ids)): + raise ValueError("grid_model_id must be unique for topology branch assets") + return v + + @field_validator("injection_assets") + @classmethod + def check_injection_asset_ids_unique(cls, v: list[InjectionAsset]) -> list[InjectionAsset]: + """Check if all topology injection assets have unique grid model ids.""" + asset_ids = [asset.grid_model_id for asset in v] + if len(asset_ids) != len(set(asset_ids)): + raise ValueError("grid_model_id must be unique for topology injection assets") + return v + + @field_validator("asset_bays") + @classmethod + def check_asset_bay_ids_unique(cls, v: list[AssetBay]) -> list[AssetBay]: + """Check if all topology asset bay ids are unique.""" + asset_bay_ids = [asset_bay.asset_bay_id for asset_bay in v] + if any(asset_bay_id is None for asset_bay_id in asset_bay_ids): + raise ValueError("All topology asset bays must define asset_bay_id") + if len(asset_bay_ids) != len(set(asset_bay_ids)): + raise ValueError("asset_bay_id must be unique for topology asset bays") + return v + + @model_validator(mode="after") + def check_station_asset_references(self: "Topology") -> "Topology": + """Check if all station asset references exist in the topology-owned collections.""" + branch_asset_ids = {asset.grid_model_id for asset in self.branch_assets} + injection_asset_ids = {asset.grid_model_id for asset in self.injection_assets} + asset_bay_ids = {asset_bay.asset_bay_id for asset_bay in self.asset_bays} + + for station in self.raw_stations: + for asset_connection in station.branch_connections: + asset_id = asset_connection.asset_id + if asset_id not in branch_asset_ids: + raise ValueError( + f"Branch asset grid_model_id {asset_id} referenced by station " + f"{station.grid_model_id} does not exist in topology assets" + ) + for asset_connection in station.injection_connections: + asset_id = asset_connection.asset_id + if asset_id not in injection_asset_ids: + raise ValueError( + f"Injection asset grid_model_id {asset_id} referenced by station " + f"{station.grid_model_id} does not exist in topology assets" + ) + for asset_connection in [*station.branch_connections, *station.injection_connections]: + asset_bay_id = asset_connection.asset_bay_id + if asset_bay_id is not None and asset_bay_id not in asset_bay_ids: + raise ValueError( + f"asset_bay_id {asset_bay_id} referenced by station " + f"{station.grid_model_id} does not exist in topology asset bays" + ) + + return self + + def materialize_stations(self) -> list[MaterializedStation]: + """Materialize station-local asset payloads from topology-owned assets and asset bays.""" + branch_asset_map = {asset.grid_model_id: asset for asset in self.branch_assets} + injection_asset_map = {asset.grid_model_id: asset for asset in self.injection_assets} + asset_bay_map = {asset_bay.asset_bay_id: asset_bay for asset_bay in self.asset_bays} + materialized_stations: list[MaterializedStation] = [] + + for station in self.raw_stations: + station_branch_assets = [ + branch_asset_map[asset_connection.asset_id].model_copy(deep=True) + for asset_connection in station.branch_connections + ] + station_injection_assets = [ + injection_asset_map[asset_connection.asset_id].model_copy(deep=True) + for asset_connection in station.injection_connections + ] + + station_branch_asset_bays = [ + asset_bay_map[asset_connection.asset_bay_id].model_copy(deep=True) + if asset_connection.asset_bay_id is not None + else None + for asset_connection in station.branch_connections + ] + station_injection_asset_bays = [ + asset_bay_map[asset_connection.asset_bay_id].model_copy(deep=True) + if asset_connection.asset_bay_id is not None + else None + for asset_connection in station.injection_connections + ] + + materialized_stations.append( + MaterializedStation( + grid_model_id=station.grid_model_id, + name=station.name, + station_type=station.station_type, + region=station.region, + voltage_level=station.voltage_level, + busbars=station.busbars, + couplers=station.couplers, + branch_connections=[ + MaterializedAssetConnection( + asset=asset, + branch_end=asset_connection.branch_end, + asset_bay=asset_bay, + ) + for asset, asset_connection, asset_bay in zip( + station_branch_assets, + station.branch_connections, + station_branch_asset_bays, + strict=True, + ) + ], + injection_connections=[ + MaterializedAssetConnection( + asset=asset, + branch_end=asset_connection.branch_end, + asset_bay=asset_bay, + ) + for asset, asset_connection, asset_bay in zip( + station_injection_assets, + station.injection_connections, + station_injection_asset_bays, + strict=True, + ) + ], + branch_switching_table=station.branch_switching_table, + injection_switching_table=station.injection_switching_table, + branch_connectivity=station.branch_connectivity, + injection_connectivity=station.injection_connectivity, + model_log=station.model_log, + ) + ) + + return materialized_stations + + def get_asset_bay_ids_for_asset(self, asset_grid_model_id: str) -> list[str]: + """Return all station-scoped asset bay ids connected to a topology asset. + + Parameters + ---------- + asset_grid_model_id : str + Grid model id of the topology-owned asset. + + Returns + ------- + list[str] + Ordered unique asset bay ids connected to the asset across all raw stations. + """ + asset_bay_ids: list[str] = [] + seen_ids: set[str] = set() + for station in self.raw_stations: + for asset_connection in [*station.branch_connections, *station.injection_connections]: + asset_id = asset_connection.asset_id + asset_bay_id = asset_connection.asset_bay_id + if asset_id != asset_grid_model_id or asset_bay_id is None or asset_bay_id in seen_ids: + continue + seen_ids.add(asset_bay_id) + asset_bay_ids.append(asset_bay_id) + return asset_bay_ids + + def get_asset_bays_for_asset(self, asset_grid_model_id: str) -> list[AssetBay]: + """Return all station-scoped asset bays connected to a topology asset. + + Parameters + ---------- + asset_grid_model_id : str + Grid model id of the topology-owned asset. + + Returns + ------- + list[AssetBay] + Ordered unique asset bay payloads connected to the asset across all raw stations. + """ + asset_bay_map = {asset_bay.asset_bay_id: asset_bay for asset_bay in self.asset_bays} + return [asset_bay_map[asset_bay_id] for asset_bay_id in self.get_asset_bay_ids_for_asset(asset_grid_model_id)] + + def model_copy(self, *, update: Optional[dict[str, Any]] = None, deep: bool = False) -> "Topology": + """Copy and revalidate the topology.""" + payload = _merged_round_trip_payload(self, update, deep=deep) + return type(self).model_validate(payload) + + +def copy_topology_with_updates( + reference_topology: Topology, + raw_stations: list[RawStation], + asset_bays: list[AssetBay], + *, + branch_assets: Optional[list[SwitchableAsset]] = None, + injection_assets: Optional[list[SwitchableAsset]] = None, +) -> Topology: + """Create a validated topology copy with updated payloads. + + Parameters + ---------- + reference_topology : Topology + Reference topology providing shared metadata. + raw_stations : list[RawStation] + Raw stations to set on the copied topology. + asset_bays : list[AssetBay] + Topology-owned asset bays to set on the copied topology. + branch_assets : Optional[list[SwitchableAsset]], optional + Topology-owned branch assets to set on the copied topology. + injection_assets : Optional[list[SwitchableAsset]], optional + Topology-owned injection assets to set on the copied topology. + + Returns + ------- + Topology + Validated topology copy with updated topology-owned payloads. + """ + resolved_branch_assets = branch_assets if branch_assets is not None else reference_topology.branch_assets + resolved_injection_assets = injection_assets if injection_assets is not None else reference_topology.injection_assets + + return Topology( + topology_id=reference_topology.topology_id, + grid_model_file=reference_topology.grid_model_file, + name=reference_topology.name, + raw_stations=raw_stations, + branch_assets=resolved_branch_assets, + injection_assets=resolved_injection_assets, + asset_bays=asset_bays, + asset_setpoints=reference_topology.asset_setpoints, + timestamp=reference_topology.timestamp, + metrics=reference_topology.metrics, + ) + + +class Strategy(BaseModel): + """Timestep data describing a collection of single timesteps, each represented by a Topology.""" + + strategy_id: str + """ The unique identifier of the strategy. """ + + timesteps: list[Topology] + """ The list of topologies, one for every timestep. """ + + name: Optional[str] = None + """ The name of the strategy. """ + + author: Optional[str] = None + """ The author of the strategy, i.e. who has created it. """ + + process_type: Optional[str] = None + """ The process type that created this topology, e.g. DC-solver, DC+-solver, Human etc. """ + + process_parameters: Optional[dict[str, Union[str, float]]] = None + """ The process parameters that were used to create this topology.""" + + date_of_creation: Optional[datetime] = None + """ The date of creation of this strategy, i.e. when the optimization ran. """ + + metadata: Optional[dict[str, Any]] = None + """ Additional metadata that might be useful for the strategy. """ diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_topology_helpers.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_topology_helpers.py new file mode 100644 index 000000000..fde7d7e82 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_topology_helpers.py @@ -0,0 +1,1935 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Common helper functions for asset topology manipulation.""" + +import itertools +from numbers import Integral +from pathlib import Path + +import networkx as nx +import numpy as np +from beartype.typing import Literal, Optional, Union +from fsspec import AbstractFileSystem +from fsspec.implementations.local import LocalFileSystem +from toop_engine_interfaces.asset_topology.applied_topology import AppliedStation, RealizedTopology +from toop_engine_interfaces.asset_topology.asset_topology import ( + Topology, + copy_topology_with_updates, +) +from toop_engine_interfaces.asset_topology.assets import AssetBay, Busbar, BusbarCoupler, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.filesystem_helper import load_pydantic_model_fs + + +def electrical_components(station: MaterializedStation, min_num_assets: int = 1) -> list[list[int]]: + """Compute the electrical components of a station. + + A set of busbars is considered a separate electrical component if it is not connected through a + closed coupler to other busbars and there are at least two assets connected to the component. + + Parameters + ---------- + station : MaterializedStation + Station to analyze. + min_num_assets : int, optional + Minimum number of connected assets required for a component to be kept. + + Returns + ------- + list[list[int]] + Busbar index groups, where each inner list references positions in + `station.busbars`. + """ + n_connections_per_bus = station.branch_switching_table.sum(axis=1) + station.injection_switching_table.sum(axis=1) + + int_id_mapper = {busbar.int_id: i for i, busbar in enumerate(station.busbars)} + + graph = nx.Graph() + graph.add_nodes_from( + [(busbar.int_id, {"degree": degree}) for busbar, degree in zip(station.busbars, n_connections_per_bus, strict=True)] + ) + graph.add_edges_from( + [(coupler.busbar_from_id, coupler.busbar_to_id) for coupler in station.couplers if not coupler.open] + ) + + components = nx.connected_components(graph) + # Filter out busbars with no assets connected to them + components = [ + list(component) + for component in components + if sum(graph.nodes[busbar]["degree"] for busbar in component) >= min_num_assets + ] + # Map int ids + components = [[int_id_mapper[busbar] for busbar in component] for component in components] + + return components + + +def number_of_splits(station: MaterializedStation) -> int: + """Compute the number of electrical components in a station. + + A set of busbars is considered a separate electrical component if it is not connected through a + closed coupler to other busbars and there are at least two assets connected to the component. + + Parameters + ---------- + station : MaterializedStation + Station to analyze. + + Returns + ------- + int + The number of electrical components in the station. + """ + station = filter_out_of_service(station) + + components = electrical_components(station, min_num_assets=2) + return len(components) + + +def remove_busbar(station: MaterializedStation, grid_model_id: str) -> MaterializedStation: + """Remove a busbar with a specific grid_model_id from the station. + + This will + - remove the busbar from the list of busbars + - remove all couplers that are connected to the busbar at either end + - remove all asset bay entries that are connected to the busbar + - remove the line from the asset switching table + - remove the line from the asset connectivity table + + Parameters + ---------- + station : MaterializedStation + Station to modify. + grid_model_id : str + Grid model identifier of the busbar to remove. + + Returns + ------- + MaterializedStation + Copy of `station` with the busbar removed. + """ + # Store the index and int_id of the dropped busbar + index = [b.grid_model_id for b in station.busbars].index(grid_model_id) + int_id = station.busbars[index].int_id + + busbars = [b for b in station.busbars if b.grid_model_id != grid_model_id] + couplers = [c for c in station.couplers if int_id not in (c.busbar_from_id, c.busbar_to_id)] + branch_switching_table = np.delete(station.branch_switching_table, index, axis=0) + injection_switching_table = np.delete(station.injection_switching_table, index, axis=0) + branch_connectivity = ( + np.delete(station.branch_connectivity, index, axis=0) if station.branch_connectivity is not None else None + ) + injection_connectivity = ( + np.delete(station.injection_connectivity, index, axis=0) if station.injection_connectivity is not None else None + ) + + def filter_sr_keys(asset_bay: Optional[AssetBay]) -> Optional[AssetBay]: + """Drop SR switch references to the removed busbar. + + Parameters + ---------- + asset_bay : Optional[AssetBay] + Asset bay whose SR switch references should be filtered. + + Returns + ------- + Optional[AssetBay] + Updated asset bay without references to the removed busbar. + """ + if asset_bay is None: + return None + return asset_bay.model_copy( + update={ + "sr_switch_grid_model_id": { + busbar_id: foreign_id + for busbar_id, foreign_id in asset_bay.sr_switch_grid_model_id.items() + if busbar_id != grid_model_id + } + } + ) + + branch_connections = [ + asset_connection.model_copy(update={"asset_bay": filter_sr_keys(asset_connection.asset_bay)}) + for asset_connection in station.branch_connections + ] + injection_connections = [ + asset_connection.model_copy(update={"asset_bay": filter_sr_keys(asset_connection.asset_bay)}) + for asset_connection in station.injection_connections + ] + + # Create a new station object with the modified busbars, couplers, and asset switching table + new_station = station.model_copy( + update={ + "busbars": busbars, + "couplers": couplers, + "branch_connections": branch_connections, + "injection_connections": injection_connections, + "branch_switching_table": branch_switching_table, + "injection_switching_table": injection_switching_table, + "branch_connectivity": branch_connectivity, + "injection_connectivity": injection_connectivity, + } + ) + return new_station + + +def filter_out_of_service_assets(station: MaterializedStation) -> MaterializedStation: + """Filter out-of-service assets from the station. + + Parameters + ---------- + station : MaterializedStation + Station to filter. + + Returns + ------- + MaterializedStation + Copy of `station` without out-of-service asset connections. + """ + if all(asset_connection.asset.in_service for asset_connection in station.branch_connections) and all( + asset_connection.asset.in_service for asset_connection in station.injection_connections + ): + return station + + branch_mask = [asset_connection.asset.in_service for asset_connection in station.branch_connections] + injection_mask = [asset_connection.asset.in_service for asset_connection in station.injection_connections] + branch_connections = [ + asset_connection + for asset_connection, in_service in zip(station.branch_connections, branch_mask, strict=True) + if in_service + ] + injection_connections = [ + asset_connection + for asset_connection, in_service in zip(station.injection_connections, injection_mask, strict=True) + if in_service + ] + + return station.model_copy( + update={ + "branch_connections": branch_connections, + "injection_connections": injection_connections, + "branch_switching_table": station.branch_switching_table[:, branch_mask], + "injection_switching_table": station.injection_switching_table[:, injection_mask], + "branch_connectivity": ( + station.branch_connectivity[:, branch_mask] if station.branch_connectivity is not None else None + ), + "injection_connectivity": ( + station.injection_connectivity[:, injection_mask] if station.injection_connectivity is not None else None + ), + } + ) + + +def filter_out_of_service_busbars(station: MaterializedStation) -> MaterializedStation: + """Filter out-of-service busbars from the station. + + Parameters + ---------- + station : MaterializedStation + Station to filter. + + Returns + ------- + MaterializedStation + Copy of `station` without out-of-service busbars. + """ + deleted_busbar_ids = [busbar.grid_model_id for busbar in station.busbars if not busbar.in_service] + + for busbar in deleted_busbar_ids: + station = remove_busbar(station, busbar) + + return station + + +def filter_out_of_service_couplers(station: MaterializedStation) -> MaterializedStation: + """Filter out-of-service couplers from the station. + + Parameters + ---------- + station : MaterializedStation + Station to filter. + + Returns + ------- + MaterializedStation + Copy of `station` without out-of-service couplers. + """ + if all(coupler.in_service for coupler in station.couplers): + return station + + return station.model_copy( + update={ + "couplers": [coupler for coupler in station.couplers if coupler.in_service], + } + ) + + +def filter_out_of_service(station: MaterializedStation) -> MaterializedStation: + """Filter out-of-service assets, busbars and couplers from the station. + + The return value will be a new station object with all out-of-service elements removed. + Note that the busbars are not reindexed, so the busbar ids will be the same as in the original + station with missing elements. If you expect a continuous range of busbar ids, you + should call reindex_busbars after this function. + + Parameters + ---------- + station : MaterializedStation + Station to filter. + + Returns + ------- + MaterializedStation + Copy of `station` without out-of-service assets, busbars, or couplers. + """ + station = filter_out_of_service_couplers(station) + station = filter_out_of_service_assets(station) + station = filter_out_of_service_busbars(station) + + # Validate the new station object + MaterializedStation.model_validate(station) + + return station + + +def filter_duplicate_couplers( + station: MaterializedStation, retain_type_hierarchy: Optional[list[str]] = None +) -> tuple[MaterializedStation, list[BusbarCoupler]]: + """Filter out duplicate couplers. + + Two couplers are duplicates if they connect the same busbar pair, regardless of order. + + Parameters + ---------- + station : MaterializedStation + Station to filter. + retain_type_hierarchy : Optional[list[str]], optional + Optional priority order for retained coupler types. Earlier entries have + higher priority. + + Returns + ------- + MaterializedStation + Station with duplicate couplers removed. + list[BusbarCoupler] + Removed duplicate couplers. + """ + # A dict with the coupler representation as key and a list of indices of the couplers + coupler_dict: dict[tuple[int, int], list[int]] = {} + for index, coupler in enumerate(station.couplers): + coupler_repr = ( + min(coupler.busbar_from_id, coupler.busbar_to_id), + max(coupler.busbar_from_id, coupler.busbar_to_id), + ) + coupler_dict[coupler_repr] = [*coupler_dict.get(coupler_repr, []), index] + + kept_couplers = [] + removed_couplers = [] + for _coupler_repr, index in coupler_dict.items(): + # If there is only one or we don't have to sort, take the first and remove all the others + if len(index) == 1 or retain_type_hierarchy is None: + sorted_couplers = [station.couplers[i] for i in index] + # We have to sort by type hierarchy + else: + # Sort the couplers by their type in the hierarchy, if the type is not in the hierarchy, it will be at the end + sorted_couplers = sorted( + (station.couplers[i] for i in index), + key=lambda c: ( + retain_type_hierarchy.index(c.coupler_type) + if c.coupler_type in retain_type_hierarchy + else len(retain_type_hierarchy) + ), + ) + # Keep the first coupler and remove the others + kept_couplers.append(sorted_couplers[0]) + removed_couplers.extend(sorted_couplers[1:]) + + if len(removed_couplers) == 0: + return station, removed_couplers + + return ( + station.model_copy(update={"couplers": kept_couplers}), + removed_couplers, + ) + + +def filter_disconnected_busbars( + station: MaterializedStation, respect_coupler_open: bool = False +) -> tuple[MaterializedStation, list[Busbar]]: + """Remove busbars that can not get connected by any coupler. + + This creates a graph of the busbars and couplers and returns only the largest connected component. The size + of a component is determined by the number of assets connected to it. + Open and closed couplers are treated the same if respect_coupler_open is False, i.e. a busbar connected + by only open couplers is considered connected. + Busbars connected by out-of-service couplers are always considered disconnected. + + This also means that elements which are only connected to the disconnected busbars are + effectively disconnected, and it looks like they are subject to transmission line switching. + + Note that this function does not reindex the busbars, so the busbar ids will be the same as in + the original station with missing elements. If you expect a continuous range of busbar ids, you + should call reindex_busbars after this function. + + Parameters + ---------- + station : MaterializedStation + Station that may contain disconnected busbars. + respect_coupler_open : bool, optional + If `True`, only closed couplers contribute to connectivity. + + Returns + ------- + MaterializedStation + Station with disconnected busbars removed. + list[Busbar] + Removed busbars. + """ + couplers = [ + coupler for coupler in station.couplers if (not respect_coupler_open or not coupler.open) and coupler.in_service + ] + graph = nx.Graph() + num_assets_per_busbar = station.branch_switching_table.sum(axis=1) + station.injection_switching_table.sum(axis=1) + graph.add_nodes_from( + [ + (busbar.int_id, {"num_assets": num_assets}) + for (busbar, num_assets) in zip(station.busbars, num_assets_per_busbar, strict=True) + ] + ) + graph.add_edges_from([(coupler.busbar_from_id, coupler.busbar_to_id) for coupler in couplers]) + + components = list(nx.connected_components(graph)) + if len(components) == 1: + return station, [] + + # Order components by the number of assets connected to them + components.sort(key=lambda x: sum(graph.nodes[busbar]["num_assets"] for busbar in x), reverse=True) + + removed_busbars = [busbar for busbar in station.busbars if busbar.int_id not in components[0]] + + for busbar in removed_busbars: + station = remove_busbar(station, busbar.grid_model_id) + + return station, removed_busbars + + +def reindex_busbars(station: MaterializedStation) -> MaterializedStation: + """Reindex the int-ids of the busbars in the station. + + This might be necessary after filder_disconnected_busbars or filter_out_of_service have been called. + + Parameters + ---------- + station : MaterializedStation + Station with potentially non-contiguous busbar ids. + + Returns + ------- + MaterializedStation + Copy of `station` with contiguous busbar `int_id` values starting at 0. + """ + busbar_mapping = {busbar.int_id: i for i, busbar in enumerate(station.busbars)} + new_busbars = [busbar.model_copy(update={"int_id": i}) for i, busbar in enumerate(station.busbars)] + new_couplers = [ + coupler.model_copy( + update={ + "busbar_from_id": busbar_mapping[coupler.busbar_from_id], + "busbar_to_id": busbar_mapping[coupler.busbar_to_id], + } + ) + for coupler in station.couplers + ] + + station = station.model_copy(update={"busbars": new_busbars, "couplers": new_couplers}) + MaterializedStation.model_validate(station) + return station + + +def filter_assets_by_type( + station: MaterializedStation, assets_allowed: set[str], allow_none_type: bool = False +) -> tuple[MaterializedStation, list[SwitchableAsset]]: + """Filter assets by type. + + Removes all assets that have a type which is not in the set of allowed types. + + Parameters + ---------- + station : MaterializedStation + Station to filter. + assets_allowed : set[str] + Allowed asset types. + allow_none_type : bool, optional + If `True`, assets without an `asset_type` are retained. + + Returns + ------- + MaterializedStation + Station with disallowed assets removed. + list[SwitchableAsset] + Removed assets. + """ + branch_mask = [ + (asset_connection.asset.asset_type in assets_allowed) + or (allow_none_type and asset_connection.asset.asset_type is None) + for asset_connection in station.branch_connections + ] + injection_mask = [ + (asset_connection.asset.asset_type in assets_allowed) + or (allow_none_type and asset_connection.asset.asset_type is None) + for asset_connection in station.injection_connections + ] + if all(branch_mask) and all(injection_mask): + return station, [] + + removed_assets = [ + asset_connection.asset + for asset_connection, mask in zip(station.branch_connections, branch_mask, strict=True) + if not mask + ] + [ + asset_connection.asset + for asset_connection, mask in zip(station.injection_connections, injection_mask, strict=True) + if not mask + ] + branch_connections = [ + asset_connection for asset_connection, mask in zip(station.branch_connections, branch_mask, strict=True) if mask + ] + injection_connections = [ + asset_connection + for asset_connection, mask in zip(station.injection_connections, injection_mask, strict=True) + if mask + ] + + new_station = station.model_copy( + update={ + "branch_connections": branch_connections, + "injection_connections": injection_connections, + "branch_switching_table": station.branch_switching_table[:, branch_mask], + "injection_switching_table": station.injection_switching_table[:, injection_mask], + "branch_connectivity": ( + station.branch_connectivity[:, branch_mask] if station.branch_connectivity is not None else None + ), + "injection_connectivity": ( + station.injection_connectivity[:, injection_mask] if station.injection_connectivity is not None else None + ), + } + ) + return new_station, removed_assets + + +def find_multi_connected_without_coupler( + station: MaterializedStation, +) -> list[tuple[Integral, Integral, Integral]]: + """Find assets that bridge multiple busbars without an intervening coupler. + + These cases can cause problems in downstream processing. + + Parameters + ---------- + station : MaterializedStation + Station to inspect. + + Returns + ------- + list[tuple[Integral, Integral, Integral]] + Tuples of `(asset_index, lower_busbar_index, upper_busbar_index)` for + multi-connected assets that bridge busbars without a coupler. + """ + multi_connected_without_coupler = [] + + def _append_multi_connected(asset_table: np.ndarray, asset_index_offset: int) -> None: + """Collect multi-busbar asset bridges that have no coupler between them. + + Parameters + ---------- + asset_table : np.ndarray + Switching table to inspect. + asset_index_offset : int + Offset used to map local asset indices to station-wide asset indices. + """ + for local_asset_idx in np.flatnonzero(asset_table.sum(axis=0) > 1): + busbars_bridged = np.flatnonzero(asset_table[:, local_asset_idx]).tolist() + for bus1_idx, bus2_idx in itertools.combinations(busbars_bridged, 2): + smaller_bus_idx = min(bus1_idx, bus2_idx) + larger_bus_idx = max(bus1_idx, bus2_idx) + + if not any( + ( + coupler.busbar_from_id == station.busbars[smaller_bus_idx].int_id + and coupler.busbar_to_id == station.busbars[larger_bus_idx].int_id + ) + or ( + coupler.busbar_from_id == station.busbars[larger_bus_idx].int_id + and coupler.busbar_to_id == station.busbars[smaller_bus_idx].int_id + ) + for coupler in station.couplers + ): + multi_connected_without_coupler.append( + ( + int(local_asset_idx + asset_index_offset), + smaller_bus_idx, + larger_bus_idx, + ) + ) + + _append_multi_connected(station.branch_switching_table, 0) + _append_multi_connected(station.injection_switching_table, len(station.branch_connections)) + + return multi_connected_without_coupler + + +def fix_multi_connected_without_coupler( + station: MaterializedStation, +) -> tuple[MaterializedStation, list[tuple[SwitchableAsset, Busbar, Busbar]]]: + """Remove one connection for unsupported multi-connected assets. + + Will always remove the connection to the busbar with the lower index. + + Parameters + ---------- + station : MaterializedStation + Station to fix. + + Returns + ------- + MaterializedStation + Station with unsupported multi-connected assets reduced to one busbar. + list[tuple[SwitchableAsset, Busbar, Busbar]] + Tuples of the affected asset and the two busbars it previously bridged. + """ + multi_connected_without_coupler = find_multi_connected_without_coupler(station) + if not multi_connected_without_coupler: + return station, [] + + branch_switching_table = np.copy(station.branch_switching_table) + injection_switching_table = np.copy(station.injection_switching_table) + diff = [] + n_branch = len(station.branch_connections) + for asset_idx, bus1_idx, bus2_idx in multi_connected_without_coupler: + if asset_idx < n_branch: + branch_switching_table[bus1_idx, asset_idx] = 0 + asset = station.branch_connections[asset_idx].asset + else: + injection_idx = asset_idx - n_branch + injection_switching_table[bus1_idx, injection_idx] = 0 + asset = station.injection_connections[injection_idx].asset + diff.append( + ( + asset, + station.busbars[bus1_idx], + station.busbars[bus2_idx], + ) + ) + + return station.model_copy( + update={ + "branch_connections": station.branch_connections, + "injection_connections": station.injection_connections, + "branch_switching_table": branch_switching_table, + "injection_switching_table": injection_switching_table, + "branch_connectivity": station.branch_connectivity, + "injection_connectivity": station.injection_connectivity, + } + ), diff + + +def has_transmission_line_switching(station: MaterializedStation) -> bool: + """Check if the switching table contains transmission line switching. + + Transmission line switching is defined as disconnecting an asset from all busbars on purpose as + a remedial action. Out-of-service assets are not considered irrespective of the switching table. + + Parameters + ---------- + station : MaterializedStation + Station to inspect. + + Returns + ------- + bool + True if the switching table contains transmission line switching, False otherwise. + """ + branch_in_service = np.array( + [asset_connection.asset.in_service for asset_connection in station.branch_connections], dtype=bool + ) + injection_in_service = np.array( + [asset_connection.asset.in_service for asset_connection in station.injection_connections], dtype=bool + ) + return bool( + np.any((station.branch_switching_table.sum(axis=0) == 0) & branch_in_service) + or np.any((station.injection_switching_table.sum(axis=0) == 0) & injection_in_service) + ) + + +def find_busbars_for_coupler(busbars: list[Busbar], coupler: BusbarCoupler) -> tuple[Busbar, Busbar]: + """Find the from-side and to-side busbars for a coupler. + + Matching is based on busbar `int_id` values. + + Parameters + ---------- + busbars : list[Busbar] + Busbars to search. + coupler : BusbarCoupler + Coupler whose endpoints should be resolved. + + Returns + ------- + Busbar + From-side busbar. + Busbar + To-side busbar. + + Raises + ------ + ValueError + If any of the busbars for the coupler are not found. This should never happen as the station validator should + capture such a scenario. + """ + try: + busbar_from = next(busbar for busbar in busbars if busbar.int_id == coupler.busbar_from_id) + busbar_to = next(busbar for busbar in busbars if busbar.int_id == coupler.busbar_to_id) + return busbar_from, busbar_to + except StopIteration as e: + raise ValueError(f"Busbars for coupler {coupler.grid_model_id} not found") from e + + +def merge_couplers( + original: list[BusbarCoupler], + new: list[BusbarCoupler], + busbar_mapping: dict[int, int], +) -> tuple[list[BusbarCoupler], list[BusbarCoupler]]: + """Merge an updated list of couplers into the original list. + + The processed list may be a subset of the original list because preprocessing can remove + duplicate couplers. If the processed list still contains duplicates, the merged state is open + only if all duplicates are open. + + Parameters + ---------- + original : list[BusbarCoupler] + Original couplers from the unprocessed station. + new : list[BusbarCoupler] + Couplers from the processed station. + busbar_mapping : dict[int, int] + Mapping from original busbar ids to the processed station busbar ids. + + Returns + ------- + list[BusbarCoupler] + Updated couplers. + list[BusbarCoupler] + Couplers whose open state changed. + """ + # Store the couplers in a dict for easier access + target_state = {} + for coupler in new: + key = (coupler.busbar_from_id, coupler.busbar_to_id) + # There can be a case with multiple couplers + # In that case, the coupler is open if all are open + # If no other coupler is present, the coupler state is just copied + target_state[key] = coupler.open and target_state.get(key, True) + + new_couplers = [] + diff = [] + for coupler in original: + key = ( + busbar_mapping[coupler.busbar_from_id], + busbar_mapping[coupler.busbar_to_id], + ) + if key in target_state and target_state[key] != coupler.open: + new_coupler = coupler.model_copy(update={"open": target_state[key]}) + new_couplers.append(new_coupler) + diff.append(new_coupler) + else: + new_couplers.append(coupler) + + return new_couplers, diff + + +def merge_stations( + original: list[MaterializedStation], + new: list[MaterializedStation], + missing_station_behavior: Literal["append", "raise"] = "append", +) -> tuple[list[MaterializedStation], list[tuple[str, BusbarCoupler]], list[tuple[str, int, int, bool]]]: + """Merge a list of changed stations into a list of original stations. + + Stations with matching `grid_model_id` values are merged with `merge_station`. + Coupler and reassignment diffs are concatenated across all stations. + + Parameters + ---------- + original : list[MaterializedStation] + Original stations. + new : list[MaterializedStation] + Updated stations to merge into `original`. + missing_station_behavior : Literal["append", "raise"], optional + Behavior when a station exists only in `new`. + + Returns + ------- + list[MaterializedStation] + Merged station list. + list[tuple[str, BusbarCoupler]] + Coupler diffs as `(station_id, coupler)` tuples. + list[tuple[str, int, int, bool]] + Asset reassignment diffs as `(station_id, asset_index, busbar_index, connected)` tuples. + """ + new_stations_found = [] + updated_station_list = [] + coupler_diff = [] + reassignment_diff = [] + for station in original: + found = False + for new_station in new: + if station.grid_model_id == new_station.grid_model_id: + updated_station, coupler_diff_local, reassignment_diff_local = merge_station(station, new_station) + updated_station_list.append(updated_station) + new_stations_found.append(new_station.grid_model_id) + coupler_diff.extend([(station.grid_model_id, coupler) for coupler in coupler_diff_local]) + reassignment_diff.extend( + [ + (station.grid_model_id, asset_idx, busbar_idx, bool(connected)) + for asset_idx, busbar_idx, connected in reassignment_diff_local + ] + ) + found = True + break + if not found: + updated_station_list.append(station) + + # Check if there are new stations that were not found in the original list + for new_station in new: + if new_station.grid_model_id not in new_stations_found: + if missing_station_behavior == "append": + updated_station_list.append(new_station) + else: + raise ValueError(f"Station {new_station.grid_model_id} was not found in the original list") + + return updated_station_list, coupler_diff, reassignment_diff + + +# TODO: refactor due to C901 +def merge_station( + original: MaterializedStation, new: MaterializedStation +) -> tuple[MaterializedStation, list[BusbarCoupler], list[tuple[int, int, bool]]]: + """Merge all the changes from the new station into the original station. + + Matching assets, couplers, and busbars are updated from `new` when they can be mapped back + onto `original`. Elements that cannot be matched are left unchanged. + + If all in-service elements of original are also in new, then the returned substation will be + electrically equivalent to the new substation. If this is not the case, the returned substation + has all possible changes applied, but there are cases in which this is not electrically + equivalent to the new substation. + + Use `compare_stations` to inspect structural differences between both stations. + + Parameters + ---------- + original : MaterializedStation + Original station to update. + new : MaterializedStation + Station that provides the target changes. + + Returns + ------- + MaterializedStation + Copy of `original` with all mergeable changes from `new` applied. + list[BusbarCoupler] + Couplers whose open state changed. + list[tuple[int, int, bool]] + Asset switching diff as `(asset_index, busbar_index, connected)` tuples. + """ + if original == new: + return original, [], [] + + # Find a mapping from old busbars to new busbars. We just need an index mapping for copying the + # switching table. Also store the missed busbars. + busbar_mapping = {} + busbar_int_id_mapping = {} + max_busbar_id = max(busbar.int_id for busbar in new.busbars) + branch_asset_mapping, injection_asset_mapping, new_couplers, coupler_diff = map_busbars_and_assets( + original, new, busbar_mapping, busbar_int_id_mapping, max_busbar_id + ) + + # Loop through the switching table and copy the values over for which there is a mapping + new_branch_switching_table = original.branch_switching_table.copy() + new_injection_switching_table = original.injection_switching_table.copy() + station, asset_diff = update_asset_switching_table( + original, + new, + busbar_mapping, + branch_asset_mapping, + injection_asset_mapping, + new_couplers, + new_branch_switching_table, + new_injection_switching_table, + ) + return station, coupler_diff, asset_diff + + +def update_asset_switching_table( + original_station: MaterializedStation, + new_station: MaterializedStation, + busbar_mapping: dict[int, int], + branch_asset_mapping: dict[int, int], + injection_asset_mapping: dict[int, int], + new_couplers: list[BusbarCoupler], + new_branch_switching_table: np.ndarray, + new_injection_switching_table: np.ndarray, +) -> tuple[MaterializedStation, list[tuple[int, int, bool]]]: + """Update switching tables and couplers using a mapped target station. + + This helper copies branch and injection switching states from `new_station` into + `original_station` for all mapped busbars and assets. It also replaces the + coupler list with `new_couplers` and records the resulting switching diff. + + Parameters + ---------- + original_station : MaterializedStation + Original station that receives the mapped switching updates. + new_station : MaterializedStation + Station that provides the target switching states. + busbar_mapping : dict[int, int] + Mapping from original busbar indices to busbar indices in `new_station`. + branch_asset_mapping : dict[int, int] + Mapping from original branch connection indices to branch indices in `new_station`. + injection_asset_mapping : dict[int, int] + Mapping from original injection connection indices to injection indices in `new_station`. + new_couplers : list[BusbarCoupler] + Couplers to store on the returned station. + new_branch_switching_table : np.ndarray + Branch switching table buffer for the returned station. Modified in place. + new_injection_switching_table : np.ndarray + Injection switching table buffer for the returned station. Modified in place. + + Returns + ------- + MaterializedStation + Copy of `original_station` with updated switching tables and couplers. + list[tuple[int, int, bool]] + Switching diff as `(asset_index, busbar_index, connected)` tuples. + """ + asset_diff = [] + for busbar_idx, mapped_busbar_idx in busbar_mapping.items(): + for branch_idx, mapped_branch_idx in branch_asset_mapping.items(): + if ( + new_branch_switching_table[busbar_idx, branch_idx] + != new_station.branch_switching_table[mapped_busbar_idx, mapped_branch_idx] + ): + asset_diff.append( + ( + branch_idx, + busbar_idx, + bool(new_station.branch_switching_table[mapped_busbar_idx, mapped_branch_idx]), + ) + ) + new_branch_switching_table[busbar_idx, branch_idx] = new_station.branch_switching_table[ + mapped_busbar_idx, mapped_branch_idx + ] + + for injection_idx, mapped_injection_idx in injection_asset_mapping.items(): + if ( + new_injection_switching_table[busbar_idx, injection_idx] + != new_station.injection_switching_table[mapped_busbar_idx, mapped_injection_idx] + ): + asset_diff.append( + ( + injection_idx + len(original_station.branch_connections), + busbar_idx, + bool(new_station.injection_switching_table[mapped_busbar_idx, mapped_injection_idx]), + ) + ) + new_injection_switching_table[busbar_idx, injection_idx] = new_station.injection_switching_table[ + mapped_busbar_idx, mapped_injection_idx + ] + + original_station = original_station.model_copy( + update={ + "couplers": new_couplers, + "branch_connections": original_station.branch_connections, + "injection_connections": original_station.injection_connections, + "branch_switching_table": new_branch_switching_table, + "injection_switching_table": new_injection_switching_table, + "branch_connectivity": original_station.branch_connectivity, + "injection_connectivity": original_station.injection_connectivity, + } + ) + return original_station, asset_diff + + +def map_busbars_and_assets( + original_station: MaterializedStation, + new_station: MaterializedStation, + busbar_mapping: dict[int, int], + busbar_int_id_mapping: dict[int, int], + max_busbar_id: int, +) -> tuple[dict[int, int], dict[int, int], list[BusbarCoupler], list[BusbarCoupler]]: + """Build busbar and asset mappings between two materialized stations. + + This helper matches busbars and asset connections by `grid_model_id`, updates the + provided busbar mapping dictionaries in place, and prepares merged coupler state + for `merge_station`. + + Parameters + ---------- + original_station : MaterializedStation + Original station whose busbars and asset connections should be matched. + new_station : MaterializedStation + Target station that provides the reference busbars, assets, and couplers. + busbar_mapping : dict[int, int] + Mapping from original busbar indices to busbar indices in `new_station`. + Modified in place. + busbar_int_id_mapping : dict[int, int] + Mapping from original busbar `int_id` values to corresponding `int_id` values + in `new_station`. Missing busbars are assigned synthetic ids beyond + `max_busbar_id`. Modified in place. + max_busbar_id : int + Maximum busbar `int_id` present in `new_station`. + + Returns + ------- + dict[int, int] + Mapping from original branch connection indices to branch indices in `new_station`. + dict[int, int] + Mapping from original injection connection indices to injection indices in `new_station`. + list[BusbarCoupler] + Coupler list to apply to the original station. + list[BusbarCoupler] + Couplers whose open state changes during the merge. + """ + + def _build_index_by_grid_model_id(elements: list[Union[Busbar, SwitchableAsset]]) -> dict[str, int]: + """Map each element grid model id to its position in the input list. + + Parameters + ---------- + elements : list[Union[Busbar, SwitchableAsset]] + Elements that expose a `grid_model_id` attribute. + + Returns + ------- + dict[str, int] + Mapping from `grid_model_id` to element position. + """ + return {element.grid_model_id: index for index, element in enumerate(elements)} + + def _map_asset_connections( + original_connections: list[MaterializedAssetConnection], + new_connections: list[MaterializedAssetConnection], + ) -> dict[int, int]: + """Map original asset connection indices to matching target connection indices. + + Parameters + ---------- + original_connections : list[MaterializedAssetConnection] + Original asset connections to map. + new_connections : list[MaterializedAssetConnection] + Target asset connections that define the mapped indices. + + Returns + ------- + dict[int, int] + Mapping from original connection index to target connection index. + """ + new_asset_indices = _build_index_by_grid_model_id([asset_connection.asset for asset_connection in new_connections]) + return { + index: new_asset_indices[asset_connection.asset.grid_model_id] + for index, asset_connection in enumerate(original_connections) + if asset_connection.asset.grid_model_id in new_asset_indices + } + + new_busbar_indices = _build_index_by_grid_model_id(new_station.busbars) + new_busbar_int_ids = {busbar.grid_model_id: busbar.int_id for busbar in new_station.busbars} + for index, busbar in enumerate(original_station.busbars): + mapped_busbar_index = new_busbar_indices.get(busbar.grid_model_id) + if mapped_busbar_index is None: + # Make sure to not accidentally map to an existing busbar + busbar_int_id_mapping[busbar.int_id] = busbar.int_id + max_busbar_id + 1 + continue + + busbar_mapping[index] = mapped_busbar_index + busbar_int_id_mapping[busbar.int_id] = new_busbar_int_ids[busbar.grid_model_id] + + branch_asset_mapping = _map_asset_connections(original_station.branch_connections, new_station.branch_connections) + injection_asset_mapping = _map_asset_connections( + original_station.injection_connections, + new_station.injection_connections, + ) + + # Merge couplers + new_couplers, coupler_diff = merge_couplers( + original_station.couplers, new_station.couplers, busbar_mapping=busbar_int_id_mapping + ) + return branch_asset_mapping, injection_asset_mapping, new_couplers, coupler_diff + + +def compare_stations( + a: MaterializedStation, b: MaterializedStation +) -> tuple[ + list[BusbarCoupler], + list[BusbarCoupler], + list[Busbar], + list[Busbar], + list[SwitchableAsset], + list[SwitchableAsset], +]: + """Compare two stations and return the missing elements. + + It uses grid_model_ids to compare the assets, busbars and couplers. It does not consider + different switching states or coupler states, but just checks if all the elements are also in + the other station. + + Parameters + ---------- + a : MaterializedStation + First station to compare. + b : MaterializedStation + Second station to compare. + + Returns + ------- + list[BusbarCoupler] + Couplers present in `a` but not in `b`. + list[BusbarCoupler] + Couplers present in `b` but not in `a`. + list[Busbar] + Busbars present in `a` but not in `b`. + list[Busbar] + Busbars present in `b` but not in `a`. + list[SwitchableAsset] + Assets present in `a` but not in `b`. + list[SwitchableAsset] + Assets present in `b` but not in `a`. + """ + a_busbars = set(busbar.grid_model_id for busbar in a.busbars) + b_busbars = set(busbar.grid_model_id for busbar in b.busbars) + + a_couplers = set(coupler.grid_model_id for coupler in a.couplers) + b_couplers = set(coupler.grid_model_id for coupler in b.couplers) + + a_assets = { + *(asset_connection.asset.grid_model_id for asset_connection in a.branch_connections), + *(asset_connection.asset.grid_model_id for asset_connection in a.injection_connections), + } + b_assets = { + *(asset_connection.asset.grid_model_id for asset_connection in b.branch_connections), + *(asset_connection.asset.grid_model_id for asset_connection in b.injection_connections), + } + + return ( + [coupler for coupler in a.couplers if coupler.grid_model_id not in b_couplers], + [coupler for coupler in b.couplers if coupler.grid_model_id not in a_couplers], + [busbar for busbar in a.busbars if busbar.grid_model_id not in b_busbars], + [busbar for busbar in b.busbars if busbar.grid_model_id not in a_busbars], + [ + *( + asset_connection.asset + for asset_connection in a.branch_connections + if asset_connection.asset.grid_model_id not in b_assets + ), + *( + asset_connection.asset + for asset_connection in a.injection_connections + if asset_connection.asset.grid_model_id not in b_assets + ), + ], + [ + *( + asset_connection.asset + for asset_connection in b.branch_connections + if asset_connection.asset.grid_model_id not in a_assets + ), + *( + asset_connection.asset + for asset_connection in b.injection_connections + if asset_connection.asset.grid_model_id not in a_assets + ), + ], + ) + + +def load_asset_topology_fs( + filesystem: AbstractFileSystem, + file_path: Union[str, Path], +) -> Topology: + """Load an asset topology from a file system. + + Parameters + ---------- + filesystem : AbstractFileSystem + File system to load from. + file_path : Union[str, Path] + Path to the JSON file containing the topology. + + Returns + ------- + Topology + The loaded asset topology. + """ + return load_pydantic_model_fs( + filesystem=filesystem, + file_path=file_path, + model_class=Topology, + ) + + +def load_asset_topology(filename: Union[str, Path]) -> Topology: + """Load an asset topology from a file. + + Parameters + ---------- + filename : Union[str, Path] + File name to load the topology from. + + Returns + ------- + Topology + Loaded topology. + """ + return load_asset_topology_fs( + filesystem=LocalFileSystem(), + file_path=filename, + ) + + +def save_asset_topology_fs(filesystem: AbstractFileSystem, filename: Union[str, Path], asset_topology: Topology) -> None: + """Save an asset topology to a file system. + + Parameters + ---------- + filesystem : AbstractFileSystem + File system to save to. + filename : Union[str, Path] + File name to save the topology to. + asset_topology : Topology + Topology to save. + """ + with filesystem.open(str(filename), "w", encoding="utf-8") as file: + file.write(asset_topology.model_dump_json(indent=2)) + + +def save_asset_topology(filename: Union[str, Path], asset_topology: Topology) -> None: + """Save an asset topology to a file. + + Parameters + ---------- + filename : Union[str, Path] + File name to save the topology to. + asset_topology : Topology + Topology to save. + """ + save_asset_topology_fs(LocalFileSystem(), filename, asset_topology) + + +def accumulate_diffs( + realized_stations: list[AppliedStation], +) -> tuple[ + list[tuple[str, BusbarCoupler]], + list[tuple[str, int, int, bool]], + list[tuple[str, int, int, bool]], + list[tuple[str, int]], + list[tuple[str, int]], +]: + """Accumulate the diffs of the realized stations into the format of realized topology. + + Parameters + ---------- + realized_stations : list[AppliedStation] + Realized stations to accumulate. + + Returns + ------- + list[tuple[str, BusbarCoupler]] + Accumulated coupler diff. + list[tuple[str, int, int, bool]] + Accumulated branch reassignment diff. + list[tuple[str, int, int, bool]] + Accumulated injection reassignment diff. + list[tuple[str, int]] + Accumulated branch disconnection diff. + list[tuple[str, int]] + Accumulated injection disconnection diff. + """ + coupler_diff = [] + branch_reassignment_diff = [] + injection_reassignment_diff = [] + branch_disconnection_diff = [] + injection_disconnection_diff = [] + for station in realized_stations: + s_id = station.station.grid_model_id + coupler_diff.extend([(s_id, coupler) for coupler in station.coupler_diff]) + branch_reassignment_diff.extend( + [(s_id, asset_idx, bus_idx, connected) for (asset_idx, bus_idx, connected) in station.branch_reassignment_diff] + ) + injection_reassignment_diff.extend( + [ + (s_id, asset_idx, bus_idx, connected) + for (asset_idx, bus_idx, connected) in station.injection_reassignment_diff + ] + ) + branch_disconnection_diff.extend([(s_id, asset_idx) for asset_idx in station.branch_disconnection_diff]) + injection_disconnection_diff.extend([(s_id, asset_idx) for asset_idx in station.injection_disconnection_diff]) + + return ( + coupler_diff, + branch_reassignment_diff, + injection_reassignment_diff, + branch_disconnection_diff, + injection_disconnection_diff, + ) + + +def station_diff( + start_station: MaterializedStation, + target_station: MaterializedStation, +) -> AppliedStation: + """Compute the diff between two stations. + + The same station must be described by both inputs, i.e. the assets, busbars and couplers (except for their open state) + must be the same. + + Parameters + ---------- + start_station : MaterializedStation + Starting station. + target_station : MaterializedStation + Target station. + + Returns + ------- + AppliedStation + Realized station containing the target station and all derived diffs. + """ + assert [s.asset.grid_model_id for s in start_station.branch_connections] == [ + s.asset.grid_model_id for s in target_station.branch_connections + ], "Branch assets do not match" + assert [s.asset.grid_model_id for s in start_station.injection_connections] == [ + s.asset.grid_model_id for s in target_station.injection_connections + ], "Injection assets do not match" + assert [b.grid_model_id for b in start_station.busbars] == [b.grid_model_id for b in target_station.busbars], ( + "Busbars do not match" + ) + assert [b.grid_model_id for b in start_station.couplers] == [b.grid_model_id for b in target_station.couplers], ( + "Couplers do not match" + ) + + def _table_diff( + start_table: np.ndarray, + target_table: np.ndarray, + asset_kind: str, + ) -> tuple[list[tuple[int, int, bool]], list[int]]: + """Compute reassignment and disconnection diffs for one asset table. + + Parameters + ---------- + start_table : np.ndarray + Initial switching table. + target_table : np.ndarray + Target switching table. + asset_kind : str + Asset kind used in error messages. + + Returns + ------- + list[tuple[int, int, bool]] + Reassignment diff as `(asset_index, busbar_index, connected)` tuples. + list[int] + Asset indices that become disconnected. + """ + reassignment_diff_local: list[tuple[int, int, bool]] = [] + disconnection_diff_local: list[int] = [] + for asset_index in range(start_table.shape[1]): + target_disconnected = ~np.any(target_table[:, asset_index]) + start_disconnected = ~np.any(start_table[:, asset_index]) + + if start_disconnected and not target_disconnected: + raise NotImplementedError( + "Reconnections are not supported yet, there is no diff for that" + + f" ({asset_kind} {asset_index} in station {start_station.grid_model_id})" + ) + + if target_disconnected and not start_disconnected: + disconnection_diff_local.append(int(asset_index)) + + if target_disconnected: + continue + + xor_diff = np.logical_xor(start_table[:, asset_index], target_table[:, asset_index]) + for busbar_index in np.flatnonzero(xor_diff): + reassignment_diff_local.append( + (int(asset_index), int(busbar_index), bool(target_table[busbar_index, asset_index])) + ) + return reassignment_diff_local, disconnection_diff_local + + branch_reassignment_diff, branch_disconnection_diff = _table_diff( + start_station.branch_switching_table, + target_station.branch_switching_table, + "branch", + ) + injection_reassignment_diff, injection_disconnection_diff = _table_diff( + start_station.injection_switching_table, + target_station.injection_switching_table, + "injection", + ) + + coupler_diff = [] + for start_coupler, target_coupler in zip(start_station.couplers, target_station.couplers, strict=True): + if start_coupler.open != target_coupler.open: + coupler_diff.append(target_coupler) + + return AppliedStation( + station=target_station, + coupler_diff=coupler_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, + ) + + +def topology_diff( + start_topo: Topology, + target_topo: Topology, +) -> RealizedTopology: + """Compute the difference between two topologies. + + Parameters + ---------- + start_topo : Topology + Starting topology. + target_topo : Topology + Target topology. + + Returns + ------- + RealizedTopology + Realized topology containing the target topology and all diffs from the start topology. + """ + realized_stations = [ + station_diff(start_station, target_station) + for (start_station, target_station) in zip( + start_topo.materialize_stations(), target_topo.materialize_stations(), strict=True + ) + ] + ( + coupler_diff, + branch_reassignment_diff, + injection_reassignment_diff, + branch_disconnection_diff, + injection_disconnection_diff, + ) = accumulate_diffs(realized_stations) + return RealizedTopology( + topology=target_topo, + coupler_diff=coupler_diff, + branch_reassignment_diff=branch_reassignment_diff, + injection_reassignment_diff=injection_reassignment_diff, + branch_disconnection_diff=branch_disconnection_diff, + injection_disconnection_diff=injection_disconnection_diff, + ) + + +def order_station_assets( + station: MaterializedStation, asset_ids: list[str] +) -> tuple[MaterializedStation, list[str], list[str]]: + """Order station assets according to a list of asset ids. + + Asset ids not present in the station are reported in `not_found`. + Assets omitted from `asset_ids` are dropped and reported in `ignored`. + + Parameters + ---------- + station : MaterializedStation + Station to reorder. + asset_ids : list[str] + Asset ids in the desired order. + + Returns + ------- + MaterializedStation + Station with reordered assets. + list[str] + Asset ids that were not found in the station. + list[str] + Asset ids ignored because they were not present in `asset_ids`. + """ + branch_lookup = { + asset_connection.asset.grid_model_id: (index, asset_connection) + for index, asset_connection in enumerate(station.branch_connections) + } + injection_lookup = { + asset_connection.asset.grid_model_id: (index, asset_connection) + for index, asset_connection in enumerate(station.injection_connections) + } + new_branch_connections = [] + new_injection_connections = [] + not_found = [] + branch_positions = [] + injection_positions = [] + for asset_id in asset_ids: + if asset_id in branch_lookup: + pos, asset_connection = branch_lookup[asset_id] + new_branch_connections.append(asset_connection) + branch_positions.append(pos) + elif asset_id in injection_lookup: + pos, asset_connection = injection_lookup[asset_id] + new_injection_connections.append(asset_connection) + injection_positions.append(pos) + else: + not_found.append(asset_id) + + ignored = [ + asset_connection.asset.grid_model_id + for index, asset_connection in enumerate(station.branch_connections) + if index not in branch_positions + ] + [ + asset_connection.asset.grid_model_id + for index, asset_connection in enumerate(station.injection_connections) + if index not in injection_positions + ] + + station = station.model_copy( + update={ + "branch_connections": new_branch_connections, + "injection_connections": new_injection_connections, + "branch_switching_table": station.branch_switching_table[:, branch_positions], + "injection_switching_table": station.injection_switching_table[:, injection_positions], + "branch_connectivity": ( + station.branch_connectivity[:, branch_positions] if station.branch_connectivity is not None else None + ), + "injection_connectivity": ( + station.injection_connectivity[:, injection_positions] + if station.injection_connectivity is not None + else None + ), + } + ) + MaterializedStation.model_validate(station) + return station, not_found, ignored + + +def order_topology(topology: Topology, station_ids: list[str]) -> tuple[Topology, list[str]]: + """Order topology stations according to a list of ids. + + Station ids not present in the topology are reported in `not_found`. + Stations omitted from `station_ids` are dropped. + + Parameters + ---------- + topology : Topology + Topology to reorder. + station_ids : list[str] + Station ids in the desired order. + + Returns + ------- + Topology + Topology with reordered stations. + list[str] + Station ids that were not found in the topology. + """ + new_stations = [] + not_found = [] + for relevant_node in station_ids: + found = False + for station in topology.raw_stations: + if station.grid_model_id == relevant_node: + new_stations.append(station) + found = True + break + if not found: + not_found.append(relevant_node) + + topology = copy_topology_with_updates( + topology, + new_stations, + topology.asset_bays, + branch_assets=topology.branch_assets, + injection_assets=topology.injection_assets, + ) + return topology, not_found + + +def _coupler_connects_same_busbars(coupler: BusbarCoupler, other_coupler: BusbarCoupler) -> bool: + """Return whether two couplers connect the same busbar pair. + + Parameters + ---------- + coupler : BusbarCoupler + Reference coupler. + other_coupler : BusbarCoupler + Coupler to compare against `coupler`. + + Returns + ------- + bool + `True` if both couplers connect the same busbar pair. + """ + return ( + other_coupler.busbar_from_id == coupler.busbar_from_id and other_coupler.busbar_to_id == coupler.busbar_to_id + ) or (other_coupler.busbar_to_id == coupler.busbar_from_id and other_coupler.busbar_from_id == coupler.busbar_to_id) + + +def _validate_coupler_can_be_fused(station: MaterializedStation, coupler: BusbarCoupler) -> None: + """Raise if the coupler has a parallel coupler on the same busbar pair. + + Parameters + ---------- + station : MaterializedStation + Station containing the coupler. + coupler : BusbarCoupler + Coupler that should be fused. + + Raises + ------ + ValueError + If more than one coupler connects the same busbar pair. + """ + parallel_couplers = [ + other_coupler for other_coupler in station.couplers if _coupler_connects_same_busbars(coupler, other_coupler) + ] + if len(parallel_couplers) > 1: + raise ValueError( + f"Coupler {coupler.grid_model_id} has parallel couplers in station {station.grid_model_id}, " + "cannot fuse parallel couplers with the same busbars" + ) + + +def _resolve_fused_busbars( + station: MaterializedStation, + coupler: BusbarCoupler, + copy_info_from: bool, +) -> tuple[int, int, int, int, Busbar, Busbar]: + """Resolve busbar indices and busbar objects used during coupler fusion. + + Parameters + ---------- + station : MaterializedStation + Station containing the coupler. + coupler : BusbarCoupler + Coupler whose adjacent busbars should be resolved. + copy_info_from : bool + Whether metadata should be kept from the from-side busbar. + + Returns + ------- + int + From-side busbar index. + int + To-side busbar index. + int + Kept busbar index. + int + Removed busbar index. + Busbar + Kept busbar. + Busbar + Removed busbar. + """ + busbar_from, busbar_to = find_busbars_for_coupler(station.busbars, coupler) + busbar_index_by_int_id = {busbar.int_id: index for index, busbar in enumerate(station.busbars)} + busbar_from_index = busbar_index_by_int_id[busbar_from.int_id] + busbar_to_index = busbar_index_by_int_id[busbar_to.int_id] + keep_busbar_index = busbar_from_index if copy_info_from else busbar_to_index + remove_busbar_index = busbar_to_index if copy_info_from else busbar_from_index + keep_busbar = station.busbars[keep_busbar_index] + remove_busbar = station.busbars[remove_busbar_index] + return busbar_from_index, busbar_to_index, keep_busbar_index, remove_busbar_index, keep_busbar, remove_busbar + + +def _merge_and_delete_fused_busbar_row( + table: Optional[np.ndarray], + busbar_from_index: int, + busbar_to_index: int, + keep_busbar_index: int, + remove_busbar_index: int, +) -> Optional[np.ndarray]: + """Merge fused busbar rows and delete the removed busbar row. + + Parameters + ---------- + table : Optional[np.ndarray] + Switching or connectivity table to update. + busbar_from_index : int + From-side busbar index. + busbar_to_index : int + To-side busbar index. + keep_busbar_index : int + Busbar index that should remain in the table. + remove_busbar_index : int + Busbar index that should be removed from the table. + + Returns + ------- + Optional[np.ndarray] + Updated table, or `None` if the input table is `None`. + """ + if table is None: + return None + merged_table = np.copy(table) + merged_table[keep_busbar_index] = merged_table[busbar_from_index] | merged_table[busbar_to_index] + return np.delete(merged_table, remove_busbar_index, axis=0) + + +def _replace_sr_keys_for_fused_busbar( + asset_bay: Optional[AssetBay], + keep_busbar: Busbar, + remove_busbar: Busbar, +) -> Optional[AssetBay]: + """Rewrite SR switch busbar keys from the removed busbar to the kept one. + + Parameters + ---------- + asset_bay : Optional[AssetBay] + Asset bay whose SR switch mapping should be updated. + keep_busbar : Busbar + Busbar that remains after fusion. + remove_busbar : Busbar + Busbar that is removed during fusion. + + Returns + ------- + Optional[AssetBay] + Updated asset bay, or `None` if no asset bay is present. + """ + if asset_bay is None: + return None + + new_sr_switch_grid_model_id = {} + for key, foreign_id in asset_bay.sr_switch_grid_model_id.items(): + if key == remove_busbar.grid_model_id and keep_busbar.grid_model_id in asset_bay.sr_switch_grid_model_id: + continue + mapped_key = keep_busbar.grid_model_id if key == remove_busbar.grid_model_id else key + new_sr_switch_grid_model_id[mapped_key] = foreign_id + + return asset_bay.model_copy(update={"sr_switch_grid_model_id": new_sr_switch_grid_model_id}) + + +def _update_asset_bays_for_fused_busbar( + asset_connections: list[MaterializedAssetConnection], + keep_busbar: Busbar, + remove_busbar: Busbar, +) -> list[MaterializedAssetConnection]: + """Apply the fused-busbar SR key rewrite to each asset connection. + + Parameters + ---------- + asset_connections : list[MaterializedAssetConnection] + Asset connections to update. + keep_busbar : Busbar + Busbar that remains after fusion. + remove_busbar : Busbar + Busbar that is removed during fusion. + + Returns + ------- + list[MaterializedAssetConnection] + Updated asset connections. + """ + return [ + asset_connection.model_copy( + update={"asset_bay": _replace_sr_keys_for_fused_busbar(asset_connection.asset_bay, keep_busbar, remove_busbar)} + ) + for asset_connection in asset_connections + ] + + +def _replace_coupler_busbar_id_for_fusion( + coupler: BusbarCoupler, + keep_busbar: Busbar, + remove_busbar: Busbar, +) -> BusbarCoupler: + """Rewrite coupler endpoints that still reference the removed busbar. + + Parameters + ---------- + coupler : BusbarCoupler + Coupler to update. + keep_busbar : Busbar + Busbar that remains after fusion. + remove_busbar : Busbar + Busbar that is removed during fusion. + + Returns + ------- + BusbarCoupler + Updated coupler with rewritten endpoints when needed. + """ + if coupler.busbar_from_id == remove_busbar.int_id: + return coupler.model_copy(update={"busbar_from_id": keep_busbar.int_id}) + if coupler.busbar_to_id == remove_busbar.int_id: + return coupler.model_copy(update={"busbar_to_id": keep_busbar.int_id}) + return coupler + + +def fuse_coupler( + station: MaterializedStation, + coupler_grid_model_id: str, + copy_info_from: bool = True, +) -> MaterializedStation: + """Fuse a coupler by merging its adjacent busbars. + + Parameters + ---------- + station : MaterializedStation + Station containing the coupler to fuse. The coupler open state is ignored. + coupler_grid_model_id : str + Grid model identifier of the coupler to fuse. + copy_info_from : bool, optional + If `True`, keep metadata from the busbar on the coupler's from-side. + If `False`, keep metadata from the to-side busbar. + + Returns + ------- + MaterializedStation + Station with the coupler removed and both busbars merged. + + Raises + ------ + ValueError + If the coupler is missing or if parallel couplers connect the same busbar pair. + """ + coupler = next((c for c in station.couplers if c.grid_model_id == coupler_grid_model_id), None) + if coupler is None: + raise ValueError(f"Coupler {coupler_grid_model_id} not found in station {station.grid_model_id}") + + _validate_coupler_can_be_fused(station, coupler) + busbar_from_index, busbar_to_index, keep_busbar_index, remove_busbar_index, keep_busbar, remove_busbar = ( + _resolve_fused_busbars(station, coupler, copy_info_from) + ) + + new_branch_switching_table = _merge_and_delete_fused_busbar_row( + station.branch_switching_table, + busbar_from_index, + busbar_to_index, + keep_busbar_index, + remove_busbar_index, + ) + new_injection_switching_table = _merge_and_delete_fused_busbar_row( + station.injection_switching_table, + busbar_from_index, + busbar_to_index, + keep_busbar_index, + remove_busbar_index, + ) + new_branch_connectivity = _merge_and_delete_fused_busbar_row( + station.branch_connectivity, + busbar_from_index, + busbar_to_index, + keep_busbar_index, + remove_busbar_index, + ) + new_injection_connectivity = _merge_and_delete_fused_busbar_row( + station.injection_connectivity, + busbar_from_index, + busbar_to_index, + keep_busbar_index, + remove_busbar_index, + ) + + new_busbars = [busbar for index, busbar in enumerate(station.busbars) if index != remove_busbar_index] + new_couplers = [ + _replace_coupler_busbar_id_for_fusion(other_coupler, keep_busbar, remove_busbar) + for other_coupler in station.couplers + if other_coupler.grid_model_id != coupler_grid_model_id + ] + new_branch_connections = _update_asset_bays_for_fused_busbar(station.branch_connections, keep_busbar, remove_busbar) + new_injection_connections = _update_asset_bays_for_fused_busbar( + station.injection_connections, + keep_busbar, + remove_busbar, + ) + + station = station.model_copy( + update={ + "busbars": new_busbars, + "branch_connections": new_branch_connections, + "injection_connections": new_injection_connections, + "branch_switching_table": new_branch_switching_table, + "injection_switching_table": new_injection_switching_table, + "branch_connectivity": new_branch_connectivity, + "injection_connectivity": new_injection_connectivity, + "couplers": new_couplers, + } + ) + MaterializedStation.model_validate(station) + return station + + +def fuse_all_couplers_with_type( + station: MaterializedStation, + coupler_type: str, + copy_info_from: bool = True, +) -> tuple[MaterializedStation, list[BusbarCoupler]]: + """Fuse all couplers of a given type in a station. + + Duplicate couplers are filtered after each fusion step so chained merges do not + leave residual parallel couplers between already fused busbars. + + Parameters + ---------- + station : MaterializedStation + Station containing the couplers to fuse. + coupler_type : str + Coupler type to match against `coupler.coupler_type`. + copy_info_from : bool, optional + If `True`, keep metadata from the from-side busbar for each fusion. + If `False`, keep metadata from the to-side busbar. + + Returns + ------- + MaterializedStation + Station after all matching couplers have been fused. + list[BusbarCoupler] + Couplers removed either by fusion or duplicate filtering. + """ + fused_couplers = [] + while True: + coupler = next( + (c for c in station.couplers if (c.coupler_type is not None and c.coupler_type == coupler_type)), None + ) + if coupler is None: + break + + station = fuse_coupler(station, coupler.grid_model_id, copy_info_from=copy_info_from) + fused_couplers.append(coupler) + + # We want to retain the coupler type that we filter for and remove all other couplers - this way we can + # make sure that all parallel couplers are removed. Otherwise it would depend on the order of the couplers + # whether there would be a residual coupler left between the potentially fused busbars. + station, removed = filter_duplicate_couplers(station, retain_type_hierarchy=[coupler_type]) + fused_couplers.extend(removed) + + return station, fused_couplers + + +def find_station_by_id(stations: list[MaterializedStation], station_id: str) -> MaterializedStation: + """Find a station by its grid_model_id in a list of stations. + + Parameters + ---------- + stations : list[MaterializedStation] + Stations to search. + station_id : str + Grid model identifier of the station to find. + + Returns + ------- + MaterializedStation + Matching station. + + Raises + ------ + ValueError + If no station with the requested identifier is present. + """ + for station in stations: + if station.grid_model_id == station_id: + return station + raise ValueError(f"Station {station_id} not found in the list") diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_types.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_types.py new file mode 100644 index 000000000..82dfc323b --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/asset_types.py @@ -0,0 +1,53 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Shared type aliases for asset topology models.""" + +from beartype.typing import Literal, TypeAlias + +BranchEnd: TypeAlias = Literal["from", "to", "hv", "mv", "lv"] +AssetBranchTypePandapower: TypeAlias = Literal[ + "line", + "trafo", + "trafo3w_lv", + "trafo3w_mv", + "trafo3w_hv", + "impedance", + "xward", +] +AssetBranchTypePowsybl: TypeAlias = Literal[ + "LINE", + "TWO_WINDINGS_TRANSFORMER", + "TIE_LINE", +] +AssetBranchType: TypeAlias = Literal[AssetBranchTypePandapower, AssetBranchTypePowsybl] + +AssetInjectionTypePandapower: TypeAlias = Literal[ + "ext_grid", + "gen", + "load", + "shunt", + "sgen", + "ward", + "ward_load", + "ward_shunt", + "xward_load", + "xward_shunt", + "dcline_from", + "dcline_to", +] +AssetInjectionTypePowsybl: TypeAlias = Literal[ + "LOAD", + "GENERATOR", + "BOUNDARY_LINE", + "HVDC_CONVERTER_STATION", + "STATIC_VAR_COMPENSATOR", + "SHUNT_COMPENSATOR", + "BATTERY", +] +AssetInjectionType: TypeAlias = Literal[AssetInjectionTypePandapower, AssetInjectionTypePowsybl] +AssetType: TypeAlias = Literal[AssetBranchType, AssetInjectionType] diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/assets.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/assets.py new file mode 100644 index 000000000..6a560fdf6 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/assets.py @@ -0,0 +1,281 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Classes that represent Assets in the grid""" + +from enum import Enum + +from beartype.typing import Any, Optional, get_args +from pydantic import BaseModel, field_validator +from toop_engine_interfaces.asset_topology.asset_types import AssetBranchType, AssetInjectionType, AssetType + + +class Busbar(BaseModel): + """Busbar data describing a single busbar a station.""" + + grid_model_id: str + """ The unique identifier of the busbar. + Corresponds to the busbar's id in the grid model.""" + + busbar_type: Optional[str] = None + """ The type of the busbar, might be useful for finding the busbar later on """ + + name: Optional[str] = None + """ The name of the busbar, might be useful for finding the busbar later on """ + + int_id: int + """ Is used to reference busbars in the couplers. Needs to be unique per station""" + + in_service: bool = True + """ Whether the busbar is in service. If False, it will be ignored in the switching table""" + + bus_branch_bus_id: Optional[str] = None + """ The bus_branch_bus_id refers to the bus-branch model bus id. + There might be a difference between the busbar grid_model_id (a physical busbar) + and the bus_branch_bus_id from the bus-branch model. + Use this bus_branch_bus_id to store the bus-branch model bus id. + Note: the Station grid_model_id also a bus-branch bus_branch_bus_id. This id is the most splitable bus_branch_bus_id. + Other bus_branch_bus_ids are part of the physical station, but are separated by a coupler or branch.""" + + +class AssetBay(BaseModel): + """Saves the physical connection from the asset to the substation busbars - a bay (Schaltfeld). + + A line usually has three switches, before it is connected to the busbar. + Two disconnector switches and one circuit breaker switch. + A transformer usually has two switches, before it is connected to the busbar. + One disconnector switch and one circuit breaker switch. + + type: n - node + type: b - busbar (Sammelschiene) + type: CB - DV Circuit Breaker / Power Switch (Leistungsschalter) + type: DS - Disconnector Switch (Trennschalter) + + ------------------ busbar 1 - type: b + | + / type: DS - SR Switch busbar 1 -> used for reassigning the asset to another busbar + | + ------|----------- busbar 2 - type: b + | | + / | type: DS - SR Switch busbar 2 -> used for reassigning the asset to another busbar + | | + --------- bus_3 - type: n - busbar section bus + | + / type: CB - DV Circuit Breaker / Power Switch -> used for disconnecting the asset from the busbar + | + --------- bus_2 - type: n - circuit breaker bus + | + / type: DS - SL Switch (optional) -> not used by the asset + | + --------- bus_1 - type: n - asset bus + ^ + | Line/Transformer + + + """ + + asset_bay_id: str + """Topology-scoped identifier for the asset bay.""" + + sl_switch_grid_model_id: Optional[str] = None + """ The id of the switch, which connects the asset to the circuit breaker node. + This switch is a disconnector switch. Do not use for anything, leave state as found. + Default should be closed.""" + + dv_switch_grid_model_id: str + """ This switch is a circuit breaker / power switch. + Use for disconnecting / reconnecting the asset from the busbar. """ + + sr_switch_grid_model_id: dict[str, str] + """ The ids of the switches, which assign the asset to the busbars. + key: busbar_grid_model_id e.g. 4%%bus + value: sr_switch_grid_model_id + This switch is a disconnector switch. Use for reassigning the asset to another busbar. + Only one switch should be closed at a time. + """ + + @field_validator("sr_switch_grid_model_id") + @classmethod + def check_is_empty(cls, v: dict[str, str]) -> dict[str, str]: + """Check if the dict is empty. + + Parameters + ---------- + v : dict[str, str] + The dictionary of sr_switch_grid_model_id to check. + + Returns + ------- + dict[str, str] + The dictionary itself. + + Raises + ------ + ValueError + If the dictionary is empty. + """ + if len(v) == 0: + raise ValueError("sr_switch_grid_model_id must not be empty") + return v + + +class BusbarCoupler(BaseModel): + """Coupler data describing a single coupler at a station. + + This references only busbar couplers, i.e. couplers connecting two busbars. + Switches connecting assets to a busbar are represented in the asset_switching_table in the station model. + + Note: A busbar couple is a physical connection between two busbars, this can be also a + cross coupler. To further specify the connection of an asset to a busbar, the asset connection + """ + + grid_model_id: str + """ The unique identifier of the coupler. + Corresponds to the coupler's id in the grid model.""" + + coupler_type: Optional[str] = None + """ The type of the coupler, might be useful for finding the coupler later on """ + + name: Optional[str] = None + """ The name of the coupler, might be useful for finding the coupler later on """ + + # TODO: this does not work for a coupler with multiple busbars on one side + busbar_from_id: int + """ Is used to determine where the coupler is connected to the busbars on the "from" side. + Refers to the int_id of the busbar""" + + # TODO: this does not work for a coupler with multiple busbars on one side + busbar_to_id: int + """ Is used to determine where the coupler is connected to the busbars on the "to" side. + Refers to the int_id of the busbar""" + + open: bool + """ The status of the coupler. True if the coupler is open, False if the coupler is closed. + TODO: Switch to using the connectivity table instead of this field. + """ + + in_service: bool = True + """ Whether the coupler is in-service. Out-of-service couplers are assumed to be always open""" + + asset_bay: Optional[AssetBay] = None + """ The asset bay (Schaltfeld) of the coupler. + Note: A coupler can have multiple from and to busbars. + The asset bay sr_switch_grid_model_id is used save the selector switches of the coupler. + Note: A coupler has never a sl_switch_grid_model_id, the dv_switch_grid_model_id should + the same as the name of the coupler. + + """ + + +class SwitchableAsset(BaseModel): + """Asset data describing a single asset at a station. + + An asset can be for instance a transformer, line, generator, load, shunt. + Note: An asset can be connected to multiple busbars through the switching grid, however if + this happens a closed coupler between these busbars is assumed. If such couplers are not present, + they will be created. + Note: An asset that is out-of-service can be represented, but its switching entries will be + ignored. + """ + + grid_model_id: str + """ The unique identifier of the asset. + Corresponds to the asset's id in the grid model.""" + + asset_type: Optional[AssetType] = None + """ The type of the asset. These refer loosely to the types in the pandapower/powsybl grid + models. If set, this can be used to disambiguate branches from injections """ + + name: Optional[str] = None + """ The name of the asset, might be useful for finding the asset later on """ + + in_service: bool = True + """ If the element is in service. False means the switching entry for this element will be + ignored. This shall not be used for elements intentionally disconnected, instead set all zeros + in the switching table.""" + + +class BranchAsset(SwitchableAsset): + """Switchable asset representing a branch-type element.""" + + asset_type: Optional[AssetBranchType] = None + + +class InjectionAsset(SwitchableAsset): + """Switchable asset representing an injection-type element.""" + + asset_type: Optional[AssetInjectionType] = None + + +def normalize_switchable_asset_payload(asset: dict[str, Any]) -> SwitchableAsset: + """Normalize an asset payload to the matching branch or injection subclass when possible.""" + if isinstance(asset, (BranchAsset, InjectionAsset)): + return asset + + asset_data = asset.model_dump() if isinstance(asset, SwitchableAsset) else dict(asset) + if "asset_type" not in asset_data and "type" in asset_data: + asset_data["asset_type"] = asset_data.pop("type") + asset_type = asset_data.get("asset_type") + if asset_type in get_args(AssetBranchType): + return BranchAsset(**asset_data) + if asset_type in get_args(AssetInjectionType): + return InjectionAsset(**asset_data) + return SwitchableAsset(**asset_data) + + +class AssetSetpoint(BaseModel): + """Asset data describing a single asset with a setpoint. + + This could for example be a PST or HVDC setpoint. + Note: The same asset can both be switchable and have a setpoint. In this case, the asset will + be represented twice. + """ + + grid_model_id: str + """ The unique identifier of the asset. + Corresponds to the asset's id in the grid model.""" + + asset_type: Optional[str] = None + """ The type of the asset, might be useful for finding the asset later on """ + + name: Optional[str] = None + """ The name of the asset, might be useful for finding the asset later on """ + + setpoint: float + """ The setpoint of the asset. """ + + +def build_asset_bay_id(station_grid_model_id: str, asset_grid_model_id: str, occurrence_index: int = 0) -> str: + """Create a deterministic station-scoped asset bay identifier. + + Parameters + ---------- + station_grid_model_id : str + Station identifier owning the asset bay. + asset_grid_model_id : str + Asset identifier for which the bay id is created. + occurrence_index : int, default=0 + Zero-based occurrence index for repeated asset ids within one station. + + Returns + ------- + str + Deterministic asset bay identifier scoped to the station. + """ + base_asset_bay_id = f"{station_grid_model_id}::{asset_grid_model_id}::bay" + if occurrence_index == 0: + return base_asset_bay_id + return f"{base_asset_bay_id}::{occurrence_index}" + + +class PowsyblSwitchValues(Enum): + """Enum for the switch values in the Powsybl model.""" + + OPEN = True + """ The switch is open, i.e. not connected.""" + CLOSED = False + """ The switch is closed, i.e. connected.""" diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/materialized_topology.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/materialized_topology.py new file mode 100644 index 000000000..4cf038d0a --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/materialized_topology.py @@ -0,0 +1,186 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Classes that represent the materialized topology. + +This is the station-local view of the topology populated with all necessary info +on assets and asset bays. +""" + +import numpy as np +from beartype.typing import Any, Literal, Optional +from pydantic import BaseModel, Field, model_validator +from toop_engine_interfaces.asset_topology.asset_types import BranchEnd +from toop_engine_interfaces.asset_topology.assets import AssetBay, SwitchableAsset +from toop_engine_interfaces.asset_topology.station_models import ( + _merged_round_trip_payload, + _StationStructure, + _validate_station_physical_assignments, + _validate_station_switching_tables, +) + + +class MaterializedAssetConnection(BaseModel): + """Station-local association between a switching-table column and a materialized asset payload.""" + + asset: SwitchableAsset + """Station-local asset payload aligned with one switching-table column.""" + + branch_end: Optional[BranchEnd] = None + """Optional branch-end metadata for this station-local asset occurrence.""" + + asset_bay: Optional[AssetBay] = None + """Optional station-local asset bay payload for this station-local asset occurrence.""" + + def get_sr_switch(self) -> Optional[dict[str, str]]: + """Return the sr_switch_grid_model_id dict from the asset bay if it exists.""" + if self.asset_bay is not None: + return self.asset_bay.sr_switch_grid_model_id + return None + + +class MaterializedStation(_StationStructure): + """Station data describing a single materialized station. + + The station identity refers to a single bus-branch model bus_id that represents one splitable + station view. + A physical substation or voltage level may contain multiple bus-branch model bus_ids. + The station assets are aligned with the switching tables and describe the assets visible in that + station view; they are not intended to define a topology-owned canonical asset list. + """ + + branch_connections: list[MaterializedAssetConnection] = Field(default_factory=list) + """Station-local branch payloads aligned with ``branch_switching_table``.""" + + injection_connections: list[MaterializedAssetConnection] = Field(default_factory=list) + """Station-local injection payloads aligned with ``injection_switching_table``.""" + + @model_validator(mode="after") + def check_asset_shapes(self: "MaterializedStation") -> "MaterializedStation": + """Check if switching-table-aligned station-local assets match the matrix shapes.""" + _validate_station_switching_tables( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + busbar_count=len(self.busbars), + asset_count=len(self.branch_connections), + asset_switching_table=self.branch_switching_table, + asset_connectivity=self.branch_connectivity, + asset_kind="branch", + ) + _validate_station_physical_assignments( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + asset_switching_table=self.branch_switching_table, + asset_connectivity=self.branch_connectivity, + asset_kind="branch", + ) + _validate_station_switching_tables( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + busbar_count=len(self.busbars), + asset_count=len(self.injection_connections), + asset_switching_table=self.injection_switching_table, + asset_connectivity=self.injection_connectivity, + asset_kind="injection", + ) + _validate_station_physical_assignments( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + asset_switching_table=self.injection_switching_table, + asset_connectivity=self.injection_connectivity, + asset_kind="injection", + ) + return self + + def __eq__(self, other: object) -> bool: + """Check if two stations are equal. + + Parameters + ---------- + other : object + The other station to compare to. + + Returns + ------- + bool + True if the stations are equal, False otherwise. + """ + if not isinstance(other, MaterializedStation): + return False + return ( + self.grid_model_id == other.grid_model_id + and self.region == other.region + and self.busbars == other.busbars + and self.couplers == other.couplers + and self.branch_connections == other.branch_connections + and self.injection_connections == other.injection_connections + and np.array_equal(self.branch_switching_table, other.branch_switching_table) + and np.array_equal(self.injection_switching_table, other.injection_switching_table) + and ( + np.array_equal(self.branch_connectivity, other.branch_connectivity) + if (self.branch_connectivity is not None and other.branch_connectivity is not None) + else self.branch_connectivity == other.branch_connectivity + ) + and ( + np.array_equal(self.injection_connectivity, other.injection_connectivity) + if (self.injection_connectivity is not None and other.injection_connectivity is not None) + else self.injection_connectivity == other.injection_connectivity + ) + ) + + def model_copy(self, *, update: Optional[dict[str, Any]] = None, deep: bool = False) -> "MaterializedStation": + """Copy and revalidate the station.""" + payload = _merged_round_trip_payload(self, update, deep=deep) + return type(self).model_validate(payload) + + def get_connected_assets( + self, + busbar_index: int, + topology_assets: Optional[list[SwitchableAsset]] = None, + asset_scope: Literal["all", "branch", "injection"] = "all", + ) -> list[SwitchableAsset]: + """Return in-service assets connected to one busbar. + + Parameters + ---------- + busbar_index : int + Row index into the station switching tables. + topology_assets : Optional[list[SwitchableAsset]] + Ignored for materialized stations because payloads are embedded locally. + asset_scope : Literal["all", "branch", "injection"] + Restrict the lookup to branch or injection connections. + + Returns + ------- + list[SwitchableAsset] + Connected in-service assets for the requested busbar and scope. + """ + del topology_assets + if asset_scope == "branch": + return [ + asset_connection.asset + for asset_connection, is_connected in zip( + self.branch_connections, + self.branch_switching_table[busbar_index], + strict=True, + ) + if is_connected and asset_connection.asset.in_service + ] + if asset_scope == "injection": + return [ + asset_connection.asset + for asset_connection, is_connected in zip( + self.injection_connections, + self.injection_switching_table[busbar_index], + strict=True, + ) + if is_connected and asset_connection.asset.in_service + ] + return [ + *self.get_connected_assets(busbar_index, asset_scope="branch"), + *self.get_connected_assets(busbar_index, asset_scope="injection"), + ] diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/station_models.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/station_models.py new file mode 100644 index 000000000..50472cf36 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/station_models.py @@ -0,0 +1,438 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Shared station models for raw and materialized asset topologies.""" + +from copy import deepcopy + +import numpy as np +from beartype.typing import Any, Literal, Optional, TypeAlias +from numpydantic import NDArray, Shape +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from toop_engine_interfaces.asset_topology.asset_types import BranchEnd +from toop_engine_interfaces.asset_topology.assets import Busbar, BusbarCoupler, SwitchableAsset + +StationSwitchingArray: TypeAlias = NDArray[Shape["* n_bus, * n_asset"], np.bool_] + + +def _merged_round_trip_payload(model: BaseModel, update: Optional[dict[str, Any]], *, deep: bool = False) -> dict[str, Any]: + """Merge model field values and requested updates for revalidation-aware model_copy overrides.""" + payload = {field_name: getattr(model, field_name) for field_name in type(model).model_fields} + if deep: + payload = deepcopy(payload) + if update: + payload.update(update) + return payload + + +class StationAssetConnection(BaseModel): + """Station-local association between a switching-table column and a topology asset.""" + + asset_id: str + """Grid model id of the topology-owned asset referenced by this station-local column.""" + + branch_end: Optional[BranchEnd] = None + """Optional branch-end metadata for this station-local asset occurrence.""" + + asset_bay_id: Optional[str] = None + """Optional topology-scoped asset bay identifier for this station-local asset occurrence.""" + + +def _validate_station_switching_tables( + station_grid_model_id: str, + station_name: Optional[str], + busbar_count: int, + asset_count: int, + asset_switching_table: np.ndarray, + asset_connectivity: Optional[np.ndarray], + asset_kind: str, +) -> None: + """Validate switching-table shapes against the station dimensions. + + Parameters + ---------- + station_grid_model_id : str + Grid model id of the station being validated. + station_name : Optional[str] + Human-readable station name used in validation errors. + busbar_count : int + Expected number of busbar rows in the switching tables. + asset_count : int + Expected number of asset columns in the switching tables. + asset_switching_table : np.ndarray + Current station switching table. + asset_connectivity : Optional[np.ndarray] + Optional connectivity mask describing physically allowed assignments. + asset_kind: str + The kind of asset being validated, used in error messages (e.g. "branch" or "injection"). + + Returns + ------- + None + This function returns nothing and raises on invalid shapes. + + Raises + ------ + ValueError + If either switching table does not match the expected station dimensions. + """ + if asset_switching_table.shape != (busbar_count, asset_count): + raise ValueError( + f"{asset_kind}_switching_table shape {asset_switching_table.shape} does not match busbars " + f"{busbar_count} and {asset_kind} assets {asset_count}" + f" Station_id: {station_grid_model_id}, Name: {station_name}" + ) + + if asset_connectivity is not None and asset_connectivity.shape != (busbar_count, asset_count): + raise ValueError( + f"{asset_kind}_connectivity shape {asset_connectivity.shape} does not match busbars " + f"{busbar_count} and {asset_kind} assets {asset_count}" + f" Station_id: {station_grid_model_id}, Name: {station_name}" + ) + + +def _validate_station_physical_assignments( + station_grid_model_id: str, + station_name: Optional[str], + asset_switching_table: np.ndarray, + asset_connectivity: Optional[np.ndarray], + asset_kind: str, +) -> None: + """Validate that all current assignments are physically allowed. + + Parameters + ---------- + station_grid_model_id : str + Grid model id of the station being validated. + station_name : Optional[str] + Human-readable station name used in validation errors. + asset_switching_table : np.ndarray + Current station switching table. + asset_connectivity : Optional[np.ndarray] + Optional connectivity mask describing physically allowed assignments. + asset_kind: str + The kind of asset being validated, used in error messages (e.g. "branch" or "injection"). + + Returns + ------- + None + This function returns nothing and raises on invalid assignments. + + Raises + ------ + ValueError + If the switching table contains assignments forbidden by ``asset_connectivity``. + """ + if asset_connectivity is not None: + if np.logical_and(asset_switching_table, np.logical_not(asset_connectivity)).any(): + raise ValueError( + f"Not all current {asset_kind} assignments are physically allowed " + f"Station_id: {station_grid_model_id}, Name: {station_name}" + ) + + +class _StationStructure(BaseModel): + """Shared station fields and structural validators for station views.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + grid_model_id: str + """The unique identifier of the station. + + Expects the bus-branch model bus_id, not the full voltage level id. + + Included are all assets, busbars and couplers that are connectable via switches. + Buses in the same station that are connected via branches are excluded in this specific bus. + + This means, that two stations/buses can have the same elements if the station is currently split. + """ + + name: Optional[str] = None + """The name of the station.""" + + station_type: Optional[str] = None + """The type of the station.""" + + region: Optional[str] = None + """The region of the station.""" + + voltage_level: Optional[float] = None + """The voltage level of the station.""" + + busbars: list[Busbar] + """The list of busbars at the station.""" + + couplers: list[BusbarCoupler] + """The list of couplers at the station.""" + + branch_switching_table: StationSwitchingArray + """Holds the switching of each branch asset to each busbar, shape (n_bus, n_branch_asset). + + An entry is true if the asset is connected to the busbar. + Note: An asset can be connected to multiple busbars, in which case a closed coupler is assumed + to be present between these busbars. + Note: An asset can be connected to none of the busbars. In this case, the asset is intentionally + disconnected as part of a transmission line switching action. In practice, this usually involves + a separate switch from the asset-to-busbar couplers, as each asset usually has a switch that + completely disconnects it from the station. These switches are not modelled here, a + postprocessing routine needs to do the translation to this physical layout. Do not use + in_service for intentional disconnections. + """ + + injection_switching_table: StationSwitchingArray + """Holds the switching of each injection asset to each busbar, shape (n_bus, n_injection_asset).""" + + branch_connectivity: Optional[StationSwitchingArray] = None + """Holds all physically possible branch layouts, shape (n_bus, n_branch_asset).""" + + injection_connectivity: Optional[StationSwitchingArray] = None + """Holds all physically possible injection layouts, shape (n_bus, n_injection_asset).""" + + model_log: Optional[list[str]] = None + """Holds log messages from the model creation process. + + This can be used to store information about the model creation process, e.g. warnings or errors. + A potential use case is to inform the user about data quality issues e.g. missing the Asset Bay switches. + """ + + @field_validator( + "branch_switching_table", + "injection_switching_table", + "branch_connectivity", + "injection_connectivity", + mode="before", + ) + @classmethod + def normalize_station_tables( + cls, + v: Optional[Any], # noqa: ANN401 + ) -> Optional[np.ndarray]: + """Normalize switching and connectivity table inputs to boolean arrays.""" + if v is None: + return None + return np.asarray(v, dtype=bool) + + @field_validator("busbars") + @classmethod + def check_int_id_unique(cls, v: list[Busbar]) -> list[Busbar]: + """Check if the int_ids of the busbars are unique.""" + int_ids = [busbar.int_id for busbar in v] + if len(int_ids) != len(set(int_ids)): + raise ValueError("busbar int_ids must be unique per station") + return v + + @field_validator("couplers") + @classmethod + def check_coupler_busbars_different(cls, v: list[BusbarCoupler]) -> list[BusbarCoupler]: + """Check if the couplers do not connect the same busbar on both ends.""" + for coupler in v: + if coupler.busbar_from_id == coupler.busbar_to_id: + raise ValueError(f"Coupler {coupler.grid_model_id} connects the same busbar on both ends") + return v + + @model_validator(mode="after") + def check_coupler_busbars_exist(self: "_StationStructure") -> "_StationStructure": + """Check if all coupler busbar ids exist in busbars.""" + busbar_ids = [busbar.int_id for busbar in self.busbars] + for coupler in self.couplers: + if coupler.busbar_from_id not in busbar_ids or coupler.busbar_to_id not in busbar_ids: + raise ValueError( + f"Coupler {coupler.grid_model_id} references non-existing busbars" + f" Station_id: {self.grid_model_id}, Name: {self.name}" + ) + return self + + @model_validator(mode="after") + def check_coupler_references(self: "_StationStructure") -> "_StationStructure": + """Check if all closed couplers reference in-service busbars.""" + busbar_state_map = {busbar.int_id: busbar.in_service for busbar in self.busbars} + for coupler in self.couplers: + if coupler.open or not coupler.in_service: + continue + if busbar_state_map[coupler.busbar_from_id] != busbar_state_map[coupler.busbar_to_id]: + raise ValueError( + f"Closed coupler {coupler.grid_model_id} connects out-of-service busbar with in-service busbar." + f" Station_id: {self.grid_model_id}, Name: {self.name}" + ) + return self + + @model_validator(mode="after") + def check_bus_id(self: "_StationStructure") -> "_StationStructure": + """Check if station grid_model_id is in the busbar.bus_branch_bus_id.""" + busbar_grid_model_id = [busbar.bus_branch_bus_id for busbar in self.busbars if busbar.bus_branch_bus_id is not None] + if len(busbar_grid_model_id) > 0 and self.grid_model_id not in busbar_grid_model_id: + raise ValueError( + f"Station grid_model_id {self.grid_model_id} does not exist in busbars bus_branch_bus_id" + f" Station_id: {self.grid_model_id}, Name: {self.name}" + ) + + return self + + def is_split(self) -> bool: + """Return whether the station view spans more than one non-empty bus-branch bus id.""" + bus_ids = {busbar.bus_branch_bus_id for busbar in self.busbars if busbar.bus_branch_bus_id not in {None, ""}} + return len(bus_ids) > 1 + + +class RawStation(_StationStructure): + """Station data stored inside a topology without embedded asset payloads. + + The station identity still refers to a bus-branch model bus_id for one splitable station view. + Asset membership is expressed through the aligned station-local arrays instead of embedded + SwitchableAsset payloads. + """ + + branch_connections: list[StationAssetConnection] = Field(default_factory=list) + """Station-local branch references aligned with ``branch_switching_table``.""" + + injection_connections: list[StationAssetConnection] = Field(default_factory=list) + """Station-local injection references aligned with ``injection_switching_table``.""" + + def with_asset_terminals(self, asset_terminals: list[Optional[BranchEnd]]) -> "RawStation": + """Return a copy with updated branch terminals.""" + if len(asset_terminals) != len(self.branch_connections): + raise ValueError( + f"asset_terminals length {len(asset_terminals)} does not match branch_connections length " + f"{len(self.branch_connections)}" + f" Station_id: {self.grid_model_id}, Name: {self.name}" + ) + + return self.model_copy( + update={ + "branch_connections": [ + asset_connection.model_copy(update={"branch_end": asset_terminal}) + for asset_connection, asset_terminal in zip(self.branch_connections, asset_terminals, strict=True) + ] + } + ) + + @model_validator(mode="after") + def check_asset_reference_alignment(self: "RawStation") -> "RawStation": + """Check if station-local asset reference arrays are aligned.""" + _validate_station_switching_tables( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + busbar_count=len(self.busbars), + asset_count=len(self.branch_connections), + asset_switching_table=self.branch_switching_table, + asset_connectivity=self.branch_connectivity, + asset_kind="branch", + ) + _validate_station_physical_assignments( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + asset_switching_table=self.branch_switching_table, + asset_connectivity=self.branch_connectivity, + asset_kind="branch", + ) + _validate_station_switching_tables( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + busbar_count=len(self.busbars), + asset_count=len(self.injection_connections), + asset_switching_table=self.injection_switching_table, + asset_connectivity=self.injection_connectivity, + asset_kind="injection", + ) + _validate_station_physical_assignments( + station_grid_model_id=self.grid_model_id, + station_name=self.name, + asset_switching_table=self.injection_switching_table, + asset_connectivity=self.injection_connectivity, + asset_kind="injection", + ) + return self + + def __eq__(self, other: object) -> bool: + """Check if two topology stations are equal.""" + if not isinstance(other, RawStation): + return False + return ( + self.grid_model_id == other.grid_model_id + and self.name == other.name + and self.station_type == other.station_type + and self.region == other.region + and self.voltage_level == other.voltage_level + and self.busbars == other.busbars + and self.couplers == other.couplers + and self.branch_connections == other.branch_connections + and self.injection_connections == other.injection_connections + and np.array_equal(self.branch_switching_table, other.branch_switching_table) + and np.array_equal(self.injection_switching_table, other.injection_switching_table) + and ( + np.array_equal(self.branch_connectivity, other.branch_connectivity) + if (self.branch_connectivity is not None and other.branch_connectivity is not None) + else self.branch_connectivity == other.branch_connectivity + ) + and ( + np.array_equal(self.injection_connectivity, other.injection_connectivity) + if (self.injection_connectivity is not None and other.injection_connectivity is not None) + else self.injection_connectivity == other.injection_connectivity + ) + and self.model_log == other.model_log + ) + + def model_copy(self, *, update: Optional[dict[str, Any]] = None, deep: bool = False) -> "RawStation": + """Copy and revalidate the station.""" + payload = _merged_round_trip_payload(self, update, deep=deep) + return type(self).model_validate(payload) + + def get_connected_assets( + self, + busbar_index: int, + topology_assets: Optional[list[SwitchableAsset]] = None, + asset_scope: Literal["all", "branch", "injection"] = "all", + ) -> list[SwitchableAsset]: + """Return in-service topology assets connected to one busbar. + + Parameters + ---------- + busbar_index : int + Row index into the station switching tables. + topology_assets : Optional[list[SwitchableAsset]] + Topology-owned assets used to resolve raw station asset references. + asset_scope : Literal["all", "branch", "injection"] + Restrict the lookup to branch or injection connections. + + Returns + ------- + list[SwitchableAsset] + Connected in-service assets for the requested busbar and scope. + + Raises + ------ + ValueError + If topology assets are missing for a raw station lookup. + """ + if topology_assets is None: + raise ValueError("topology_assets must be provided when resolving connected assets for a RawStation") + + asset_map = {asset.grid_model_id: asset for asset in topology_assets} + if asset_scope == "branch": + return [ + asset_map[asset_connection.asset_id] + for asset_connection, is_connected in zip( + self.branch_connections, + self.branch_switching_table[busbar_index], + strict=True, + ) + if is_connected and asset_map[asset_connection.asset_id].in_service + ] + if asset_scope == "injection": + return [ + asset_map[asset_connection.asset_id] + for asset_connection, is_connected in zip( + self.injection_connections, + self.injection_switching_table[busbar_index], + strict=True, + ) + if is_connected and asset_map[asset_connection.asset_id].in_service + ] + return [ + *self.get_connected_assets(busbar_index, topology_assets=topology_assets, asset_scope="branch"), + *self.get_connected_assets(busbar_index, topology_assets=topology_assets, asset_scope="injection"), + ] diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/topology_conversion.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/topology_conversion.py new file mode 100644 index 000000000..1d787e289 --- /dev/null +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology/topology_conversion.py @@ -0,0 +1,138 @@ +# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, +# you can obtain one at https://mozilla.org/MPL/2.0/. +# Mozilla Public License, version 2.0 + +"""Conversions between materialized stations and canonical topology models.""" + +from beartype.typing import Optional +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.assets import ( + AssetBay, + BranchAsset, + InjectionAsset, + normalize_switchable_asset_payload, +) +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import RawStation, StationAssetConnection + + +def topology_parts_from_materialized_station( + station: MaterializedStation, +) -> tuple[RawStation, list[BranchAsset], list[InjectionAsset], list[AssetBay]]: + """Extract topology-owned payloads from a materialized station. + + Parameters + ---------- + station : MaterializedStation + Materialized station to decompose. + + Returns + ------- + tuple[RawStation, list[BranchAsset], list[InjectionAsset], list[AssetBay]] + Raw station record plus topology-owned branch assets, injection assets, and asset bays. + """ + branch_assets: list[BranchAsset] = [] + injection_assets: list[InjectionAsset] = [] + asset_bays: list[AssetBay] = [] + branch_connections: list[StationAssetConnection] = [] + injection_connections: list[StationAssetConnection] = [] + for asset_connection in station.branch_connections: + asset = normalize_switchable_asset_payload(asset_connection.asset.model_dump(round_trip=True)) + asset_bay = asset_connection.asset_bay + asset_id = asset.grid_model_id + asset_bay_id: Optional[str] = None + + if asset_bay is not None: + asset_bay_id = asset_bay.asset_bay_id + asset_bays.append(asset_bay.model_copy(deep=True)) + + branch_asset = asset if isinstance(asset, BranchAsset) else BranchAsset.model_validate(asset.model_dump()) + branch_assets.append(branch_asset.model_copy(deep=True)) + branch_connections.append( + StationAssetConnection( + asset_id=asset_id, + branch_end=asset_connection.branch_end, + asset_bay_id=asset_bay_id, + ) + ) + + for asset_connection in station.injection_connections: + asset = normalize_switchable_asset_payload(asset_connection.asset.model_dump(round_trip=True)) + asset_bay = asset_connection.asset_bay + asset_id = asset.grid_model_id + asset_bay_id = asset_bay.asset_bay_id if asset_bay is not None else None + + if asset_bay is not None: + asset_bays.append(asset_bay.model_copy(deep=True)) + + injection_asset = asset if isinstance(asset, InjectionAsset) else InjectionAsset.model_validate(asset.model_dump()) + injection_assets.append(injection_asset.model_copy(deep=True)) + injection_connections.append( + StationAssetConnection( + asset_id=asset_id, + branch_end=asset_connection.branch_end, + asset_bay_id=asset_bay_id, + ) + ) + + return ( + RawStation( + grid_model_id=station.grid_model_id, + name=station.name, + station_type=station.station_type, + region=station.region, + voltage_level=station.voltage_level, + busbars=station.busbars, + couplers=station.couplers, + branch_connections=branch_connections, + injection_connections=injection_connections, + branch_switching_table=station.branch_switching_table, + injection_switching_table=station.injection_switching_table, + branch_connectivity=station.branch_connectivity, + injection_connectivity=station.injection_connectivity, + model_log=station.model_log, + ), + branch_assets, + injection_assets, + asset_bays, + ) + + +def topology_from_materialized_stations(reference_topology: Topology, stations: list[MaterializedStation]) -> Topology: + """Create a topology from materialized stations. + + Parameters + ---------- + reference_topology : Topology + Reference topology providing shared metadata. + stations : list[MaterializedStation] + Materialized stations to convert. + + Returns + ------- + Topology + Topology containing updated raw stations while reusing the reference topology-owned payloads. + """ + topology_stations: list[RawStation] = [] + + for station in stations: + topology_station, _station_branch_assets, _station_injection_assets, _station_asset_bays = ( + topology_parts_from_materialized_station(station) + ) + topology_stations.append(topology_station) + + return Topology( + topology_id=reference_topology.topology_id, + grid_model_file=reference_topology.grid_model_file, + name=reference_topology.name, + raw_stations=topology_stations, + branch_assets=[asset.model_copy(deep=True) for asset in reference_topology.branch_assets], + injection_assets=[asset.model_copy(deep=True) for asset in reference_topology.injection_assets], + asset_bays=[asset_bay.model_copy(deep=True) for asset_bay in reference_topology.asset_bays], + asset_setpoints=reference_topology.asset_setpoints, + timestamp=reference_topology.timestamp, + metrics=reference_topology.metrics, + ) diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology_helpers.py b/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology_helpers.py deleted file mode 100644 index 239d8e550..000000000 --- a/packages/interfaces_pkg/src/toop_engine_interfaces/asset_topology_helpers.py +++ /dev/null @@ -1,1486 +0,0 @@ -# Copyright 2026 50Hertz Transmission GmbH and Elia Transmission Belgium SA/NV -# -# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -# If a copy of the MPL was not distributed with this file, -# you can obtain one at https://mozilla.org/MPL/2.0/. -# Mozilla Public License, version 2.0 - -"""Collects some common helper functions for asset topology manipulation.""" - -import itertools -from numbers import Integral -from pathlib import Path - -import networkx as nx -import numpy as np -from beartype.typing import Literal, Optional, Union -from fsspec import AbstractFileSystem -from fsspec.implementations.local import LocalFileSystem -from toop_engine_interfaces.asset_topology import ( - Busbar, - BusbarCoupler, - RealizedStation, - RealizedTopology, - Station, - SwitchableAsset, - Topology, -) -from toop_engine_interfaces.filesystem_helper import load_pydantic_model_fs - - -def electrical_components(station: Station, min_num_assets: int = 1) -> list[list[int]]: - """Compute the electrical components of a station. - - A set of busbars is considered a separate electrical component if it is not connected through a - closed coupler to other busbars and there are at least two assets connected to the component. - - Parameters - ---------- - station : Station - The station object to analyze. - min_num_assets : int, optional - The minimum number of assets connected to a component to be considered a valid component, by default 1 - - Returns - ------- - list[list[int]] - A list of lists, where each inner list contains the indices of the busbars in the component indexing into the list - of all busbars in the station. - """ - n_connections_per_bus = station.asset_switching_table.sum(axis=1) - - int_id_mapper = {busbar.int_id: i for i, busbar in enumerate(station.busbars)} - - graph = nx.Graph() - graph.add_nodes_from( - [(busbar.int_id, {"degree": degree}) for busbar, degree in zip(station.busbars, n_connections_per_bus, strict=True)] - ) - graph.add_edges_from( - [(coupler.busbar_from_id, coupler.busbar_to_id) for coupler in station.couplers if not coupler.open] - ) - - components = nx.connected_components(graph) - # Filter out busbars with no assets connected to them - components = [ - list(component) - for component in components - if sum(graph.nodes[busbar]["degree"] for busbar in component) >= min_num_assets - ] - # Map int ids - components = [[int_id_mapper[busbar] for busbar in component] for component in components] - - return components - - -def number_of_splits(station: Station) -> int: - """Compute the number of electrical components that are present in a station. - - A set of busbars is considered a separate electrical component if it is not connected through a - closed coupler to other busbars and there are at least two assets connected to the component. - - Parameters - ---------- - station : Station - The station object to analyze. - - Returns - ------- - int - The number of electrical components in the station. - """ - station = filter_out_of_service(station) - - components = electrical_components(station, min_num_assets=2) - return len(components) - - -def remove_busbar(station: Station, grid_model_id: str) -> Station: - """Remove a busbar with a specific grid_model_id from the station. - - This will - - remove the busbar from the list of busbars - - remove all couplers that are connected to the busbar at either end - - remove all asset bay entries that are connected to the busbar - - remove the line from the asset switching table - - remove the line from the asset connectivity table - - Parameters - ---------- - station : Station - The station object to modify. - grid_model_id : str - The grid_model_id of the busbar to remove. - - Returns - ------- - Station - The modified station object with the busbar removed. - """ - # Store the index and int_id of the dropped busbar - index = [b.grid_model_id for b in station.busbars].index(grid_model_id) - int_id = station.busbars[index].int_id - - busbars = [b for b in station.busbars if b.grid_model_id != grid_model_id] - couplers = [c for c in station.couplers if int_id not in (c.busbar_from_id, c.busbar_to_id)] - asset_switching_table = np.delete(station.asset_switching_table, index, axis=0) - asset_connectivity = ( - np.delete(station.asset_connectivity, index, axis=0) if station.asset_connectivity is not None else None - ) - - def filter_sr_keys(asset: SwitchableAsset) -> SwitchableAsset: - """Filter out the sr keys from the asset bay""" - if asset.asset_bay is None: - return asset - return asset.model_copy( - update={ - "asset_bay": asset.asset_bay.model_copy( - update={ - "sr_switch_grid_model_id": { - busbar_id: foreign_id - for busbar_id, foreign_id in asset.asset_bay.sr_switch_grid_model_id.items() - if busbar_id != grid_model_id - } - } - ) - } - ) - - assets = [filter_sr_keys(a) for a in station.assets] - - # Create a new station object with the modified busbars, couplers, and asset switching table - new_station = station.model_copy( - update={ - "busbars": busbars, - "couplers": couplers, - "assets": assets, - "asset_switching_table": asset_switching_table, - "asset_connectivity": asset_connectivity, - } - ) - return new_station - - -def filter_out_of_service_assets(station: Station) -> Station: - """Filter out-of-service assets from the station. - - Parameters - ---------- - station : Station - The station object to filter. - - Returns - ------- - Station - The new station object with all out-of-service assets removed. - """ - if all(asset.in_service for asset in station.assets): - return station - - in_service_assets = [asset.in_service for asset in station.assets] - - return station.model_copy( - update={ - "assets": [asset for asset in station.assets if asset.in_service], - "asset_switching_table": station.asset_switching_table[:, in_service_assets], - "asset_connectivity": station.asset_connectivity[:, in_service_assets] - if station.asset_connectivity is not None - else None, - } - ) - - -def filter_out_of_service_busbars(station: Station) -> Station: - """Filter out-of-service busbars from the station. - - Parameters - ---------- - station : Station - The station object to filter. - - Returns - ------- - Station - The new station object with all out-of-service busbars removed. - """ - deleted_busbar_ids = [busbar.grid_model_id for busbar in station.busbars if not busbar.in_service] - - for busbar in deleted_busbar_ids: - station = remove_busbar(station, busbar) - - return station - - -def filter_out_of_service_couplers(station: Station) -> Station: - """Filter out-of-service couplers from the station. - - Parameters - ---------- - station : Station - The station object to filter. - - Returns - ------- - Station - The new station object with all out-of-service couplers removed. - """ - if all(coupler.in_service for coupler in station.couplers): - return station - - return station.model_copy( - update={ - "couplers": [coupler for coupler in station.couplers if coupler.in_service], - } - ) - - -def filter_out_of_service(station: Station) -> Station: - """Filter out-of-service assets, busbars and couplers from the station. - - The return value will be a new station object with all out-of-service elements removed. - Note that the busbars are not reindexed, so the busbar ids will be the same as in the original - station with missing elements. If you expect a continuous range of busbar ids, you - should call reindex_busbars after this function. - - Parameters - ---------- - station : Station - The station object to filter. - - Returns - ------- - Station - The new station object with all out-of-service assets removed. - """ - station = filter_out_of_service_couplers(station) - station = filter_out_of_service_assets(station) - station = filter_out_of_service_busbars(station) - - # Validate the new station object - Station.model_validate(station) - - return station - - -def filter_duplicate_couplers( - station: Station, retain_type_hierarchy: Optional[list[str]] = None -) -> tuple[Station, list[BusbarCoupler]]: - """Filter out duplicate couplers - - Two couplers are considered duplicates if they connect the same busbars, regardless of their - order. If a duplicate coupler is found, only the first one is kept. - - Parameters - ---------- - station : Station - The station object to filter. - retain_type_hierarchy : Optional[list[str]], optional - If provided, not the first coupler is kept but the one with the highest type in the hierarchy list. Highest means - that the type is the first in the list. If not provided, the first coupler is kept, by default None - - Returns - ------- - Station - The new station object with duplicate couplers removed. - list[BusbarCoupler] - The list of removed couplers. - """ - # A dict with the coupler representation as key and a list of indices of the couplers - coupler_dict: dict[tuple[int, int], list[int]] = {} - for index, coupler in enumerate(station.couplers): - coupler_repr = ( - min(coupler.busbar_from_id, coupler.busbar_to_id), - max(coupler.busbar_from_id, coupler.busbar_to_id), - ) - coupler_dict[coupler_repr] = [*coupler_dict.get(coupler_repr, []), index] - - kept_couplers = [] - removed_couplers = [] - for _coupler_repr, index in coupler_dict.items(): - # If there is only one or we don't have to sort, take the first and remove all the others - if len(index) == 1 or retain_type_hierarchy is None: - sorted_couplers = [station.couplers[i] for i in index] - # We have to sort by type hierarchy - else: - # Sort the couplers by their type in the hierarchy, if the type is not in the hierarchy, it will be at the end - sorted_couplers = sorted( - (station.couplers[i] for i in index), - key=lambda c: ( - retain_type_hierarchy.index(c.type) if c.type in retain_type_hierarchy else len(retain_type_hierarchy) - ), - ) - # Keep the first coupler and remove the others - kept_couplers.append(sorted_couplers[0]) - removed_couplers.extend(sorted_couplers[1:]) - - if len(removed_couplers) == 0: - return station, removed_couplers - - return ( - station.model_copy(update={"couplers": kept_couplers}), - removed_couplers, - ) - - -def filter_disconnected_busbars(station: Station, respect_coupler_open: bool = False) -> tuple[Station, list[Busbar]]: - """Remove busbars that can not get connected by any coupler. - - This creates a graph of the busbars and couplers and returns only the largest connected component. The size - of a component is determined by the number of assets connected to it. - Open and closed couplers are treated the same if respect_coupler_open is False, i.e. a busbar connected - by only open couplers is considered connected. - Busbars connected by out-of-service couplers are always considered disconnected. - - This also means that elements which are only connected to the disconnected busbars are - effectively disconnected, and it looks like they are subject to transmission line switching. - - Note that this function does not reindex the busbars, so the busbar ids will be the same as in - the original station with missing elements. If you expect a continuous range of busbar ids, you - should call reindex_busbars after this function. - - Parameters - ---------- - station : Station - The station object potentially with disconnected busbars. - respect_coupler_open : bool, optional - If True, only closed couplers are considered connected, if False, all couplers are - considered connected, by default False - - Returns - ------- - Station - The new station object with disconnected busbars removed. - list[Busbar] - The list of removed busbars. - """ - couplers = [ - coupler for coupler in station.couplers if (not respect_coupler_open or not coupler.open) and coupler.in_service - ] - graph = nx.Graph() - num_assets_per_busbar = station.asset_switching_table.sum(axis=1) - graph.add_nodes_from( - [ - (busbar.int_id, {"num_assets": num_assets}) - for (busbar, num_assets) in zip(station.busbars, num_assets_per_busbar, strict=True) - ] - ) - graph.add_edges_from([(coupler.busbar_from_id, coupler.busbar_to_id) for coupler in couplers]) - - components = list(nx.connected_components(graph)) - if len(components) == 1: - return station, [] - - # Order components by the number of assets connected to them - components.sort(key=lambda x: sum(graph.nodes[busbar]["num_assets"] for busbar in x), reverse=True) - - removed_busbars = [busbar for busbar in station.busbars if busbar.int_id not in components[0]] - - for busbar in removed_busbars: - station = remove_busbar(station, busbar.grid_model_id) - - return station, removed_busbars - - -def reindex_busbars(station: Station) -> Station: - """Reindex the int-ids of the busbars in the station - - This might be necessary after filder_disconnected_busbars or filter_out_of_service have been called. - - Parameters - ---------- - station : Station - The station object with possible inconsistent busbar ids. - - Returns - ------- - Station - The new station object with reindexed busbars, where int-ids are continuous and start at 0. - """ - busbar_mapping = {busbar.int_id: i for i, busbar in enumerate(station.busbars)} - new_busbars = [busbar.model_copy(update={"int_id": i}) for i, busbar in enumerate(station.busbars)] - new_couplers = [ - coupler.model_copy( - update={ - "busbar_from_id": busbar_mapping[coupler.busbar_from_id], - "busbar_to_id": busbar_mapping[coupler.busbar_to_id], - } - ) - for coupler in station.couplers - ] - - station = station.model_copy(update={"busbars": new_busbars, "couplers": new_couplers}) - Station.model_validate(station) - return station - - -def filter_assets_by_type( - station: Station, assets_allowed: set[str], allow_none_type: bool = False -) -> tuple[Station, list[SwitchableAsset]]: - """Filter assets by type - - Removes all assets that have a type which is not in the set of allowed types. - - Parameters - ---------- - station : Station - The station object to filter. - assets_allowed : set[str] - The set of asset types that are allowed. - allow_none_type : bool, optional - If True, assets without a type are allowed, by default False - - Returns - ------- - Station - The new station object with assets of the wrong type removed. - list[SwitchableAsset] - The list of removed assets. - """ - asset_mask = [(asset.type in assets_allowed) or (allow_none_type and asset.type is None) for asset in station.assets] - if all(asset_mask): - return station, [] - - removed_assets = [asset for asset, mask in zip(station.assets, asset_mask, strict=True) if not mask] - kept_assets = [asset for asset, mask in zip(station.assets, asset_mask, strict=True) if mask] - - new_station = station.model_copy( - update={ - "assets": kept_assets, - "asset_switching_table": station.asset_switching_table[:, asset_mask], - "asset_connectivity": station.asset_connectivity[:, asset_mask] - if station.asset_connectivity is not None - else None, - } - ) - return new_station, removed_assets - - -def find_multi_connected_without_coupler( - station: Station, -) -> list[tuple[Integral, Integral, Integral]]: - """Find assets bridging multiple busbars without a coupler in between - - These cases can cause a bug in the downstream functions - - Parameters - ---------- - station : Station - The station object to check. - - Returns - ------- - list[tuple[Integral, Integral, Integral]] - A list of tuples containing the index of the multi-connected asset and the indices of the - two busbars it is bridging. - Only returns bridges that don't have a coupler in between. - The first busbar index will always be lower than the second, hence a routine which always - removes the first doesn't run into double removals if an asset appears multiple times. - """ - multi_connected_without_coupler = [] - for asset_idx in np.flatnonzero(station.asset_switching_table.sum(axis=0) > 1): - busbars_bridged = np.flatnonzero(station.asset_switching_table[:, asset_idx]).tolist() - for bus1_idx, bus2_idx in itertools.combinations(busbars_bridged, 2): - # bus1_idx shall always be the smaller of the two - if bus1_idx > bus2_idx: - smaller_bus_idx, larger_bus_idx = bus2_idx, bus1_idx - else: - smaller_bus_idx, larger_bus_idx = bus1_idx, bus2_idx - - if not any( - ( - coupler.busbar_from_id == station.busbars[smaller_bus_idx].int_id - and coupler.busbar_to_id == station.busbars[larger_bus_idx].int_id - ) - or ( - coupler.busbar_from_id == station.busbars[larger_bus_idx].int_id - and coupler.busbar_to_id == station.busbars[smaller_bus_idx].int_id - ) - for coupler in station.couplers - ): - multi_connected_without_coupler.append( - ( - asset_idx, - smaller_bus_idx, - larger_bus_idx, - ) - ) - - return multi_connected_without_coupler - - -def fix_multi_connected_without_coupler( - station: Station, -) -> tuple[Station, list[tuple[SwitchableAsset, Busbar, Busbar]]]: - """Remove one connection for multi-connected assets without a coupler in between - - Will always remove the connection to the busbar with the lower index. - - Parameters - ---------- - station : Station - The station object to fix - - Returns - ------- - Station - The new station object with the multi-connected assets fixed. - list[tuple[SwitchableAsset, Busbar, Busbar]] - A list of tuples containing the previously multi-connected assets and the busbars they were - connected to. The first busbar in the tuple is the one that was disconnected. - """ - multi_connected_without_coupler = find_multi_connected_without_coupler(station) - if not multi_connected_without_coupler: - return station, [] - - new_asset_switching_table = np.copy(station.asset_switching_table) - diff = [] - for asset_idx, bus1_idx, bus2_idx in multi_connected_without_coupler: - new_asset_switching_table[bus1_idx, asset_idx] = 0 - diff.append( - ( - station.assets[asset_idx], - station.busbars[bus1_idx], - station.busbars[bus2_idx], - ) - ) - - return station.model_copy(update={"asset_switching_table": new_asset_switching_table}), diff - - -def has_transmission_line_switching(station: Station) -> bool: - """Check if the switching table contains transmission line switching - - Transmission line switching is defined as disconnecting an asset from all busbars on purpose as - a remedial action. Out-of-service assets are not considered irrespective of the switching table. - - Parameters - ---------- - station : Station - The station object to check. - - Returns - ------- - bool - True if the switching table contains transmission line switching, False otherwise. - """ - in_service = np.array([asset.in_service for asset in station.assets]) - return np.any((station.asset_switching_table.sum(axis=0) == 0) & in_service).item() - - -def find_busbars_for_coupler(busbars: list[Busbar], coupler: BusbarCoupler) -> tuple[Busbar, Busbar]: - """Find the from and two busbars for a coupler - - Finds based on the int_id of the busbars. - - Parameters - ---------- - busbars : list[Busbar] - The list of busbars to search in - coupler : BusbarCoupler - The coupler to search for - - Returns - ------- - Busbar - The from busbar - Busbar - The to busbar - - Raises - ------ - ValueError - If any of the busbars for the coupler are not found. This should never happen as the station validator should - capture such a scenario. - """ - try: - busbar_from = next(busbar for busbar in busbars if busbar.int_id == coupler.busbar_from_id) - busbar_to = next(busbar for busbar in busbars if busbar.int_id == coupler.busbar_to_id) - return busbar_from, busbar_to - except StopIteration as e: - raise ValueError(f"Busbars for coupler {coupler.grid_model_id} not found") from e - - -def merge_couplers( - original: list[BusbarCoupler], - new: list[BusbarCoupler], - busbar_mapping: dict[int, int], -) -> tuple[list[BusbarCoupler], list[BusbarCoupler]]: - """Merge an updated list of couplers into the original list - - Due to preprocessing actions, the new list might contain a subset of the original couplers. - Especially duplicate couplers were removed, so if the original list contains duplicates, both - couplers need to be switched. It assumes that the busbar ids are mapped through the busbar - mapping - - If the new list contains duplicates, the couplers are counted as open if all duplicates are open - and as closed otherwise - - Parameters - ---------- - original : list[BusbarCoupler] - The original list of couplers of the unprocessed station - new : list[BusbarCoupler] - The new list of couplers of the processed station after applying the topology - busbar_mapping : dict[int, int] - The mapping from the original busbar indices to the new busbar indices - - Returns - ------- - list[BusbarCoupler] - The updated list of couplers - list[BusbarCoupler] - The coupler diff in this station, i.e. which couplers have been switched. Stores the - new state of the couplers, i.e. which state the coupler has been switched to - """ - # Store the couplers in a dict for easier access - target_state = {} - for coupler in new: - key = (coupler.busbar_from_id, coupler.busbar_to_id) - # There can be a case with multiple couplers - # In that case, the coupler is open if all are open - # If no other coupler is present, the coupler state is just copied - target_state[key] = coupler.open and target_state.get(key, True) - - new_couplers = [] - diff = [] - for coupler in original: - key = ( - busbar_mapping[coupler.busbar_from_id], - busbar_mapping[coupler.busbar_to_id], - ) - if key in target_state and target_state[key] != coupler.open: - new_coupler = coupler.model_copy(update={"open": target_state[key]}) - new_couplers.append(new_coupler) - diff.append(new_coupler) - else: - new_couplers.append(coupler) - - return new_couplers, diff - - -def merge_stations( - original: list[Station], - new: list[Station], - missing_station_behavior: Literal["append", "raise"] = "append", -) -> tuple[list[Station], list[tuple[str, BusbarCoupler]], list[tuple[str, int, int, bool]]]: - """Merge a list of changed stations into a list of original stations - - All stations with a grid_model_id that is present in the new list will be updated using - merge_station. The coupler+reassignment diffs will be concatenated. If a new station is not - present in the original list, it will be appended to the end of the list if - missing_station_behavior is "append". - - Parameters - ---------- - original : list[Station] - The original list of stations - new : list[Station] - The list of changed stations - missing_station_behavior : Literal["append", "raise"], optional - What to do if a station is not found in the original list, by default "append" - - Returns - ------- - list[Station] - The updated list of stations - list[tuple[str, BusbarCoupler]] - The coupler diff that has been switched, consisting of tuples with the station grid_model_id - and the coupler that has been switched - list[tuple[str, int, int, bool]] - The reassignment diff that has been switched, consisting of tuples with the station grid_model_id, - the asset index that was affected, which busbar index was affected and whether the asset was - connected (True) or disconnected (False) to that bus - """ - new_stations_found = [] - updated_station_list = [] - coupler_diff = [] - reassignment_diff = [] - for station in original: - found = False - for new_station in new: - if station.grid_model_id == new_station.grid_model_id: - updated_station, coupler_diff_local, reassignment_diff_local = merge_station(station, new_station) - updated_station_list.append(updated_station) - new_stations_found.append(new_station.grid_model_id) - coupler_diff.extend([(station.grid_model_id, coupler) for coupler in coupler_diff_local]) - reassignment_diff.extend( - [ - (station.grid_model_id, asset_idx, busbar_idx, bool(connected)) - for asset_idx, busbar_idx, connected in reassignment_diff_local - ] - ) - found = True - break - if not found: - updated_station_list.append(station) - - # Check if there are new stations that were not found in the original list - for new_station in new: - if new_station.grid_model_id not in new_stations_found: - if missing_station_behavior == "append": - updated_station_list.append(new_station) - else: - raise ValueError(f"Station {new_station.grid_model_id} was not found in the original list") - - return updated_station_list, coupler_diff, reassignment_diff - - -# TODO: refactor due to C901 -def merge_station(original: Station, new: Station) -> tuple[Station, list[BusbarCoupler], list[tuple[int, int, bool]]]: - """Merge all the changes from the new station into the original station - - This will overwrite all assets, couplers and busbars in the original station with the ones from - the new station if the couplers are also found in the new station. Things that are not found in - the new station will be left untouched. Assets that are in the new station but not in the - original will also be left untouched. - - If all in-service elements of original are also in new, then the returned substation will be - electrically equivalent to the new substation. If this is not the case, the returned substation - has all possible changes applied, but there are cases in which this is not electrically - equivalent to the new substation. - - You can use asset_topology_helpers.compare_stations to check which elements are different - in the two stations and infer the differences. - - Parameters - ---------- - original : Station - The original station that should be modified - new : Station - The new station that contains the changes that should be merged into the original station - - Returns - ------- - Station - The modified original station, with all the changes from the new station merged in - list[BusbarCoupler] - The coupler diff that has been switched - list[tuple[int, int, bool]] - The asset diff that has been switched. Each tuple contains the asset index that was - affected, which busbar index was affected and whether the asset was connected (True) or - disconnected (False) to that bus - """ - if original == new: - return original, [], [] - - # Find a mapping from old busbars to new busbars. We just need an index mapping for copying the - # switching table. Also store the missed busbars. - busbar_mapping = {} - busbar_int_id_mapping = {} - max_busbar_id = max(busbar.int_id for busbar in new.busbars) - asset_mapping, new_couplers, coupler_diff = map_busbars_and_assets( - original, new, busbar_mapping, busbar_int_id_mapping, max_busbar_id - ) - - # Loop through the switching table and copy the values over for which there is a mapping - new_asset_switching_table = original.asset_switching_table.copy() - station, asset_diff = update_asset_switching_table( - original, new, busbar_mapping, asset_mapping, new_couplers, new_asset_switching_table - ) - return station, coupler_diff, asset_diff - - -def update_asset_switching_table( - original_station: Station, - new_station: Station, - busbar_mapping: dict[int, int], - asset_mapping: dict[int, int], - new_couplers: list[BusbarCoupler], - new_asset_switching_table: np.ndarray, -) -> tuple[Station, list[tuple[int, int, bool]]]: - """Update the asset switching table of the original station with the values from the new station. - - This is a separate function to reduce the complexity of merge_station. - It also updates the couplers in the original station with the new couplers. - - Parameters - ---------- - original_station : Station - The original station that should be modified - new_station : Station - The new station that contains the changes that should be merged into the original station - busbar_mapping : dict[int, int] - The mapping from the original busbar indices to the new busbar indices - asset_mapping : dict[int, int] - The mapping from the original asset indices to the new asset indices - new_couplers : list[BusbarCoupler] - The new list of couplers that should be merged into the original station - new_asset_switching_table : np.ndarray - The new asset switching table that should be updated with the values from the new station. This - is modified in place and returned as part of the new station object. - - Returns - ------- - Station - The modified original station, with the updated asset switching table and couplers - list[tuple[int, int, bool]] - The asset diff that has been switched. Each tuple contains the asset index that was - affected, which busbar index was affected and whether the asset was connected (True) or - disconnected (False) to that bus - """ - asset_diff = [] - for busbar_idx in range(original_station.asset_switching_table.shape[0]): - if busbar_idx in busbar_mapping: - mapped_busbar_idx = busbar_mapping[busbar_idx] - for asset_idx in range(original_station.asset_switching_table.shape[1]): - if asset_idx in asset_mapping: - mapped_asset_idx = asset_mapping[asset_idx] - - if ( - new_asset_switching_table[busbar_idx, asset_idx] - != new_station.asset_switching_table[mapped_busbar_idx, mapped_asset_idx] - ): - asset_diff.append( - ( - asset_idx, - busbar_idx, - bool(new_station.asset_switching_table[mapped_busbar_idx, mapped_asset_idx]), - ) - ) - - new_asset_switching_table[busbar_idx, asset_idx] = new_station.asset_switching_table[ - mapped_busbar_idx, mapped_asset_idx - ] - - original_station = original_station.model_copy( - update={"couplers": new_couplers, "asset_switching_table": new_asset_switching_table} - ) - return original_station, asset_diff - - -def map_busbars_and_assets( - original_station: Station, - new_station: Station, - busbar_mapping: dict[int, int], - busbar_int_id_mapping: dict[int, int], - max_busbar_id: int, -) -> tuple[dict[int, int], list[BusbarCoupler], list[BusbarCoupler]]: - """Find a mapping from old busbars to new busbars and from old assets to new assets - - This is a separate function to reduce the complexity of merge_station. - It also merges the couplers and returns the new couplers and the coupler diff. - - Parameters - ---------- - original_station : Station - The original station that should be modified - new_station : Station - The new station that contains the changes that should be merged into the original station - busbar_mapping : dict[int, int] - The mapping from the original busbar indices to the new busbar indices. This is modified in place and - returned as part of the new station object. - busbar_int_id_mapping : dict[int, int] - The mapping from the original busbar int_ids to the new busbar int_ids. This is modified in place and - returned as part of the new station object. - max_busbar_id : int - The maximum busbar int_id in the new station. - This is used to assign new int_ids to busbars that are in the original station but not in the new station, - to avoid conflicts with existing busbars in the new station. - - Returns - ------- - dict[int, int] - The mapping from the original asset indices to the new asset indices. This is modified in place - and returned as part of the new station object. - list[BusbarCoupler] - The new list of couplers that should be merged into the original station - list[BusbarCoupler] - The coupler diff in this station, i.e. which couplers have been switched. Stores the - new state of the couplers, i.e. which state the coupler has been switched to - """ - for i, busbar in enumerate(original_station.busbars): - found = False - for j, other in enumerate(new_station.busbars): - if busbar.grid_model_id == other.grid_model_id: - busbar_mapping[i] = j - busbar_int_id_mapping[busbar.int_id] = other.int_id - found = True - break - if not found: - # Make sure to not accidentally map to an existing busbar - busbar_int_id_mapping[busbar.int_id] = busbar.int_id + max_busbar_id + 1 - - # Same for the switchable assets - asset_mapping = {} - for i, asset in enumerate(original_station.assets): - for j, other in enumerate(new_station.assets): - if asset.grid_model_id == other.grid_model_id: - asset_mapping[i] = j - break - - # Merge couplers - new_couplers, coupler_diff = merge_couplers( - original_station.couplers, new_station.couplers, busbar_mapping=busbar_int_id_mapping - ) - return asset_mapping, new_couplers, coupler_diff - - -def compare_stations( - a: Station, b: Station -) -> tuple[ - list[BusbarCoupler], - list[BusbarCoupler], - list[Busbar], - list[Busbar], - list[SwitchableAsset], - list[SwitchableAsset], -]: - """Compare two stations and return the missing elements - - It uses grid_model_ids to compare the assets, busbars and couplers. It does not consider - different switching states or coupler states, but just checks if all the elements are also in - the other station. - - Parameters - ---------- - a : Station - The first station to compare. - b : Station - The second station to compare. - - Returns - ------- - list[BusbarCoupler] - The couplers that are in a but not in b. - list[BusbarCoupler] - The couplers that are in b but not in a. - list[Busbar] - The busbars that are in a but not in b. - list[Busbar] - The busbars that are in b but not in a. - list[SwitchableAsset] - The assets that are in a but not in b. - list[SwitchableAsset] - The assets that are in b but not in a. - """ - a_busbars = set(busbar.grid_model_id for busbar in a.busbars) - b_busbars = set(busbar.grid_model_id for busbar in b.busbars) - - a_couplers = set(coupler.grid_model_id for coupler in a.couplers) - b_couplers = set(coupler.grid_model_id for coupler in b.couplers) - - a_assets = set(asset.grid_model_id for asset in a.assets) - b_assets = set(asset.grid_model_id for asset in b.assets) - - return ( - [coupler for coupler in a.couplers if coupler.grid_model_id not in b_couplers], - [coupler for coupler in b.couplers if coupler.grid_model_id not in a_couplers], - [busbar for busbar in a.busbars if busbar.grid_model_id not in b_busbars], - [busbar for busbar in b.busbars if busbar.grid_model_id not in a_busbars], - [asset for asset in a.assets if asset.grid_model_id not in b_assets], - [asset for asset in b.assets if asset.grid_model_id not in a_assets], - ) - - -def load_asset_topology_fs( - filesystem: AbstractFileSystem, - file_path: Union[str, Path], -) -> Topology: - """Load an asset topology from a file system. - - Parameters - ---------- - filesystem : AbstractFileSystem - The file system to use to load the asset topology. - file_path : Union[str, Path] - The path to the file containing the asset topology in json format. - - Returns - ------- - Topology - The loaded asset topology. - """ - return load_pydantic_model_fs( - filesystem=filesystem, - file_path=file_path, - model_class=Topology, - ) - - -def load_asset_topology(filename: Union[str, Path]) -> Topology: - """Load an asset topology from a file - - Parameters - ---------- - filename : Union[str, Path] - The filename to load the asset topology from - - Returns - ------- - Topology - The loaded asset topology - """ - return load_asset_topology_fs( - filesystem=LocalFileSystem(), - file_path=filename, - ) - - -def save_asset_topology_fs(filesystem: AbstractFileSystem, filename: Union[str, Path], asset_topology: Topology) -> None: - """Save an asset topology to a file system - - Parameters - ---------- - filesystem : AbstractFileSystem - The file system to save the asset topology to - filename : Union[str, Path] - The filename to save the asset topology to - asset_topology: Topology - The asset topology to save - """ - with filesystem.open(str(filename), "w", encoding="utf-8") as file: - file.write(asset_topology.model_dump_json(indent=2)) - - -def save_asset_topology(filename: Union[str, Path], asset_topology: Topology) -> None: - """Save an asset topology to a file - - Parameters - ---------- - filename : Union[str, Path] - The filename to save the asset topology to - asset_topology: Topology - The asset topology to save - """ - save_asset_topology_fs(LocalFileSystem(), filename, asset_topology) - - -def get_connected_assets(station: Station, busbar_index: int) -> list[SwitchableAsset]: - """ - Get the assets connected to a specific busbar in a station. - - Parameters - ---------- - station : Station - The station object containing the switching table and assets. - busbar_index : int - The index of the busbar in the switching table. - - Returns - ------- - list of SwitchableAsset - A list of assets connected to the specified busbar. - """ - connected_asset_indices = np.nonzero(station.asset_switching_table[busbar_index])[0] - return [station.assets[i] for i in connected_asset_indices if station.assets[i].in_service] - - -def accumulate_diffs( - realized_stations: list[RealizedStation], -) -> tuple[ - list[tuple[str, BusbarCoupler]], - list[tuple[str, int, int, bool]], - list[tuple[str, int]], -]: - """Accumulate the diffs of the realized stations into the format of realized topology - - Parameters - ---------- - realized_stations : list[RealizedStation] - The realized stations to accumulate - - Returns - ------- - list[tuple[str, BusbarCoupler]] - The accumulated coupler diff - list[tuple[str, int, int, bool]] - The accumulated reassignment diff - list[tuple[str, int]] - The accumulated disconnection diff - """ - coupler_diff = [] - reassignment_diff = [] - disconnection_diff = [] - for station in realized_stations: - s_id = station.station.grid_model_id - coupler_diff.extend([(s_id, coupler) for coupler in station.coupler_diff]) - reassignment_diff.extend( - [(s_id, asset_idx, bus_idx, connected) for (asset_idx, bus_idx, connected) in station.reassignment_diff] - ) - disconnection_diff.extend([(s_id, asset_idx) for asset_idx in station.disconnection_diff]) - - return coupler_diff, reassignment_diff, disconnection_diff - - -def station_diff( - start_station: Station, - target_station: Station, -) -> RealizedStation: - """Compute the diff between two stations - - The same station must be described by both inputs, i.e. the assets, busbars and couplers (except for their open state) - must be the same. - - Parameters - ---------- - start_station : Station - The starting station from which to start the diff - target_station : Station - The ending station to which the diff shall lead when applied to the starting station - - Returns - ------- - RealizedStation - The realized station containing the target station and the coupler, reassignment and disconnection diffs - """ - assert [s.grid_model_id for s in start_station.assets] == [s.grid_model_id for s in target_station.assets], ( - "Assets do not match" - ) - assert [b.grid_model_id for b in start_station.busbars] == [b.grid_model_id for b in target_station.busbars], ( - "Busbars do not match" - ) - assert [b.grid_model_id for b in start_station.couplers] == [b.grid_model_id for b in target_station.couplers], ( - "Couplers do not match" - ) - - reassignment_diff = [] - disconnection_diff = [] - for asset_index in range(len(start_station.assets)): - target_disconnected = ~np.any(target_station.asset_switching_table[:, asset_index]) - start_disconnected = ~np.any(start_station.asset_switching_table[:, asset_index]) - - if start_disconnected and not target_disconnected: - raise NotImplementedError( - "Reconnections are not supported yet, there is no diff for that" - + f" (asset {asset_index} in station {start_station.grid_model_id})" - ) - - if target_disconnected and not start_disconnected: - # if the asset is connected in the target station but disconnected in the starting station - # we need to add it to the diff - disconnection_diff.append(asset_index) - - # No reassignment diff if the asset is disconnected - if target_disconnected: - continue - - xor_diff = np.logical_xor( - start_station.asset_switching_table[:, asset_index], - target_station.asset_switching_table[:, asset_index], - ) - for busbar_index in np.flatnonzero(xor_diff): - # If the start and end switching entries differ, add it to the reassignment diff - reassignment_diff.append( - (asset_index, busbar_index, target_station.asset_switching_table[busbar_index, asset_index]) - ) - - coupler_diff = [] - for start_coupler, target_coupler in zip(start_station.couplers, target_station.couplers, strict=True): - if start_coupler.open != target_coupler.open: - coupler_diff.append(target_coupler) - - return RealizedStation( - station=target_station, - coupler_diff=coupler_diff, - reassignment_diff=reassignment_diff, - disconnection_diff=disconnection_diff, - ) - - -def topology_diff( - start_topo: Topology, - target_topo: Topology, -) -> RealizedTopology: - """Compute the difference between two topologies - - Parameters - ---------- - start_topo : Topology - The starting topology - target_topo : Topology - The targeted topology. - - Returns - ------- - RealizedTopology - The realized topology containing the target topology and the coupler, reassignment and disconnection diffs that lead - from the starting topology to the target topology. - """ - realized_stations = [ - station_diff(start_station, target_station) - for (start_station, target_station) in zip(start_topo.stations, target_topo.stations, strict=True) - ] - coupler_diff, reassignment_diff, disconnection_diff = accumulate_diffs(realized_stations) - return RealizedTopology( - topology=target_topo, - coupler_diff=coupler_diff, - reassignment_diff=reassignment_diff, - disconnection_diff=disconnection_diff, - ) - - -def order_station_assets(station: Station, asset_ids: list[str]) -> tuple[Station, list[str], list[str]]: - """Orders the assets in a station according to a list of asset ids. - - If an asset is not found in the station, it will be added to the not_found list. - If an asset is not present in the asset_ids list, it will be dropped from the station. - - Parameters - ---------- - station : Station - The station to order. - asset_ids : list[str] - A list of asset ids. The assets will be ordered according to the grid_model_id. - - Returns - ------- - Station - The ordered station - list[str] - A list of asset ids that were not found in the station - list[str] - A list of asset ids that were ignored, i.e. not present in the asset_ids list - """ - new_assets = [] - not_found = [] - old_positions = [] - for asset_id in asset_ids: - found = False - for pos, asset in enumerate(station.assets): - if asset.grid_model_id == asset_id: - new_assets.append(asset) - old_positions.append(pos) - found = True - break - if not found: - not_found.append(asset_id) - - ignored = [asset.grid_model_id for index, asset in enumerate(station.assets) if index not in old_positions] - - asset_switching_table = station.asset_switching_table[:, old_positions] - asset_connectivity = station.asset_connectivity[:, old_positions] if station.asset_connectivity is not None else None - station = station.model_copy( - update={ - "assets": new_assets, - "asset_switching_table": asset_switching_table, - "asset_connectivity": asset_connectivity, - } - ) - Station.model_validate(station) - return station, not_found, ignored - - -def order_topology(topology: Topology, station_ids: list[str]) -> tuple[Topology, list[str]]: - """Orders the stations in a topology according to a list of ids. - - If a station is not found in the topology, it will be added to the not_found list. - If a station is not present in the station_ids list, it will be dropped. - - Parameters - ---------- - topology : Topology - The topology to order. - station_ids : list[str] - A list of station ids. The stations will be ordered according to the grid_model_id. - - Returns - ------- - Topology - The ordered topology - list[str] - A list of station ids that were not found in the topology - """ - new_stations = [] - not_found = [] - for relevant_node in station_ids: - found = False - for station in topology.stations: - if station.grid_model_id == relevant_node: - new_stations.append(station) - found = True - break - if not found: - not_found.append(relevant_node) - - topology = topology.model_copy(update={"stations": new_stations}) - return topology, not_found - - -def fuse_coupler( - station: Station, - coupler_grid_model_id: str, - copy_info_from: bool = True, -) -> Station: - """Fuses a coupler by merging the adjacent busbars into one busbar. - - Parameters - ---------- - station : Station - The station with the coupler to fuse. Assumes that the coupler is in the station. The open/closed state of the - coupler is disregarded, i.e. the coupler will be fused regardless of its state. - coupler_grid_model_id : str - The grid_model_id of the coupler to fuse - copy_info_from : bool, optional - Whether the new busbar retains the information (grid_model_id, etc) of the busbar on the from side of the coupler, - by default True. If False, the busbar on the to side of the coupler is used. - - Returns - ------- - Station - The station with the coupler fused. The coupler is removed and the busbars are merged. - """ - coupler = next((c for c in station.couplers if c.grid_model_id == coupler_grid_model_id), None) - if coupler is None: - raise ValueError(f"Coupler {coupler_grid_model_id} not found in station {station.grid_model_id}") - - busbar_from_index = next((index for index, b in enumerate(station.busbars) if b.int_id == coupler.busbar_from_id), None) - busbar_to_index = next((index for index, b in enumerate(station.busbars) if b.int_id == coupler.busbar_to_id), None) - - assert busbar_from_index is not None, f"Busbar {coupler.busbar_from_id} not found in station {station.grid_model_id}" - assert busbar_to_index is not None, f"Busbar {coupler.busbar_to_id} not found in station {station.grid_model_id}" - - if ( - len( - [ - c - for c in station.couplers - if (c.busbar_from_id == coupler.busbar_from_id and c.busbar_to_id == coupler.busbar_to_id) - or (c.busbar_to_id == coupler.busbar_from_id and c.busbar_from_id == coupler.busbar_to_id) - ] - ) - > 1 - ): - raise ValueError( - f"Coupler {coupler_grid_model_id} has parallel couplers in station {station.grid_model_id}, " - "cannot fuse parallel couplers with the same busbars" - ) - - switching_row = station.asset_switching_table[busbar_from_index] | station.asset_switching_table[busbar_to_index] - connectivity_row = station.asset_connectivity[busbar_from_index] | station.asset_connectivity[busbar_to_index] - - # Remove either the from or the to busbar - busbar_index_to_remove = busbar_to_index if copy_info_from else busbar_from_index - busbar_index_to_keep = busbar_from_index if copy_info_from else busbar_to_index - - new_switching_table = np.copy(station.asset_switching_table) - new_switching_table[busbar_index_to_keep] = switching_row - new_switching_table = np.delete(new_switching_table, busbar_index_to_remove, axis=0) - - if station.asset_connectivity is not None: - new_connectivity_table = np.copy(station.asset_connectivity) - new_connectivity_table[busbar_index_to_keep] = connectivity_row - new_connectivity_table = np.delete(new_connectivity_table, busbar_index_to_remove, axis=0) - else: - new_connectivity_table = None - - busbar_to_remove = station.busbars[busbar_index_to_remove] - busbar_to_keep = station.busbars[busbar_index_to_keep] - - def _replace_sr_keys(asset: SwitchableAsset) -> SwitchableAsset: - """Update the sr switch asset if it is connected to the removed busbar.""" - if asset.asset_bay is None: - return asset - if ( - busbar_to_remove.grid_model_id in asset.asset_bay.sr_switch_grid_model_id.keys() - and busbar_to_keep.grid_model_id in asset.asset_bay.sr_switch_grid_model_id.keys() - ): - # If both busbars are present, we need to remove the one that is not kept - new_sr_switch_grid_model_id = { - key: foreign_id - for (key, foreign_id) in asset.asset_bay.sr_switch_grid_model_id.items() - if key != busbar_to_remove.grid_model_id - } - else: - # If the target busbar is not present, we change the key - new_sr_switch_grid_model_id = { - (busbar_to_keep.grid_model_id if key == busbar_to_remove.grid_model_id else key): foreign_id - for (key, foreign_id) in asset.asset_bay.sr_switch_grid_model_id.items() - } - - return asset.model_copy( - update={"asset_bay": asset.asset_bay.model_copy(update={"sr_switch_grid_model_id": new_sr_switch_grid_model_id})} - ) - - def _replace_int_id(coupler: BusbarCoupler) -> BusbarCoupler: - """Update coupler int-ids that are pointing to the removed busbar.""" - if coupler.busbar_from_id == busbar_to_remove.int_id: - return coupler.model_copy(update={"busbar_from_id": busbar_to_keep.int_id}) - if coupler.busbar_to_id == busbar_to_remove.int_id: - return coupler.model_copy(update={"busbar_to_id": busbar_to_keep.int_id}) - return coupler - - new_busbars = [b for i, b in enumerate(station.busbars) if i != busbar_index_to_remove] - new_couplers = [_replace_int_id(c) for c in station.couplers if c.grid_model_id != coupler_grid_model_id] - new_assets = [_replace_sr_keys(a) for a in station.assets] - - station = station.model_copy( - update={ - "busbars": new_busbars, - "asset_switching_table": new_switching_table, - "asset_connectivity": new_connectivity_table, - "assets": new_assets, - "couplers": new_couplers, - } - ) - Station.model_validate(station) - return station - - -def fuse_all_couplers_with_type( - station: Station, - coupler_type: str, - copy_info_from: bool = True, -) -> tuple[Station, list[BusbarCoupler]]: - """Fuses all couplers of a specific type in a station. - - This will also filter all duplicate couplers, as there might be an edge case in which a triangle of busbars is not - properly merged otherwise. - - Parameters - ---------- - station : Station - The station with the couplers to fuse - coupler_type : str - The type of coupler to fuse, will match the coupler.type attribute. If coupler.type is None, it will never match. - copy_info_from : bool, optional - Whether the new busbar retains the information (grid_model_id, etc) of the busbar on the from side of the coupler, - by default True. If False, the busbar on the to side of the coupler is used. - - Returns - ------- - Station - The station with the couplers fused that matched the type. The couplers are removed and the busbars are merged. - list[BusbarCoupler] - The couplers that were fused or removed due to being parallel and are no longer present in the station. - """ - fused_couplers = [] - while True: - coupler = next((c for c in station.couplers if (c.type is not None and c.type == coupler_type)), None) - if coupler is None: - break - - station = fuse_coupler(station, coupler.grid_model_id, copy_info_from=copy_info_from) - fused_couplers.append(coupler) - - # We want to retain the coupler type that we filter for and remove all other couplers - this way we can - # make sure that all parallel couplers are removed. Otherwise it would depend on the order of the couplers - # whether there would be a residual coupler left between the potentially fused busbars. - station, removed = filter_duplicate_couplers(station, retain_type_hierarchy=[coupler_type]) - fused_couplers.extend(removed) - - return station, fused_couplers - - -def find_station_by_id(stations: list[Station], station_id: str) -> Station: - """Find a station by its grid_model_id in a list of stations. - - Parameters - ---------- - stations : list[Station] - The list of stations to search in. - station_id : str - The grid_model_id of the station to find. - - Returns - ------- - Station - The station with the given grid_model_id. - - Raises - ------ - ValueError - If the station is not found in the list. - """ - for station in stations: - if station.grid_model_id == station_id: - return station - raise ValueError(f"Station {station_id} not found in the list") diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py index e88c9b5d9..2df5a5921 100644 --- a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py @@ -12,7 +12,7 @@ import numpy as np from beartype.typing import Optional, Sequence, Union from jaxtyping import Bool, Float, Int -from toop_engine_interfaces.asset_topology import Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology class BackendInterface(ABC): diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/lf_service/loadflow_commands.py b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/lf_service/loadflow_commands.py index a28d2ae5d..2a9e31ac3 100644 --- a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/lf_service/loadflow_commands.py +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/lf_service/loadflow_commands.py @@ -25,7 +25,7 @@ from beartype.typing import Literal, Optional, Protocol, Union from pydantic import BaseModel, Field -from toop_engine_interfaces.asset_topology import Strategy +from toop_engine_interfaces.asset_topology.asset_topology import Strategy from toop_engine_interfaces.loadflow_results import LoadflowResults from toop_engine_interfaces.nminus1_definition import GridElement, Nminus1Definition diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/stored_action_set.py b/packages/interfaces_pkg/src/toop_engine_interfaces/stored_action_set.py index 8bec8da44..adb42e2dc 100644 --- a/packages/interfaces_pkg/src/toop_engine_interfaces/stored_action_set.py +++ b/packages/interfaces_pkg/src/toop_engine_interfaces/stored_action_set.py @@ -43,7 +43,8 @@ from fsspec.implementations.local import LocalFileSystem from jaxtyping import Bool from pydantic import BaseModel, ConfigDict, model_validator -from toop_engine_interfaces.asset_topology import Station, Topology +from toop_engine_interfaces.asset_topology.asset_topology import Topology +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedStation from toop_engine_interfaces.filesystem_helper import save_pydantic_model_fs from toop_engine_interfaces.nminus1_definition import GridElement @@ -113,7 +114,7 @@ class ActionSet(BaseModel): """A list of high voltage direct current lines that can be set as a remedial action. This is currently not implemented yet in the solver.""" - local_actions: list[Station] + local_actions: list[MaterializedStation] """A list of split/reconfiguration actions that affect exactly one substation. These are must be ordered by station, i.e. actions affecting the same station are next to each other. The grid_model_id of the station should be used to determine which substation it affects. Within a station, asset @@ -147,32 +148,56 @@ class StationDiffArray(BaseModel): station.couplers in length and order and the entries correspond to open (True) and closed (False). The n_actions dimension provides an entry per action in the action set.""" - switching_table: Bool[np.ndarray, " _n_actions _n_busbars _n_assets"] - """The switching table of the station. The array dimensions n_busbars and n_assets are equivalent to the - switching table in the station, """ + branch_switching_table: Bool[np.ndarray, " _n_actions _n_busbars _n_branch_assets"] + """Branch switching tables for the station actions. + + The busbar and branch-asset dimensions match ``station.branch_switching_table``. + """ + + injection_switching_table: Bool[np.ndarray, " _n_actions _n_busbars _n_injection_assets"] + """Injection switching tables for the station actions. + + The busbar and injection-asset dimensions match ``station.injection_switching_table``. + """ @model_validator(mode="after") def _validate_station_diff_arrays(self) -> "StationDiffArray": """Validate stored station diff array shapes. Different stations can legitimately have different action counts, so the relevant invariant is - local to each station diff: coupler_open and switching_table must agree on their first - dimension per station. However, the beartype checker invokes the checks in such a way that a global instantiation of - dimension values was happening, raising. Hence, we check the shapes manually here. + local to each station diff: coupler_open, branch_switching_table, and injection_switching_table + must agree on their first dimension per station. The two switching tables must also agree on + their busbar dimension. However, the beartype checker invokes the checks in such a way that a + global instantiation of dimension values was happening, raising. Hence, we check the shapes + manually here. """ if self.coupler_open.ndim != 2: raise ValueError("coupler_open must be a 2D array of shape (n_actions, n_couplers)") - if self.switching_table.ndim != 3: - raise ValueError("switching_table must be a 3D array of shape (n_actions, n_busbars, n_assets)") - if self.coupler_open.shape[0] != self.switching_table.shape[0]: + if self.branch_switching_table.ndim != 3: + raise ValueError("branch_switching_table must be a 3D array of shape (n_actions, n_busbars, n_branch_assets)") + if self.injection_switching_table.ndim != 3: + raise ValueError( + "injection_switching_table must be a 3D array of shape (n_actions, n_busbars, n_injection_assets)" + ) + if self.coupler_open.shape[0] != self.branch_switching_table.shape[0]: + raise ValueError( + "coupler_open and branch_switching_table must have the same n_actions dimension, got " + f"{self.coupler_open.shape[0]} and {self.branch_switching_table.shape[0]}" + ) + if self.coupler_open.shape[0] != self.injection_switching_table.shape[0]: raise ValueError( - "coupler_open and switching_table must have the same n_actions dimension, got " - f"{self.coupler_open.shape[0]} and {self.switching_table.shape[0]}" + "coupler_open and injection_switching_table must have the same n_actions dimension, got " + f"{self.coupler_open.shape[0]} and {self.injection_switching_table.shape[0]}" + ) + if self.branch_switching_table.shape[1] != self.injection_switching_table.shape[1]: + raise ValueError( + "branch_switching_table and injection_switching_table must have the same n_busbars dimension, got " + f"{self.branch_switching_table.shape[1]} and {self.injection_switching_table.shape[1]}" ) return self -def validate_actions_grouped(actions: list[Station]) -> None: +def validate_actions_grouped(actions: list[MaterializedStation]) -> None: """Validate that actions are grouped by station grid model id. Parameters @@ -198,7 +223,7 @@ def validate_actions_grouped(actions: list[Station]) -> None: last_grid_model_id = grid_model_id -def _validate_station_diff_hypothesis(starting_station: Station, action: Station) -> None: +def _validate_station_diff_hypothesis(starting_station: MaterializedStation, action: MaterializedStation) -> None: """Validate that only coupler open states and switching table values differ. Parameters @@ -219,9 +244,10 @@ def _validate_station_diff_hypothesis(starting_station: Station, action: Station f"{starting_station.grid_model_id}." ) - def normalize_station(station: Station) -> dict[str, object]: + def normalize_station(station: MaterializedStation) -> dict[str, object]: station_data = station.model_dump(mode="json") - station_data.pop("asset_switching_table", None) + station_data.pop("branch_switching_table", None) + station_data.pop("injection_switching_table", None) for coupler in station_data.get("couplers", []): if isinstance(coupler, dict): coupler.pop("open", None) @@ -229,7 +255,7 @@ def normalize_station(station: Station) -> dict[str, object]: if normalize_station(action) != normalize_station(starting_station): raise ValueError( - f"Action station {action.grid_model_id} changed fields other than coupler open states and asset switching table." + f"Action station {action.grid_model_id} changed fields other than coupler open states and switching tables." ) @@ -260,7 +286,8 @@ def store_station_diff_fs( for station_diff in station_diffs: group = file.create_group(station_diff.grid_model_id) group.create_dataset("coupler_open", data=station_diff.coupler_open) - group.create_dataset("switching_table", data=station_diff.switching_table) + group.create_dataset("branch_switching_table", data=station_diff.branch_switching_table) + group.create_dataset("injection_switching_table", data=station_diff.injection_switching_table) bytes_io.seek(0) with filesystem.open(str(diff_file_path), "wb") as file: file.write(bytes_io.getbuffer()) @@ -294,9 +321,13 @@ def _load_station_diff_io(binaryio: io.IOBase) -> list[StationDiffArray]: for grid_model_id in station_order: group = file[grid_model_id] coupler_open = group["coupler_open"][:] - switching_table = group["switching_table"][:] + branch_switching_table = group["branch_switching_table"][:] + injection_switching_table = group["injection_switching_table"][:] station_diff = StationDiffArray( - grid_model_id=grid_model_id, coupler_open=coupler_open, switching_table=switching_table + grid_model_id=grid_model_id, + coupler_open=coupler_open, + branch_switching_table=branch_switching_table, + injection_switching_table=injection_switching_table, ) station_diffs.append(station_diff) return station_diffs @@ -325,7 +356,9 @@ def load_station_diff_fs(filesystem: AbstractFileSystem, diff_file_path: str | P return _load_station_diff_io(buffer) -def expand_single_station_diff_to_actions(starting_station: Station, station_diff: StationDiffArray) -> list[Station]: +def expand_single_station_diff_to_actions( + starting_station: MaterializedStation, station_diff: StationDiffArray +) -> list[MaterializedStation]: """Expand densely stored station diffs to a list of stations with the same format as in the action set. This only expands a single station diff, so it should be called once per station in the action set. @@ -334,7 +367,7 @@ def expand_single_station_diff_to_actions(starting_station: Station, station_dif ---------- starting_station : Station The station as it looks in the starting topology. All fields from the station will be copied except for the - coupler states and switching table, which will be overwritten by the station diff. + coupler states and switching tables, which will be overwritten by the station diff. station_diff : StationDiffArray The station diff to expand. @@ -350,19 +383,21 @@ def expand_single_station_diff_to_actions(starting_station: Station, station_dif coupler.model_copy(update={"open": bool(coupler_open)}) for coupler, coupler_open in zip(starting_station.couplers, coupler_array, strict=True) ] - switching_table = station_diff.switching_table[i] + branch_switching_table = station_diff.branch_switching_table[i] + injection_switching_table = station_diff.injection_switching_table[i] action = starting_station.model_copy( update={ "couplers": couplers, - "asset_switching_table": switching_table, + "branch_switching_table": branch_switching_table, + "injection_switching_table": injection_switching_table, }, ) actions.append(action) return actions -def expand_station_diffs(starting_topology: Topology, station_diffs: list[StationDiffArray]) -> list[Station]: +def expand_station_diffs(starting_topology: Topology, station_diffs: list[StationDiffArray]) -> list[MaterializedStation]: """Expand densely stored station diffs to a list of stations with the same format as in the action set. This expands a list of station diffs, so it can be called once per action set. @@ -372,7 +407,7 @@ def expand_station_diffs(starting_topology: Topology, station_diffs: list[Statio starting_topology : Topology The topology as it looks in the starting topology. The station diffs will be matched to the stations in the topology based on their grid_model_id and all fields from the station will be copied except for the coupler states and - switching table, which will be overwritten by the station diff. + switching tables, which will be overwritten by the station diff. station_diffs : list[StationDiffArray] The station diffs to expand. @@ -381,7 +416,7 @@ def expand_station_diffs(starting_topology: Topology, station_diffs: list[Statio list[Station] A list of stations, each corresponding to an action in the station diffs action dimension. """ - grid_model_id_to_station = {station.grid_model_id: station for station in starting_topology.stations} + grid_model_id_to_station = {station.grid_model_id: station for station in starting_topology.materialize_stations()} actions = [] for station_diff in station_diffs: starting_station = grid_model_id_to_station[station_diff.grid_model_id] @@ -390,7 +425,7 @@ def expand_station_diffs(starting_topology: Topology, station_diffs: list[Statio def compress_actions_to_station_diffs( - starting_topology: Topology, actions: list[Station], validate_diff_hypothesis: bool = False + starting_topology: Topology, actions: list[MaterializedStation], validate_diff_hypothesis: bool = False ) -> list[StationDiffArray]: """Compress a list of stations with the same format as in the action set to densely stored station diffs. @@ -405,7 +440,7 @@ def compress_actions_to_station_diffs( ---------- starting_topology : Topology The topology as it looks in the starting topology. The stations will be matched to the stations in the topology - based on their grid_model_id and the coupler states and switching table will be compared to the ones in the topology + based on their grid_model_id and the coupler states and switching tables will be compared to the ones in the topology to create the station diffs. Note that this should be the simplified starting topology if simplifications have been applies, as they will also be present in all stations in the action set. @@ -427,9 +462,9 @@ def compress_actions_to_station_diffs( If the actions are not grouped by station ValueError If validate_diff_hypothesis is True and the change between actions for the same station regards fields other than the - coupler states and switching table. + coupler states and switching tables. """ - grid_model_id_to_station = {station.grid_model_id: station for station in starting_topology.stations} + grid_model_id_to_station = {station.grid_model_id: station for station in starting_topology.materialize_stations()} station_diffs = {} for grid_model_id, group in itertools.groupby(actions, key=lambda action: action.grid_model_id): if grid_model_id not in grid_model_id_to_station: @@ -437,22 +472,31 @@ def compress_actions_to_station_diffs( starting_station = grid_model_id_to_station[grid_model_id] coupler_open = [] - switching_table = [] + branch_switching_tables = [] + injection_switching_tables = [] for action in group: assert len(action.couplers) == len(starting_station.couplers), ( "Number of couplers in action station does not match starting station." ) - assert action.asset_switching_table.shape == starting_station.asset_switching_table.shape, ( - "Switching table shape in action station does not match starting station." + assert action.branch_switching_table.shape == starting_station.branch_switching_table.shape, ( + "Branch switching table shape in action station does not match starting station." + ) + assert action.injection_switching_table.shape == starting_station.injection_switching_table.shape, ( + "Injection switching table shape in action station does not match starting station." ) if validate_diff_hypothesis: _validate_station_diff_hypothesis(starting_station=starting_station, action=action) coupler_open.append([coupler.open for coupler in action.couplers]) - switching_table.append(action.asset_switching_table) + branch_switching_tables.append(action.branch_switching_table) + injection_switching_tables.append(action.injection_switching_table) coupler_open_array = np.array(coupler_open).astype(bool) - switching_table_array = np.array(switching_table).astype(bool) + branch_switching_table_array = np.array(branch_switching_tables).astype(bool) + injection_switching_table_array = np.array(injection_switching_tables).astype(bool) station_diff = StationDiffArray( - grid_model_id=grid_model_id, coupler_open=coupler_open_array, switching_table=switching_table_array + grid_model_id=grid_model_id, + coupler_open=coupler_open_array, + branch_switching_table=branch_switching_table_array, + injection_switching_table=injection_switching_table_array, ) if station_diff.grid_model_id in station_diffs: raise ValueError(f"Duplicate station diff for grid_model_id {grid_model_id}, actions were not in order.") diff --git a/packages/interfaces_pkg/tests/messages/lf_service/test_loadflow_commands.py b/packages/interfaces_pkg/tests/messages/lf_service/test_loadflow_commands.py index ede3e0c6e..3a48ea4ee 100644 --- a/packages/interfaces_pkg/tests/messages/lf_service/test_loadflow_commands.py +++ b/packages/interfaces_pkg/tests/messages/lf_service/test_loadflow_commands.py @@ -5,7 +5,7 @@ # you can obtain one at https://mozilla.org/MPL/2.0/. # Mozilla Public License, version 2.0 -from toop_engine_interfaces.asset_topology import Strategy +from toop_engine_interfaces.asset_topology.asset_topology import Strategy from toop_engine_interfaces.messages.lf_service import loadflow_commands as lf_cmds from toop_engine_interfaces.nminus1_definition import GridElement, Nminus1Definition diff --git a/packages/interfaces_pkg/tests/test_asset_topology.py b/packages/interfaces_pkg/tests/test_asset_topology.py index 79703bba2..8174f7516 100644 --- a/packages/interfaces_pkg/tests/test_asset_topology.py +++ b/packages/interfaces_pkg/tests/test_asset_topology.py @@ -12,15 +12,11 @@ import numpy as np import pytest from pydantic import ValidationError -from toop_engine_interfaces.asset_topology import ( - AssetBay, - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, Topology, ) -from toop_engine_interfaces.asset_topology_helpers import ( +from toop_engine_interfaces.asset_topology.asset_topology_helpers import ( filter_assets_by_type, filter_disconnected_busbars, filter_duplicate_couplers, @@ -30,10 +26,136 @@ load_asset_topology, save_asset_topology, ) +from toop_engine_interfaces.asset_topology.assets import ( + AssetBay, + BranchAsset, + Busbar, + BusbarCoupler, + InjectionAsset, + SwitchableAsset, + build_asset_bay_id, + normalize_switchable_asset_payload, +) +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection +from toop_engine_interfaces.asset_topology.topology_conversion import topology_from_materialized_stations + + +def materialized_asset_connections( + assets: list[SwitchableAsset], + terminals: list[str | None] | None = None, + asset_bays: list[AssetBay | None] | None = None, +) -> list[MaterializedAssetConnection]: + if terminals is None: + terminals = [None] * len(assets) + if asset_bays is None: + asset_bays = [None] * len(assets) + return [ + MaterializedAssetConnection(asset=asset, branch_end=terminal, asset_bay=asset_bay) + for asset, terminal, asset_bay in zip(assets, terminals, asset_bays, strict=True) + ] + + +def raw_asset_connections( + asset_ids: list[str], + terminals: list[str | None] | None = None, + asset_bay_ids: list[str | None] | None = None, +) -> list[StationAssetConnection]: + if terminals is None: + terminals = [None] * len(asset_ids) + if asset_bay_ids is None: + asset_bay_ids = [None] * len(asset_ids) + return [ + StationAssetConnection(asset_id=asset_id, branch_end=terminal, asset_bay_id=asset_bay_id) + for asset_id, terminal, asset_bay_id in zip(asset_ids, terminals, asset_bay_ids, strict=True) + ] + + +def make_materialized_station( + *, + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + branch_assets: list[SwitchableAsset], + branch_switching_table: np.ndarray, + branch_connectivity: np.ndarray | None = None, + branch_terminals: list[str | None] | None = None, + branch_asset_bays: list[AssetBay | None] | None = None, + injection_assets: list[SwitchableAsset] | None = None, + injection_switching_table: np.ndarray | None = None, + injection_connectivity: np.ndarray | None = None, + injection_terminals: list[str | None] | None = None, + injection_asset_bays: list[AssetBay | None] | None = None, +) -> MaterializedStation: + n_busbars = len(busbars) + resolved_injection_assets = injection_assets or [] + resolved_injection_switching = injection_switching_table + if resolved_injection_switching is None: + resolved_injection_switching = np.zeros((n_busbars, len(resolved_injection_assets)), dtype=bool) + + return MaterializedStation( + grid_model_id=grid_model_id, + busbars=busbars, + couplers=couplers, + branch_connections=materialized_asset_connections(branch_assets, branch_terminals, branch_asset_bays), + injection_connections=materialized_asset_connections( + resolved_injection_assets, + injection_terminals, + injection_asset_bays, + ), + branch_switching_table=branch_switching_table, + injection_switching_table=resolved_injection_switching, + branch_connectivity=branch_connectivity, + injection_connectivity=injection_connectivity, + ) + + +def make_raw_station( + *, + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + branch_asset_ids: list[str], + branch_switching_table: np.ndarray, + branch_connectivity: np.ndarray | None = None, + branch_terminals: list[str | None] | None = None, + branch_asset_bay_ids: list[str | None] | None = None, + injection_asset_ids: list[str] | None = None, + injection_switching_table: np.ndarray | None = None, + injection_connectivity: np.ndarray | None = None, + injection_terminals: list[str | None] | None = None, + injection_asset_bay_ids: list[str | None] | None = None, +) -> RawStation: + n_busbars = len(busbars) + resolved_injection_asset_ids = injection_asset_ids or [] + resolved_injection_switching = injection_switching_table + if resolved_injection_switching is None: + resolved_injection_switching = np.zeros((n_busbars, len(resolved_injection_asset_ids)), dtype=bool) + + return RawStation( + grid_model_id=grid_model_id, + busbars=busbars, + couplers=couplers, + branch_connections=raw_asset_connections(branch_asset_ids, branch_terminals, branch_asset_bay_ids), + injection_connections=raw_asset_connections( + resolved_injection_asset_ids, + injection_terminals, + injection_asset_bay_ids, + ), + branch_switching_table=branch_switching_table, + injection_switching_table=resolved_injection_switching, + branch_connectivity=branch_connectivity, + injection_connectivity=injection_connectivity, + ) def test_station() -> None: - station = Station( + assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="line2"), + SwitchableAsset(grid_model_id="line3"), + ] + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -41,19 +163,15 @@ def test_station() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) assert station is not None with pytest.raises(ValidationError): # Wrong shape of switching table - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -66,18 +184,14 @@ def test_station() -> None: grid_model_id="coupler1", ), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]).T, + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]).T, grid_model_id="station1", ) with pytest.raises(ValidationError): # Coupler references non-existing busbar - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -90,18 +204,14 @@ def test_station() -> None: grid_model_id="coupler1", ), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) with pytest.raises(ValidationError): # Coupler references non-existing busbar - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -114,18 +224,14 @@ def test_station() -> None: grid_model_id="coupler1", ), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) with pytest.raises(ValidationError): # Busbar int_id is not unique - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=1, grid_model_id="busbar2"), @@ -138,17 +244,13 @@ def test_station() -> None: grid_model_id="coupler1", ), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) with pytest.raises(ValidationError): - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -168,13 +270,13 @@ def test_station() -> None: grid_model_id="coupler2", ), ], - assets=[ + branch_assets=[ SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2", in_service=False), SwitchableAsset(grid_model_id="line3"), SwitchableAsset(grid_model_id="line4", in_service=False), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [True, False, True, True], [False, True, False, False], @@ -185,7 +287,7 @@ def test_station() -> None: ) with pytest.raises(ValidationError): - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -198,16 +300,12 @@ def test_station() -> None: grid_model_id="coupler1", ), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id="bus_id1"), @@ -215,18 +313,35 @@ def test_station() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="bus_id1", ) assert station is not None + assert not station.is_split() + + split_station = station.model_copy( + update={ + "busbars": [ + Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), + Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id="bus_id2"), + ] + } + ) + assert split_station.is_split() + + station_with_empty_bus_id = station.model_copy( + update={ + "busbars": [ + Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), + Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id=""), + ] + } + ) + assert not station_with_empty_bus_id.is_split() with pytest.raises(ValidationError): - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id="bus_id1"), @@ -239,12 +354,8 @@ def test_station() -> None: grid_model_id="coupler1", ), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="bus_id0", ) @@ -265,12 +376,12 @@ def test_station_connectivity_tables(): asset_switching_table = np.array([[True, False, True], [False, True, False]]) asset_connectivity = np.array([[True, True, True], [True, True, True]]) grid_model_id = "station1" - station = Station( + station = make_materialized_station( busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=asset_switching_table, - asset_connectivity=asset_connectivity, + branch_assets=assets, + branch_switching_table=asset_switching_table, + branch_connectivity=asset_connectivity, grid_model_id=grid_model_id, ) assert station is not None @@ -279,41 +390,82 @@ def test_station_connectivity_tables(): # entry in asset_switching_table is not in asset_connectivity asset_switching_table = np.array([[True, False, True], [False, True, False]]) asset_connectivity = np.array([[True, True, True], [True, False, True]]) - station = Station( + station = make_materialized_station( busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=asset_switching_table, - asset_connectivity=asset_connectivity, + branch_assets=assets, + branch_switching_table=asset_switching_table, + branch_connectivity=asset_connectivity, grid_model_id=grid_model_id, ) asset_switching_table = np.array([[True, False, True], [False, True, False]]) asset_connectivity = np.array([[True, True, True], [True, True, True]]) - station = Station( + station = make_materialized_station( busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=asset_switching_table, - asset_connectivity=asset_connectivity, + branch_assets=assets, + branch_switching_table=asset_switching_table, + branch_connectivity=asset_connectivity, grid_model_id=grid_model_id, ) assert station is not None +def test_topology_station_is_split() -> None: + station = make_raw_station( + grid_model_id="bus_id1", + busbars=[ + Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), + Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id="bus_id1"), + ], + couplers=[], + branch_asset_ids=["line1"], + branch_switching_table=np.array([[True], [False]]), + ) + assert not station.is_split() + + split_station = station.model_copy( + update={ + "busbars": [ + Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), + Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id="bus_id2"), + ] + } + ) + assert split_station.is_split() + + station_with_empty_bus_id = station.model_copy( + update={ + "busbars": [ + Busbar(int_id=1, grid_model_id="busbar1", bus_branch_bus_id="bus_id1"), + Busbar(int_id=2, grid_model_id="busbar2", bus_branch_bus_id=""), + ] + } + ) + assert not station_with_empty_bus_id.is_split() + + def test_schema() -> None: # Schema generation works - schema = Station.model_json_schema() + schema = MaterializedStation.model_json_schema() assert schema is not None assert "busbars" in schema["properties"] assert "couplers" in schema["properties"] - assert "assets" in schema["properties"] - assert "asset_switching_table" in schema["properties"] + assert "branch_connections" in schema["properties"] + assert "branch_switching_table" in schema["properties"] + assert "injection_connections" in schema["properties"] + assert "injection_switching_table" in schema["properties"] assert "grid_model_id" in schema["properties"] def test_serialize_station() -> None: - station = Station( + assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="line2"), + SwitchableAsset(grid_model_id="line3"), + ] + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -321,23 +473,24 @@ def test_serialize_station() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) serialized = station.model_dump_json() - station2 = Station.model_validate_json(serialized) + station2 = MaterializedStation.model_validate_json(serialized) assert station == station2 def test_load_asset_topology() -> None: - station1 = Station( + station1_assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="line2"), + SwitchableAsset(grid_model_id="line3"), + ] + station1 = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -345,16 +498,17 @@ def test_load_asset_topology() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=station1_assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) - station2 = Station( + station2_assets = [ + SwitchableAsset(grid_model_id="line4"), + SwitchableAsset(grid_model_id="line5"), + SwitchableAsset(grid_model_id="line6"), + ] + station2 = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar3"), Busbar(int_id=2, grid_model_id="busbar4"), @@ -362,19 +516,26 @@ def test_load_asset_topology() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler2"), ], - assets=[ - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - SwitchableAsset(grid_model_id="line6"), - ], - asset_switching_table=np.array([[True, False, True], [False, True, False]]), + branch_assets=station2_assets, + branch_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station2", ) - topology = Topology( + topology = topology_from_materialized_stations( + reference_topology=Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[ + BranchAsset(grid_model_id="line1"), + BranchAsset(grid_model_id="line2"), + BranchAsset(grid_model_id="line3"), + BranchAsset(grid_model_id="line4"), + BranchAsset(grid_model_id="line5"), + BranchAsset(grid_model_id="line6"), + ], + timestamp=datetime.now(), + ), stations=[station1, station2], - topology_id="topology1", - timestamp=datetime.now(), ) with tempfile.TemporaryDirectory() as tmpdirname: @@ -384,8 +545,346 @@ def test_load_asset_topology() -> None: assert topology == loaded_topology +def test_topology_extracts_assets_and_materializes_stations() -> None: + assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="load1"), + ] + asset_bays = [ + AssetBay( + asset_bay_id="station1::line1::bay", + dv_switch_grid_model_id="dv1", + sr_switch_grid_model_id={"busbar1": "sr1"}, + ), + None, + ] + station = make_materialized_station( + busbars=[ + Busbar(int_id=1, grid_model_id="busbar1"), + Busbar(int_id=2, grid_model_id="busbar2"), + ], + couplers=[ + BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), + ], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + branch_terminals=["from"], + branch_asset_bays=[asset_bays[0]], + branch_switching_table=np.array([[True], [False]]), + injection_assets=[InjectionAsset(grid_model_id="load1", asset_type="load")], + injection_terminals=[None], + injection_asset_bays=[asset_bays[1]], + injection_switching_table=np.array([[False], [True]]), + grid_model_id="station1", + ) + + topology = topology_from_materialized_stations( + reference_topology=Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + injection_assets=[InjectionAsset(grid_model_id="load1", asset_type="load")], + asset_bays=[asset_bays[0]], + timestamp=datetime.now(), + ), + stations=[station], + ) + + assert topology.raw_stations == [ + RawStation( + grid_model_id="station1", + busbars=station.busbars, + couplers=station.couplers, + branch_connections=raw_asset_connections(["line1"], ["from"], ["station1::line1::bay"]), + injection_connections=raw_asset_connections(["load1"], [None], [None]), + branch_switching_table=np.array([[True], [False]]), + injection_switching_table=np.array([[False], [True]]), + ) + ] + assert [asset.grid_model_id for asset in topology.branch_assets] == ["line1"] + assert [asset.grid_model_id for asset in topology.injection_assets] == ["load1"] + assert [asset_bay.asset_bay_id for asset_bay in topology.asset_bays] == ["station1::line1::bay"] + + materialized_station = topology.materialize_stations()[0] + assert materialized_station == station + + +def test_topology_from_materialized_stations_keeps_single_canonical_asset_for_two_station_views() -> None: + asset_from = BranchAsset(grid_model_id="line1", asset_type="line") + asset_to = BranchAsset(grid_model_id="line1", asset_type="line") + + station_from = make_materialized_station( + grid_model_id="station_from", + busbars=[Busbar(int_id=1, grid_model_id="busbar1")], + couplers=[], + branch_assets=[asset_from], + branch_terminals=["from"], + branch_switching_table=np.array([[True]]), + ) + station_to = make_materialized_station( + grid_model_id="station_to", + busbars=[Busbar(int_id=1, grid_model_id="busbar2")], + couplers=[], + branch_assets=[asset_to], + branch_terminals=["to"], + branch_switching_table=np.array([[True]]), + ) + + topology = topology_from_materialized_stations( + reference_topology=Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + timestamp=datetime.now(), + ), + stations=[station_from, station_to], + ) + + assert [asset.grid_model_id for asset in topology.branch_assets] == ["line1"] + assert [connection.asset_id for connection in topology.raw_stations[0].branch_connections] == ["line1"] + assert [connection.branch_end for connection in topology.raw_stations[0].branch_connections] == ["from"] + assert [connection.asset_id for connection in topology.raw_stations[1].branch_connections] == ["line1"] + assert [connection.branch_end for connection in topology.raw_stations[1].branch_connections] == ["to"] + assert topology.materialize_stations() == [station_from, station_to] + + +def test_topology_from_materialized_stations_normalizes_equivalent_branch_asset_payloads() -> None: + station_from = make_materialized_station( + grid_model_id="station_from", + busbars=[Busbar(int_id=1, grid_model_id="busbar1")], + couplers=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + branch_terminals=["from"], + branch_switching_table=np.array([[True]]), + ) + station_to = make_materialized_station( + grid_model_id="station_to", + busbars=[Busbar(int_id=1, grid_model_id="busbar2")], + couplers=[], + branch_assets=[SwitchableAsset(grid_model_id="line1", asset_type="line")], + branch_terminals=["to"], + branch_switching_table=np.array([[True]]), + ) + + topology = topology_from_materialized_stations( + reference_topology=Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + timestamp=datetime.now(), + ), + stations=[station_from, station_to], + ) + + assert len(topology.branch_assets) == 1 + assert isinstance(topology.branch_assets[0], BranchAsset) + assert topology.branch_assets[0].grid_model_id == "line1" + + +def test_topology_from_materialized_stations_reuses_reference_canonical_assets() -> None: + reference_topology = Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[ + BranchAsset(grid_model_id="line1", asset_type="line"), + BranchAsset(grid_model_id="line_unused", asset_type="line"), + ], + injection_assets=[InjectionAsset(grid_model_id="load1", asset_type="load")], + asset_bays=[ + AssetBay( + asset_bay_id="station1::line1::bay", + dv_switch_grid_model_id="dv1", + sr_switch_grid_model_id={"busbar1": "sr1"}, + ) + ], + timestamp=datetime.now(), + ) + station = make_materialized_station( + busbars=[Busbar(int_id=1, grid_model_id="busbar1")], + couplers=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + branch_terminals=["from"], + branch_asset_bays=[reference_topology.asset_bays[0]], + branch_switching_table=np.array([[True]]), + injection_assets=[InjectionAsset(grid_model_id="load1", asset_type="load")], + injection_switching_table=np.array([[True]]), + grid_model_id="station1", + ) + + topology = topology_from_materialized_stations(reference_topology=reference_topology, stations=[station]) + + assert [asset.grid_model_id for asset in topology.branch_assets] == ["line1", "line_unused"] + assert [asset.grid_model_id for asset in topology.injection_assets] == ["load1"] + assert [asset_bay.asset_bay_id for asset_bay in topology.asset_bays] == ["station1::line1::bay"] + assert topology.raw_stations[0].branch_connections[0].asset_id == "line1" + + +def test_topology_from_materialized_stations_raises_when_reference_assets_are_missing() -> None: + reference_topology = Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[], + injection_assets=[], + asset_bays=[], + timestamp=datetime.now(), + ) + station = make_materialized_station( + busbars=[Busbar(int_id=1, grid_model_id="busbar1")], + couplers=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + branch_switching_table=np.array([[True]]), + grid_model_id="station1", + ) + + with pytest.raises( + ValidationError, match="Branch asset grid_model_id line1 referenced by station station1 does not exist" + ): + topology_from_materialized_stations(reference_topology=reference_topology, stations=[station]) + + +def test_raw_station_model_copy_revalidates_updates() -> None: + station = make_raw_station( + grid_model_id="station1", + busbars=[Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2")], + couplers=[], + branch_asset_ids=["line1"], + branch_switching_table=np.array([[True], [False]]), + ) + + with pytest.raises(ValidationError, match="branch_switching_table shape"): + station.model_copy(update={"branch_switching_table": np.array([[True]], dtype=bool)}) + + +def test_raw_station_model_copy_honors_deep_flag_for_nested_models() -> None: + station = make_raw_station( + grid_model_id="station1", + busbars=[Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2")], + couplers=[], + branch_asset_ids=["line1"], + branch_switching_table=np.array([[True], [False]]), + ) + + shallow_copy = station.model_copy() + deep_copy = station.model_copy(deep=True) + + assert shallow_copy.busbars[0] is station.busbars[0] + assert deep_copy.busbars[0] is not station.busbars[0] + + +def test_topology_from_materialized_stations_scopes_generated_asset_bay_ids_per_station() -> None: + station_from = make_materialized_station( + grid_model_id="station_from", + busbars=[Busbar(int_id=1, grid_model_id="busbar1")], + couplers=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + branch_terminals=["from"], + branch_asset_bays=[ + AssetBay( + asset_bay_id=build_asset_bay_id("station_from", "line1"), + dv_switch_grid_model_id="dv_from", + sr_switch_grid_model_id={"busbar1": "sr_from"}, + ) + ], + branch_switching_table=np.array([[True]]), + ) + station_to = make_materialized_station( + grid_model_id="station_to", + busbars=[Busbar(int_id=1, grid_model_id="busbar2")], + couplers=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + branch_terminals=["to"], + branch_asset_bays=[ + AssetBay( + asset_bay_id=build_asset_bay_id("station_to", "line1"), + dv_switch_grid_model_id="dv_to", + sr_switch_grid_model_id={"busbar2": "sr_to"}, + ) + ], + branch_switching_table=np.array([[True]]), + ) + + topology = topology_from_materialized_stations( + reference_topology=Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + asset_bays=[ + station_from.branch_connections[0].asset_bay.model_copy(deep=True), + station_to.branch_connections[0].asset_bay.model_copy(deep=True), + ], + timestamp=datetime.now(), + ), + stations=[station_from, station_to], + ) + + assert sorted(asset_bay.asset_bay_id for asset_bay in topology.asset_bays) == [ + "station_from::line1::bay", + "station_to::line1::bay", + ] + assert [connection.asset_bay_id for connection in topology.raw_stations[0].branch_connections] == [ + "station_from::line1::bay" + ] + assert [connection.asset_bay_id for connection in topology.raw_stations[1].branch_connections] == [ + "station_to::line1::bay" + ] + + +def test_topology_from_materialized_stations_scopes_generated_asset_bay_ids_per_occurrence() -> None: + station = make_materialized_station( + grid_model_id="station1", + busbars=[Busbar(int_id=1, grid_model_id="busbar1")], + couplers=[], + branch_assets=[ + BranchAsset(grid_model_id="line1", asset_type="line"), + BranchAsset(grid_model_id="line1", asset_type="line"), + ], + branch_asset_bays=[ + AssetBay( + asset_bay_id=build_asset_bay_id("station1", "line1"), + dv_switch_grid_model_id="dv1", + sr_switch_grid_model_id={"busbar1": "sr1"}, + ), + AssetBay( + asset_bay_id=build_asset_bay_id("station1", "line1", 1), + dv_switch_grid_model_id="dv2", + sr_switch_grid_model_id={"busbar1": "sr2"}, + ), + ], + branch_switching_table=np.array([[True, True]]), + ) + + topology = topology_from_materialized_stations( + reference_topology=Topology( + topology_id="topology1", + raw_stations=[], + branch_assets=[BranchAsset(grid_model_id="line1", asset_type="line")], + asset_bays=[ + station.branch_connections[0].asset_bay.model_copy(deep=True), + station.branch_connections[1].asset_bay.model_copy(deep=True), + ], + timestamp=datetime.now(), + ), + stations=[station], + ) + + assert [connection.asset_id for connection in topology.raw_stations[0].branch_connections] == ["line1", "line1"] + assert [connection.asset_bay_id for connection in topology.raw_stations[0].branch_connections] == [ + "station1::line1::bay", + "station1::line1::bay::1", + ] + assert sorted(asset_bay.asset_bay_id for asset_bay in topology.asset_bays) == [ + "station1::line1::bay", + "station1::line1::bay::1", + ] + + def test_filter_out_of_service() -> None: - station = Station( + assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="line2", in_service=False), + SwitchableAsset(grid_model_id="line3"), + SwitchableAsset(grid_model_id="line4", in_service=False), + ] + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -395,13 +894,8 @@ def test_filter_out_of_service() -> None: BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=True, grid_model_id="coupler2"), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2", in_service=False), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4", in_service=False), - ], - asset_switching_table=np.array( + branch_assets=assets, + branch_switching_table=np.array( [ [True, False, True, True], [False, True, False, False], @@ -414,12 +908,12 @@ def test_filter_out_of_service() -> None: station = filter_out_of_service(station) assert len(station.busbars) == 2 assert len(station.couplers) == 1 - assert len(station.assets) == 2 - assert np.array_equal(station.asset_switching_table, np.array([[True, True], [False, False]])) + assert len(station.branch_connections) == 2 + assert np.array_equal(station.branch_switching_table, np.array([[True, True], [False, False]])) def test_has_transmission_line_switching() -> None: - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -429,13 +923,13 @@ def test_has_transmission_line_switching() -> None: BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=True, grid_model_id="coupler2"), ], - assets=[ + branch_assets=[ SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2", in_service=False), SwitchableAsset(grid_model_id="line3"), SwitchableAsset(grid_model_id="line4", in_service=False), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [True, False, True, False], [False, True, False, False], @@ -447,7 +941,7 @@ def test_has_transmission_line_switching() -> None: assert has_transmission_line_switching(station) is False - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -457,13 +951,13 @@ def test_has_transmission_line_switching() -> None: BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=True, grid_model_id="coupler2"), ], - assets=[ + branch_assets=[ SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), SwitchableAsset(grid_model_id="line4"), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [True, False, True, True], [False, False, False, False], @@ -477,7 +971,7 @@ def test_has_transmission_line_switching() -> None: def test_filter_duplicate_couplers() -> None: - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -489,13 +983,13 @@ def test_filter_duplicate_couplers() -> None: BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=True, grid_model_id="coupler3"), BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=True, grid_model_id="coupler4"), ], - assets=[ + branch_assets=[ SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), SwitchableAsset(grid_model_id="line4"), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [True, False, True, True], [False, True, False, False], @@ -520,7 +1014,7 @@ def test_filter_duplicate_couplers() -> None: def test_filter_disconnected_busbars() -> None: - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -529,13 +1023,13 @@ def test_filter_disconnected_busbars() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ + branch_assets=[ SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), SwitchableAsset(grid_model_id="line4"), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [True, False, True, True], [False, True, False, False], @@ -548,9 +1042,9 @@ def test_filter_disconnected_busbars() -> None: station, removed = filter_disconnected_busbars(station) assert len(station.busbars) == 2 assert len(station.couplers) == 1 - assert len(station.assets) == 4 + assert len(station.branch_connections) == 4 assert np.array_equal( - station.asset_switching_table, + station.branch_switching_table, np.array([[True, False, True, True], [False, True, False, False]]), ) assert len(removed) == 1 @@ -558,7 +1052,7 @@ def test_filter_disconnected_busbars() -> None: def test_filter_disconnected_busbars_sort_by_asset_count() -> None: - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -567,13 +1061,13 @@ def test_filter_disconnected_busbars_sort_by_asset_count() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ + branch_assets=[ SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), SwitchableAsset(grid_model_id="line4"), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [False, False, False, False], [False, True, False, False], @@ -587,9 +1081,9 @@ def test_filter_disconnected_busbars_sort_by_asset_count() -> None: assert len(station.busbars) == 1 assert station.busbars[0].grid_model_id == "busbar3" assert len(station.couplers) == 0 - assert len(station.assets) == 4 + assert len(station.branch_connections) == 4 assert np.array_equal( - station.asset_switching_table, + station.branch_switching_table, np.array([[True, False, True, True]]), ) assert len(removed) == 2 @@ -598,7 +1092,7 @@ def test_filter_disconnected_busbars_sort_by_asset_count() -> None: def test_select_one_for_multi_connected_assets() -> None: - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -608,13 +1102,13 @@ def test_select_one_for_multi_connected_assets() -> None: BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=False, grid_model_id="coupler2"), ], - assets=[ - SwitchableAsset(grid_model_id="line1"), # Connects busbar 1 and 3 + branch_assets=[ + SwitchableAsset(grid_model_id="line1"), SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), # Connects busbar 2 and 3 + SwitchableAsset(grid_model_id="line4"), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ [True, False, True, False], [False, True, False, True], @@ -625,9 +1119,9 @@ def test_select_one_for_multi_connected_assets() -> None: ) station, removed = fix_multi_connected_without_coupler(station) - assert station.asset_switching_table[:, 0].sum() == 1 + assert station.branch_switching_table[:, 0].sum() == 1 assert np.array_equal( - station.asset_switching_table, + station.branch_switching_table, np.array( [ [False, False, True, False], @@ -640,7 +1134,7 @@ def test_select_one_for_multi_connected_assets() -> None: def test_filter_assets_by_type() -> None: - station = Station( + station = make_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -648,41 +1142,53 @@ def test_filter_assets_by_type() -> None: couplers=[ BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), ], - assets=[ - SwitchableAsset(grid_model_id="line1", type="line"), - SwitchableAsset(grid_model_id="line2", type="line"), - SwitchableAsset(grid_model_id="gen1", type="gen"), - SwitchableAsset(grid_model_id="load1", type="load"), - SwitchableAsset(grid_model_id="line3", type=None), + branch_assets=[ + BranchAsset(grid_model_id="line1", asset_type="line"), + BranchAsset(grid_model_id="line2", asset_type="line"), + SwitchableAsset(grid_model_id="line3", asset_type=None), ], - asset_switching_table=np.array( + branch_switching_table=np.array( [ - [True, False, True, False, True], - [False, True, False, True, False], + [True, False, True], + [False, True, False], + ] + ), + injection_assets=[ + InjectionAsset(grid_model_id="gen1", asset_type="gen"), + InjectionAsset(grid_model_id="load1", asset_type="load"), + ], + injection_switching_table=np.array( + [ + [True, False], + [False, True], ] ), grid_model_id="station1", ) station_filtered, removed = filter_assets_by_type(station, set(["line", "trafo"])) - assert len(station_filtered.assets) == 2 + assert len(station_filtered.branch_connections) == 2 + assert len(station_filtered.injection_connections) == 0 assert len(removed) == 3 - assert station_filtered.assets[0].grid_model_id == "line1" - assert station_filtered.assets[1].grid_model_id == "line2" - assert station_filtered.asset_switching_table.shape == (2, 2) + assert station_filtered.branch_connections[0].asset.grid_model_id == "line1" + assert station_filtered.branch_connections[1].asset.grid_model_id == "line2" + assert station_filtered.branch_switching_table.shape == (2, 2) station_filtered, removed = filter_assets_by_type(station, set(["line", "gen"]), allow_none_type=True) - assert len(station_filtered.assets) == 4 + assert len(station_filtered.branch_connections) == 3 + assert len(station_filtered.injection_connections) == 1 assert len(removed) == 1 - assert station_filtered.assets[0].grid_model_id == "line1" - assert station_filtered.assets[1].grid_model_id == "line2" - assert station_filtered.assets[2].grid_model_id == "gen1" - assert station_filtered.assets[3].grid_model_id == "line3" + combined_connections = [*station_filtered.branch_connections, *station_filtered.injection_connections] + assert combined_connections[0].asset.grid_model_id == "line1" + assert combined_connections[1].asset.grid_model_id == "line2" + assert combined_connections[2].asset.grid_model_id == "line3" + assert combined_connections[3].asset.grid_model_id == "gen1" def test_asset_bay() -> None: # Test valid AssetBay path = AssetBay( + asset_bay_id="station1::line1::bay", sl_switch_grid_model_id="sl_switch_1", dv_switch_grid_model_id="dv_switch_1", sr_switch_grid_model_id={"busbar1": "sr_switch_1", "busbar2": "sr_switch_2"}, @@ -690,6 +1196,7 @@ def test_asset_bay() -> None: assert path is not None path = AssetBay( + asset_bay_id="station1::line2::bay", sl_switch_grid_model_id="sl_switch_1", dv_switch_grid_model_id="dv_switch_1", sr_switch_grid_model_id={"busbar1": "sr_switch_1", "busbar2": "sr_switch_2"}, @@ -700,6 +1207,7 @@ def test_asset_bay() -> None: # Test AssetBay with missing dv_switch_grid_model_id with pytest.raises(ValidationError, match="Field required"): path = AssetBay( + asset_bay_id="station1::line3::bay", sl_switch_grid_model_id="sl_switch_1", sr_switch_grid_model_id={"busbar1": "sr_switch_1", "busbar2": "sr_switch_2"}, ) @@ -707,6 +1215,7 @@ def test_asset_bay() -> None: # Test AssetBay with empty sr_switch_grid_model_id with pytest.raises(ValidationError, match="sr_switch_grid_model_id must not be empty"): path = AssetBay( + asset_bay_id="station1::line4::bay", sl_switch_grid_model_id="sl_switch_1", dv_switch_grid_model_id="dv_switch_1", sr_switch_grid_model_id={}, @@ -715,6 +1224,7 @@ def test_asset_bay() -> None: # Test AssetBay with invalid sr_switch_grid_model_id type with pytest.raises(ValidationError): path = AssetBay( + asset_bay_id="station1::line5::bay", sl_switch_grid_model_id="sl_switch_1", dv_switch_grid_model_id="dv_switch_1", sr_switch_grid_model_id={"busbar1": 123}, # Invalid type @@ -723,6 +1233,7 @@ def test_asset_bay() -> None: def test_station_bay() -> None: path = AssetBay( + asset_bay_id="station1::line2::bay", sl_switch_grid_model_id="sl_switch_1", dv_switch_grid_model_id="dv_switch_1", sr_switch_grid_model_id={"busbar1": "sr_switch_1", "busbar2": "sr_switch_2"}, @@ -737,49 +1248,52 @@ def test_station_bay() -> None: ] assets = [ SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2", asset_bay=path), + SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), ] asset_switching_table = np.array([[True, False, True], [False, True, False]]) grid_model_id = "station1" # test valid Station - station = Station( + station = make_materialized_station( busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=asset_switching_table, + branch_assets=assets, + branch_asset_bays=[None, path, None], + branch_switching_table=asset_switching_table, grid_model_id=grid_model_id, ) assert station is not None # Test invalid AssetBay -> busbar3 is not in busbars path_error = AssetBay( + asset_bay_id="station1::line2::bay", sl_switch_grid_model_id="sl_switch_1", dv_switch_grid_model_id="dv_switch_1", sr_switch_grid_model_id={"busbar1": "sr_switch_1", "busbar3": "sr_switch_2"}, ) assets = [ SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2", asset_bay=path_error), + SwitchableAsset(grid_model_id="line2"), SwitchableAsset(grid_model_id="line3"), ] with pytest.raises(ValidationError, match="busbar_id busbar3 in asset line2 does not exist in busbars"): - station = Station( + station = make_materialized_station( busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=asset_switching_table, + branch_assets=assets, + branch_asset_bays=[None, path_error, None], + branch_switching_table=asset_switching_table, grid_model_id=grid_model_id, ) def test_disambiguate_type() -> None: - asset = SwitchableAsset(grid_model_id="line", type=None) - assert asset.is_branch() is None + asset = normalize_switchable_asset_payload({"grid_model_id": "line", "asset_type": None}) + assert type(asset) is SwitchableAsset - asset = SwitchableAsset(grid_model_id="line", type="line") - assert asset.is_branch() is True + asset = normalize_switchable_asset_payload({"grid_model_id": "line", "asset_type": "line"}) + assert isinstance(asset, BranchAsset) - asset = SwitchableAsset(grid_model_id="gen", type="gen") - assert asset.is_branch() is False + asset = normalize_switchable_asset_payload({"grid_model_id": "gen", "asset_type": "gen"}) + assert isinstance(asset, InjectionAsset) diff --git a/packages/interfaces_pkg/tests/test_asset_topology_helpers.py b/packages/interfaces_pkg/tests/test_asset_topology_helpers.py index 6530e5972..567f0dd8c 100644 --- a/packages/interfaces_pkg/tests/test_asset_topology_helpers.py +++ b/packages/interfaces_pkg/tests/test_asset_topology_helpers.py @@ -9,21 +9,17 @@ import numpy as np import pytest -from toop_engine_interfaces.asset_topology import ( - Busbar, - BusbarCoupler, - Station, - SwitchableAsset, +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, Topology, ) -from toop_engine_interfaces.asset_topology_helpers import ( +from toop_engine_interfaces.asset_topology.asset_topology_helpers import ( filter_disconnected_busbars, filter_duplicate_couplers, filter_out_of_service, find_busbars_for_coupler, fuse_all_couplers_with_type, fuse_coupler, - get_connected_assets, has_transmission_line_switching, merge_stations, order_station_assets, @@ -32,10 +28,169 @@ station_diff, topology_diff, ) +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler, InjectionAsset, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection + + +def build_raw_station( + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + asset_ids: list[str], + asset_switching_table: np.ndarray, + injection_asset_ids: list[str] | None = None, + injection_switching_table: np.ndarray | None = None, + asset_terminals: list[str | None] | None = None, + asset_bay_ids: list[str | None] | None = None, +) -> RawStation: + """Build a raw station from explicit raw-topology fields. + + Parameters + ---------- + grid_model_id : str + Identifier of the station in the grid model. + busbars : list[Busbar] + Busbars belonging to the station. + couplers : list[BusbarCoupler] + Couplers belonging to the station. + asset_ids : list[str] + Grid model ids of the assets connected to the station. + asset_switching_table : np.ndarray + Busbar-to-asset switching matrix for the station. + asset_terminals : list[str | None] | None, optional + Optional branch-end metadata aligned with ``asset_ids``. + asset_bay_ids : list[str | None] | None, optional + Optional asset-bay metadata aligned with ``asset_ids``. + + Returns + ------- + RawStation + Raw station representation suitable for topology construction in tests. + """ + resolved_injection_asset_ids = injection_asset_ids if injection_asset_ids is not None else [] + resolved_injection_switching_table = ( + injection_switching_table + if injection_switching_table is not None + else np.zeros((len(busbars), len(resolved_injection_asset_ids)), dtype=bool) + ) + + return RawStation( + grid_model_id=grid_model_id, + name=None, + station_type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=couplers, + branch_connections=[ + StationAssetConnection(asset_id=asset_id, branch_end=asset_terminal, asset_bay_id=asset_bay_id) + for asset_id, asset_terminal, asset_bay_id in zip( + asset_ids, + asset_terminals if asset_terminals is not None else [None] * len(asset_ids), + asset_bay_ids if asset_bay_ids is not None else [None] * len(asset_ids), + strict=True, + ) + ], + injection_connections=[ + StationAssetConnection(asset_id=asset_id, branch_end=None, asset_bay_id=None) + for asset_id in resolved_injection_asset_ids + ], + branch_switching_table=asset_switching_table, + injection_switching_table=resolved_injection_switching_table, + branch_connectivity=None, + injection_connectivity=None, + model_log=None, + ) + + +def build_materialized_station( + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + assets: list[SwitchableAsset], + asset_switching_table: np.ndarray, + asset_connectivity: np.ndarray | None = None, + injection_assets: list[SwitchableAsset] | None = None, + injection_switching_table: np.ndarray | None = None, + injection_connectivity: np.ndarray | None = None, +) -> MaterializedStation: + """Build a materialized station using explicit asset connections.""" + resolved_injection_assets = injection_assets if injection_assets is not None else [] + resolved_injection_switching_table = ( + injection_switching_table + if injection_switching_table is not None + else np.zeros((len(busbars), len(resolved_injection_assets)), dtype=bool) + ) + resolved_injection_connectivity = injection_connectivity if injection_connectivity is not None else None + branch_assets = [ + asset if isinstance(asset, BranchAsset) else BranchAsset.model_validate(asset.model_dump()) for asset in assets + ] + normalized_injection_assets = [ + asset if isinstance(asset, InjectionAsset) else InjectionAsset.model_validate(asset.model_dump()) + for asset in resolved_injection_assets + ] + return MaterializedStation( + grid_model_id=grid_model_id, + name=None, + station_type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=couplers, + branch_connections=[MaterializedAssetConnection(asset=asset) for asset in branch_assets], + injection_connections=[MaterializedAssetConnection(asset=asset) for asset in normalized_injection_assets], + branch_switching_table=asset_switching_table, + injection_switching_table=resolved_injection_switching_table, + branch_connectivity=asset_connectivity, + injection_connectivity=resolved_injection_connectivity, + model_log=None, + ) + + +def build_test_topology(topology_id: str, raw_stations: list[RawStation], assets: list[SwitchableAsset]) -> Topology: + """Build a topology from raw stations and topology-owned assets. + + Parameters + ---------- + topology_id : str + Identifier of the topology. + raw_stations : list[RawStation] + Raw stations that define the topology layout. + assets : list[SwitchableAsset] + Topology-owned assets referenced by the raw stations. + + Returns + ------- + Topology + Topology instance for helper-focused tests. + """ + branch_assets = [ + asset if isinstance(asset, BranchAsset) else BranchAsset.model_validate(asset.model_dump()) + for asset in assets + if not isinstance(asset, InjectionAsset) + ] + injection_assets = [ + asset if isinstance(asset, InjectionAsset) else InjectionAsset.model_validate(asset.model_dump()) + for asset in assets + if isinstance(asset, InjectionAsset) + ] + + return Topology( + topology_id=topology_id, + grid_model_file=None, + name=None, + raw_stations=raw_stations, + branch_assets=branch_assets, + injection_assets=injection_assets, + asset_setpoints=None, + timestamp=datetime.now(), + metrics=None, + ) def test_merge_stations(): - station1 = Station( + station1 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -51,7 +206,7 @@ def test_merge_stations(): asset_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) - station2 = Station( + station2 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -82,7 +237,7 @@ def test_merge_stations(): ] } ), - station2.model_copy(update={"asset_switching_table": np.array([[False, True, True], [True, False, False]])}), + station2.model_copy(update={"branch_switching_table": np.array([[False, True, True], [True, False, False]])}), ] updated_stations, coupler_diff, reassignment_diff = merge_stations(original_stations, new_stations, "raise") @@ -94,14 +249,14 @@ def test_merge_stations(): assert updated_stations[1].grid_model_id == "station2" assert np.array_equal( - updated_stations[1].asset_switching_table, + updated_stations[1].branch_switching_table, np.array([[False, True, True], [True, False, False]]), ) assert set(reassignment_diff) == set([("station2", 1, 0, True), ("station2", 1, 1, False)]) def test_merge_stations_append_behavior(): - station1 = Station( + station1 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -117,7 +272,7 @@ def test_merge_stations_append_behavior(): asset_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) - station2 = Station( + station2 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -148,7 +303,7 @@ def test_merge_stations_append_behavior(): ] } ), - station2.model_copy(update={"asset_switching_table": np.array([[False, True, True], [True, False, False]])}), + station2.model_copy(update={"branch_switching_table": np.array([[False, True, True], [True, False, False]])}), ] updated_stations, coupler_diff, reassignment_diff = merge_stations(original_stations, new_stations, "append") @@ -160,14 +315,14 @@ def test_merge_stations_append_behavior(): assert updated_stations[1].grid_model_id == "station2" assert np.array_equal( - updated_stations[1].asset_switching_table, + updated_stations[1].branch_switching_table, np.array([[False, True, True], [True, False, False]]), ) assert reassignment_diff == [] def test_merge_stations_raise_behavior(): - station1 = Station( + station1 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -183,7 +338,7 @@ def test_merge_stations_raise_behavior(): asset_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) - station2 = Station( + station2 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -208,7 +363,7 @@ def test_merge_stations_raise_behavior(): def test_merge_stations_with_new_station_append(): - station1 = Station( + station1 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -224,7 +379,7 @@ def test_merge_stations_with_new_station_append(): asset_switching_table=np.array([[True, False, True], [False, True, False]]), grid_model_id="station1", ) - station2 = Station( + station2 = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -254,7 +409,7 @@ def test_merge_stations_with_new_station_append(): def test_get_connected_assets(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -272,22 +427,46 @@ def test_get_connected_assets(): ) # Test for busbar 0 - connected_assets_busbar_0 = get_connected_assets(station, 0) + connected_assets_busbar_0 = station.get_connected_assets(0) assert len(connected_assets_busbar_0) == 2 assert connected_assets_busbar_0[0].grid_model_id == "line1" assert connected_assets_busbar_0[1].grid_model_id == "line3" # Test for busbar 1 - connected_assets_busbar_1 = get_connected_assets(station, 1) + connected_assets_busbar_1 = station.get_connected_assets(1) assert len(connected_assets_busbar_1) == 0 # Test with no assets in service - station.assets[0].in_service = False - station.assets[2].in_service = False - connected_assets_busbar_0 = get_connected_assets(station, 0) + station.branch_connections[0].asset.in_service = False + station.branch_connections[2].asset.in_service = False + connected_assets_busbar_0 = station.get_connected_assets(0) assert len(connected_assets_busbar_0) == 0 +def test_get_connected_assets_raw_station_injection_scope(): + station = RawStation( + grid_model_id="station1", + busbars=[ + Busbar(int_id=1, grid_model_id="busbar1"), + Busbar(int_id=2, grid_model_id="busbar2"), + ], + couplers=[], + branch_connections=[StationAssetConnection(asset_id="line1", branch_end="from", asset_bay_id=None)], + injection_connections=[StationAssetConnection(asset_id="gen1", branch_end=None, asset_bay_id=None)], + branch_switching_table=np.array([[True], [False]]), + injection_switching_table=np.array([[False], [True]]), + ) + + connected_assets_busbar_1 = station.get_connected_assets( + 1, + topology_assets=[InjectionAsset(grid_model_id="gen1", in_service=True)], + asset_scope="injection", + ) + + assert len(connected_assets_busbar_1) == 1 + assert connected_assets_busbar_1[0].grid_model_id == "gen1" + + def test_find_busbars_for_coupler(): busbars = [ Busbar(int_id=1, grid_model_id="busbar1"), @@ -318,7 +497,7 @@ def test_find_busbars_for_coupler(): def test_station_diff_no_changes(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -338,12 +517,14 @@ def test_station_diff_no_changes(): assert realized_station.station == station assert realized_station.coupler_diff == [] - assert realized_station.reassignment_diff == [] - assert realized_station.disconnection_diff == [] + assert realized_station.branch_reassignment_diff == [] + assert realized_station.injection_reassignment_diff == [] + assert realized_station.branch_disconnection_diff == [] + assert realized_station.injection_disconnection_diff == [] def test_station_diff_coupler_state_change(): - start_station = Station( + start_station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -370,12 +551,14 @@ def test_station_diff_coupler_state_change(): assert realized_station.station == target_station assert realized_station.coupler_diff == [target_station.couplers[0]] - assert realized_station.reassignment_diff == [] - assert realized_station.disconnection_diff == [] + assert realized_station.branch_reassignment_diff == [] + assert realized_station.injection_reassignment_diff == [] + assert realized_station.branch_disconnection_diff == [] + assert realized_station.injection_disconnection_diff == [] def test_station_diff_asset_reassignment(): - start_station = Station( + start_station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -392,7 +575,7 @@ def test_station_diff_asset_reassignment(): ) target_station = start_station.model_copy( update={ - "asset_switching_table": np.array([[False, True], [True, False]]), + "branch_switching_table": np.array([[False, True], [True, False]]), } ) @@ -400,12 +583,14 @@ def test_station_diff_asset_reassignment(): assert realized_station.station == target_station assert realized_station.coupler_diff == [] - assert set(realized_station.reassignment_diff) == set([(0, 0, False), (0, 1, True), (1, 0, True), (1, 1, False)]) - assert realized_station.disconnection_diff == [] + assert set(realized_station.branch_reassignment_diff) == set([(0, 0, False), (0, 1, True), (1, 0, True), (1, 1, False)]) + assert realized_station.injection_reassignment_diff == [] + assert realized_station.branch_disconnection_diff == [] + assert realized_station.injection_disconnection_diff == [] def test_station_diff_asset_disconnection(): - start_station = Station( + start_station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -422,7 +607,7 @@ def test_station_diff_asset_disconnection(): ) target_station = start_station.model_copy( update={ - "asset_switching_table": np.array([[False, False], [False, True]]), + "branch_switching_table": np.array([[False, False], [False, True]]), } ) @@ -430,12 +615,14 @@ def test_station_diff_asset_disconnection(): assert realized_station.station == target_station assert realized_station.coupler_diff == [] - assert realized_station.reassignment_diff == [] - assert realized_station.disconnection_diff == [0] + assert realized_station.branch_reassignment_diff == [] + assert realized_station.injection_reassignment_diff == [] + assert realized_station.branch_disconnection_diff == [0] + assert realized_station.injection_disconnection_diff == [] def test_station_diff_unsupported_reconnection(): - start_station = Station( + start_station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -452,7 +639,7 @@ def test_station_diff_unsupported_reconnection(): ) target_station = start_station.model_copy( update={ - "asset_switching_table": np.array([[True, False], [False, True]]), + "branch_switching_table": np.array([[True, False], [False, True]]), } ) @@ -461,70 +648,114 @@ def test_station_diff_unsupported_reconnection(): def test_topology_diff() -> None: - start_station_1 = Station( - busbars=[ - Busbar(int_id=1, grid_model_id="busbar1"), - Busbar(int_id=2, grid_model_id="busbar2"), - ], - couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), - ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - ], + station1_busbars = [ + Busbar(int_id=1, grid_model_id="busbar1"), + Busbar(int_id=2, grid_model_id="busbar2"), + ] + station1_assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="line2"), + ] + station1_couplers = [ + BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), + ] + start_station_1 = build_materialized_station( + busbars=station1_busbars, + couplers=station1_couplers, + assets=station1_assets, asset_switching_table=np.array([[True, False], [False, True]]), grid_model_id="station1", ) # We perform reassignments and open the coupler target_station_1 = start_station_1.model_copy( update={ - "asset_switching_table": np.array([[False, True], [True, False]]), + "branch_switching_table": np.array([[False, True], [True, False]]), "couplers": [start_station_1.couplers[0].model_copy(update={"open": True})], } ) - start_station_2 = Station( - busbars=[ - Busbar(int_id=1, grid_model_id="busbar1"), - Busbar(int_id=2, grid_model_id="busbar2"), - Busbar(int_id=3, grid_model_id="busbar3"), - ], - couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), - BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=False, grid_model_id="coupler2"), - ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - ], + station2_busbars = [ + Busbar(int_id=1, grid_model_id="busbar1"), + Busbar(int_id=2, grid_model_id="busbar2"), + Busbar(int_id=3, grid_model_id="busbar3"), + ] + station2_assets = [ + SwitchableAsset(grid_model_id="station2_line1"), + SwitchableAsset(grid_model_id="station2_line2"), + ] + station2_couplers = [ + BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), + BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=False, grid_model_id="coupler2"), + ] + start_station_2 = build_materialized_station( + busbars=station2_busbars, + couplers=station2_couplers, + assets=station2_assets, asset_switching_table=np.array([[True, False], [False, True], [False, False]]), grid_model_id="station2", ) # We disconnect an asset target_station_2 = start_station_2.model_copy( update={ - "asset_switching_table": np.array([[False, False], [False, True], [False, False]]), + "branch_switching_table": np.array([[False, False], [False, True], [False, False]]), } ) - start_topology = Topology(topology_id="topology1", stations=[start_station_1, start_station_2], timestamp=datetime.now()) - target_topology = Topology( - topology_id="topology1", stations=[target_station_1, target_station_2], timestamp=datetime.now() + start_topology = build_test_topology( + "topology1", + raw_stations=[ + build_raw_station( + "station1", + station1_busbars, + station1_couplers, + ["line1", "line2"], + np.array([[True, False], [False, True]]), + ), + build_raw_station( + "station2", + station2_busbars, + station2_couplers, + ["station2_line1", "station2_line2"], + np.array([[True, False], [False, True], [False, False]]), + ), + ], + assets=station1_assets + station2_assets, + ) + target_topology = build_test_topology( + "topology1", + raw_stations=[ + build_raw_station( + "station1", + station1_busbars, + [station1_couplers[0].model_copy(update={"open": True})], + ["line1", "line2"], + np.array([[False, True], [True, False]]), + ), + build_raw_station( + "station2", + station2_busbars, + station2_couplers, + ["station2_line1", "station2_line2"], + np.array([[False, False], [False, True], [False, False]]), + ), + ], + assets=station1_assets + station2_assets, ) realized_topo = topology_diff(start_topology, target_topology) assert realized_topo.topology == target_topology assert realized_topo.coupler_diff == [("station1", target_station_1.couplers[0])] - assert set(realized_topo.reassignment_diff) == set( + assert set(realized_topo.branch_reassignment_diff) == set( [("station1", 0, 0, False), ("station1", 0, 1, True), ("station1", 1, 0, True), ("station1", 1, 1, False)] ) - assert realized_topo.disconnection_diff == [("station2", 0)] + assert realized_topo.injection_reassignment_diff == [] + assert realized_topo.branch_disconnection_diff == [("station2", 0)] + assert realized_topo.injection_disconnection_diff == [] def test_filter_out_of_service(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1", in_service=True), Busbar(int_id=2, grid_model_id="busbar2", in_service=False), @@ -548,14 +779,14 @@ def test_filter_out_of_service(): assert len(filtered_station.couplers) == 0 - assert len(filtered_station.assets) == 1 - assert filtered_station.assets[0].grid_model_id == "line1" + assert len(filtered_station.branch_connections) == 1 + assert filtered_station.branch_connections[0].asset.grid_model_id == "line1" - assert filtered_station.asset_switching_table.shape == (1, 1) + assert filtered_station.branch_switching_table.shape == (1, 1) def test_filter_disconnected_busbars(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -579,11 +810,11 @@ def test_filter_disconnected_busbars(): assert len(removed_busbars) == 1 assert removed_busbars[0].int_id == 3 - assert filtered_station.asset_switching_table.shape == (2, 1) + assert filtered_station.branch_switching_table.shape == (2, 1) def test_filter_disconnected_busbars_open_coupler(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -610,11 +841,11 @@ def test_filter_disconnected_busbars_open_coupler(): assert {busbar.int_id for busbar in filtered_station.busbars} == {1, 2} assert len(removed_busbars) == 1 assert removed_busbars[0].int_id == 3 - assert filtered_station.asset_switching_table.shape == (2, 1) + assert filtered_station.branch_switching_table.shape == (2, 1) def test_reindex_busbars(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=10, grid_model_id="busbar1"), Busbar(int_id=20, grid_model_id="busbar2"), @@ -641,7 +872,7 @@ def test_reindex_busbars(): def test_has_transmission_line_switching(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -657,12 +888,12 @@ def test_has_transmission_line_switching(): assert has_transmission_line_switching(station) is True - station.asset_switching_table = np.array([[True, False], [False, True]]) + station.branch_switching_table = np.array([[True, False], [False, True]]) assert has_transmission_line_switching(station) is False def test_order_station_assets() -> None: - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -695,10 +926,10 @@ def test_order_station_assets() -> None: assert not_found == [] assert ignored == ["line2"] - assert len(ordered.assets) == len(desired_order) - assert [asset.grid_model_id for asset in ordered.assets] == desired_order + assert len(ordered.branch_connections) == len(desired_order) + assert [asset_connection.asset.grid_model_id for asset_connection in ordered.branch_connections] == desired_order assert np.array_equal( - ordered.asset_switching_table, + ordered.branch_switching_table, np.array( [ [False, False, True, True], @@ -713,8 +944,8 @@ def test_order_station_assets() -> None: assert not_found == ["pink_unicorn"] assert ignored == ["line2"] - assert len(ordered.assets) == len(desired_order) - 1 - assert [asset.grid_model_id for asset in ordered.assets] == [ + assert len(ordered.branch_connections) == len(desired_order) - 1 + assert [asset_connection.asset.grid_model_id for asset_connection in ordered.branch_connections] == [ "line5", "line4", "line3", @@ -723,59 +954,89 @@ def test_order_station_assets() -> None: def test_order_topology() -> None: - station = station = Station( - busbars=[ - Busbar(int_id=1, grid_model_id="busbar1"), - Busbar(int_id=2, grid_model_id="busbar2"), - ], - couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), - ], - assets=[ - SwitchableAsset(grid_model_id="line1"), - SwitchableAsset(grid_model_id="line2"), - SwitchableAsset(grid_model_id="line3"), - SwitchableAsset(grid_model_id="line4"), - SwitchableAsset(grid_model_id="line5"), - ], - asset_switching_table=np.array( - [ - [True, False, True, False, False], - [False, False, False, True, True], - ] - ), - grid_model_id="station1", + busbars = [ + Busbar(int_id=1, grid_model_id="busbar1"), + Busbar(int_id=2, grid_model_id="busbar2"), + ] + couplers = [ + BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1"), + ] + switching_table = np.array( + [ + [True, False, True, False, False], + [False, False, False, True, True], + ] ) - stations = [ - station, - station.model_copy(update={"grid_model_id": "station2"}), - station.model_copy(update={"grid_model_id": "station3"}), - station.model_copy(update={"grid_model_id": "station4"}), + raw_stations = [ + build_raw_station("station1", busbars, couplers, ["line1", "line2", "line3", "line4", "line5"], switching_table), + build_raw_station( + "station2", + busbars, + couplers, + ["station2_line1", "station2_line2", "station2_line3", "station2_line4", "station2_line5"], + switching_table, + ), + build_raw_station( + "station3", + busbars, + couplers, + ["station3_line1", "station3_line2", "station3_line3", "station3_line4", "station3_line5"], + switching_table, + ), + build_raw_station( + "station4", + busbars, + couplers, + ["station4_line1", "station4_line2", "station4_line3", "station4_line4", "station4_line5"], + switching_table, + ), ] - topology = Topology( - topology_id="topo-popo", - stations=stations, - timestamp=datetime.now(), + assets = [ + SwitchableAsset(grid_model_id="line1"), + SwitchableAsset(grid_model_id="line2"), + SwitchableAsset(grid_model_id="line3"), + SwitchableAsset(grid_model_id="line4"), + SwitchableAsset(grid_model_id="line5"), + SwitchableAsset(grid_model_id="station2_line1"), + SwitchableAsset(grid_model_id="station2_line2"), + SwitchableAsset(grid_model_id="station2_line3"), + SwitchableAsset(grid_model_id="station2_line4"), + SwitchableAsset(grid_model_id="station2_line5"), + SwitchableAsset(grid_model_id="station3_line1"), + SwitchableAsset(grid_model_id="station3_line2"), + SwitchableAsset(grid_model_id="station3_line3"), + SwitchableAsset(grid_model_id="station3_line4"), + SwitchableAsset(grid_model_id="station3_line5"), + SwitchableAsset(grid_model_id="station4_line1"), + SwitchableAsset(grid_model_id="station4_line2"), + SwitchableAsset(grid_model_id="station4_line3"), + SwitchableAsset(grid_model_id="station4_line4"), + SwitchableAsset(grid_model_id="station4_line5"), + ] + topology = build_test_topology( + "topo-popo", + raw_stations=raw_stations, + assets=assets, ) ordered, not_found = order_topology(topology, ["station4", "station2", "station1", "station3"]) - station_ids = [station.grid_model_id for station in ordered.stations] + station_ids = [station.grid_model_id for station in ordered.raw_stations] assert station_ids == ["station4", "station2", "station1", "station3"] assert not_found == [] ordered, not_found = order_topology(topology, ["station4", "station2", "station1", "station3", "station5"]) - station_ids = [station.grid_model_id for station in ordered.stations] + station_ids = [station.grid_model_id for station in ordered.raw_stations] assert station_ids == ["station4", "station2", "station1", "station3"] assert not_found == ["station5"] ordered, not_found = order_topology(topology, ["station4", "station2", "station1"]) - station_ids = [station.grid_model_id for station in ordered.stations] + station_ids = [station.grid_model_id for station in ordered.raw_stations] assert station_ids == ["station4", "station2", "station1"] assert not_found == [] def test_fuse_coupler(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -807,8 +1068,8 @@ def test_fuse_coupler(): assert len(fused_station.couplers) == 1 assert fused_station.couplers[0].grid_model_id == "coupler2" expected_switching_table = np.array([[True, True, True, False], [False, False, False, True]]) - assert np.array_equal(fused_station.asset_switching_table, expected_switching_table) - assert np.array_equal(fused_station.asset_connectivity, expected_switching_table) + assert np.array_equal(fused_station.branch_switching_table, expected_switching_table) + assert np.array_equal(fused_station.branch_connectivity, expected_switching_table) # Test fusing the coupler with copy_info_from=False fused_station = fuse_coupler(station, "coupler1", copy_info_from=False) @@ -818,8 +1079,8 @@ def test_fuse_coupler(): assert fused_station.busbars[1].grid_model_id == "busbar3" assert len(fused_station.couplers) == 1 assert fused_station.couplers[0].grid_model_id == "coupler2" - assert np.array_equal(fused_station.asset_switching_table, expected_switching_table) - assert np.array_equal(fused_station.asset_connectivity, expected_switching_table) + assert np.array_equal(fused_station.branch_switching_table, expected_switching_table) + assert np.array_equal(fused_station.branch_connectivity, expected_switching_table) # Test fusing the other coupler @@ -830,8 +1091,8 @@ def test_fuse_coupler(): assert len(fused_station.couplers) == 1 assert fused_station.couplers[0].grid_model_id == "coupler1" expected_switching_table = np.array([[True, False, False, False], [False, True, True, True]]) - assert np.array_equal(fused_station.asset_switching_table, expected_switching_table) - assert np.array_equal(fused_station.asset_connectivity, expected_switching_table) + assert np.array_equal(fused_station.branch_switching_table, expected_switching_table) + assert np.array_equal(fused_station.branch_connectivity, expected_switching_table) fused_station = fuse_coupler(station, "coupler2", copy_info_from=False) assert len(fused_station.busbars) == 2 @@ -840,8 +1101,8 @@ def test_fuse_coupler(): assert len(fused_station.couplers) == 1 assert fused_station.couplers[0].grid_model_id == "coupler1" expected_switching_table = np.array([[True, False, False, False], [False, True, True, True]]) - assert np.array_equal(fused_station.asset_switching_table, expected_switching_table) - assert np.array_equal(fused_station.asset_connectivity, expected_switching_table) + assert np.array_equal(fused_station.branch_switching_table, expected_switching_table) + assert np.array_equal(fused_station.branch_connectivity, expected_switching_table) # Test fusing a non-existent coupler with pytest.raises(ValueError, match="Coupler invalid_coupler not found in station station1"): @@ -849,7 +1110,7 @@ def test_fuse_coupler(): def test_fuse_all_couplers_with_type(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -858,11 +1119,41 @@ def test_fuse_all_couplers_with_type(): Busbar(int_id=5, grid_model_id="busbar5"), ], couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1", type="BREAKER"), - BusbarCoupler(busbar_from_id=2, busbar_to_id=3, open=False, grid_model_id="coupler2", type="BREAKER"), - BusbarCoupler(busbar_from_id=3, busbar_to_id=4, open=False, grid_model_id="coupler3", type="DISCONNECTOR"), - BusbarCoupler(busbar_from_id=4, busbar_to_id=5, open=False, grid_model_id="coupler4", type="BREAKER"), - BusbarCoupler(busbar_from_id=5, busbar_to_id=4, open=False, grid_model_id="coupler5", type="DISCONNECTOR"), + BusbarCoupler( + busbar_from_id=1, + busbar_to_id=2, + open=False, + grid_model_id="coupler1", + coupler_type="BREAKER", + ), + BusbarCoupler( + busbar_from_id=2, + busbar_to_id=3, + open=False, + grid_model_id="coupler2", + coupler_type="BREAKER", + ), + BusbarCoupler( + busbar_from_id=3, + busbar_to_id=4, + open=False, + grid_model_id="coupler3", + coupler_type="DISCONNECTOR", + ), + BusbarCoupler( + busbar_from_id=4, + busbar_to_id=5, + open=False, + grid_model_id="coupler4", + coupler_type="BREAKER", + ), + BusbarCoupler( + busbar_from_id=5, + busbar_to_id=4, + open=False, + grid_model_id="coupler5", + coupler_type="DISCONNECTOR", + ), ], assets=[ SwitchableAsset(grid_model_id="line1"), @@ -906,8 +1197,8 @@ def test_fuse_all_couplers_with_type(): [False, False, False], ] ) - assert np.array_equal(fused_station.asset_switching_table, expected_switching_table) - assert np.array_equal(fused_station.asset_connectivity, expected_switching_table) + assert np.array_equal(fused_station.branch_switching_table, expected_switching_table) + assert np.array_equal(fused_station.branch_connectivity, expected_switching_table) # Test fusing all couplers of type DISCONNECTOR fused_station, fused_couplers = fuse_all_couplers_with_type(station, "DISCONNECTOR", copy_info_from=True) @@ -928,7 +1219,7 @@ def test_fuse_all_couplers_with_type(): def test_filter_duplicate_couplers(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -954,14 +1245,26 @@ def test_filter_duplicate_couplers(): def test_filter_duplicate_couplers_with_type_hierarchy(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), ], couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1", type="DISCONNECTOR"), - BusbarCoupler(busbar_from_id=2, busbar_to_id=1, open=False, grid_model_id="coupler2", type="BREAKER"), + BusbarCoupler( + busbar_from_id=1, + busbar_to_id=2, + open=False, + grid_model_id="coupler1", + coupler_type="DISCONNECTOR", + ), + BusbarCoupler( + busbar_from_id=2, + busbar_to_id=1, + open=False, + grid_model_id="coupler2", + coupler_type="BREAKER", + ), ], assets=[ SwitchableAsset(grid_model_id="line1"), @@ -977,11 +1280,11 @@ def test_filter_duplicate_couplers_with_type_hierarchy(): assert len(filtered_station.couplers) == 1 assert filtered_station.couplers[0].grid_model_id == "coupler2" - assert filtered_station.couplers[0].type == "BREAKER" + assert filtered_station.couplers[0].coupler_type == "BREAKER" assert len(removed_couplers) == 1 assert removed_couplers[0].grid_model_id == "coupler1" - assert removed_couplers[0].type == "DISCONNECTOR" + assert removed_couplers[0].coupler_type == "DISCONNECTOR" # Test with reversed hierarchy where DISCONNECTOR is preferred filtered_station, removed_couplers = filter_duplicate_couplers( @@ -990,15 +1293,15 @@ def test_filter_duplicate_couplers_with_type_hierarchy(): assert len(filtered_station.couplers) == 1 assert filtered_station.couplers[0].grid_model_id == "coupler1" - assert filtered_station.couplers[0].type == "DISCONNECTOR" + assert filtered_station.couplers[0].coupler_type == "DISCONNECTOR" assert len(removed_couplers) == 1 assert removed_couplers[0].grid_model_id == "coupler2" - assert removed_couplers[0].type == "BREAKER" + assert removed_couplers[0].coupler_type == "BREAKER" def test_filter_duplicate_couplers_no_duplicates(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), @@ -1022,14 +1325,26 @@ def test_filter_duplicate_couplers_no_duplicates(): def test_filter_duplicate_couplers_with_unknown_type(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), ], couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1", type="KNOWN"), - BusbarCoupler(busbar_from_id=2, busbar_to_id=1, open=False, grid_model_id="coupler2", type="UNKNOWN"), + BusbarCoupler( + busbar_from_id=1, + busbar_to_id=2, + open=False, + grid_model_id="coupler1", + coupler_type="KNOWN", + ), + BusbarCoupler( + busbar_from_id=2, + busbar_to_id=1, + open=False, + grid_model_id="coupler2", + coupler_type="UNKNOWN", + ), ], assets=[ SwitchableAsset(grid_model_id="line1"), @@ -1043,23 +1358,41 @@ def test_filter_duplicate_couplers_with_unknown_type(): assert len(filtered_station.couplers) == 1 assert filtered_station.couplers[0].grid_model_id == "coupler1" - assert filtered_station.couplers[0].type == "KNOWN" + assert filtered_station.couplers[0].coupler_type == "KNOWN" assert len(removed_couplers) == 1 assert removed_couplers[0].grid_model_id == "coupler2" - assert removed_couplers[0].type == "UNKNOWN" + assert removed_couplers[0].coupler_type == "UNKNOWN" def test_filter_duplicate_couplers_multiple_duplicates(): - station = Station( + station = build_materialized_station( busbars=[ Busbar(int_id=1, grid_model_id="busbar1"), Busbar(int_id=2, grid_model_id="busbar2"), ], couplers=[ - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=False, grid_model_id="coupler1", type="TYPE_A"), - BusbarCoupler(busbar_from_id=2, busbar_to_id=1, open=False, grid_model_id="coupler2", type="TYPE_B"), - BusbarCoupler(busbar_from_id=1, busbar_to_id=2, open=True, grid_model_id="coupler3", type="TYPE_C"), + BusbarCoupler( + busbar_from_id=1, + busbar_to_id=2, + open=False, + grid_model_id="coupler1", + coupler_type="TYPE_A", + ), + BusbarCoupler( + busbar_from_id=2, + busbar_to_id=1, + open=False, + grid_model_id="coupler2", + coupler_type="TYPE_B", + ), + BusbarCoupler( + busbar_from_id=1, + busbar_to_id=2, + open=True, + grid_model_id="coupler3", + coupler_type="TYPE_C", + ), ], assets=[ SwitchableAsset(grid_model_id="line1"), @@ -1075,7 +1408,7 @@ def test_filter_duplicate_couplers_multiple_duplicates(): assert len(filtered_station.couplers) == 1 assert filtered_station.couplers[0].grid_model_id == "coupler2" - assert filtered_station.couplers[0].type == "TYPE_B" + assert filtered_station.couplers[0].coupler_type == "TYPE_B" assert len(removed_couplers) == 2 assert set(c.grid_model_id for c in removed_couplers) == {"coupler1", "coupler3"} diff --git a/packages/interfaces_pkg/tests/test_filesystem_helpers.py b/packages/interfaces_pkg/tests/test_filesystem_helpers.py index e3c4d08d3..88827285c 100644 --- a/packages/interfaces_pkg/tests/test_filesystem_helpers.py +++ b/packages/interfaces_pkg/tests/test_filesystem_helpers.py @@ -10,7 +10,7 @@ import numpy as np import pytest from fsspec.implementations.dirfs import DirFileSystem -from toop_engine_interfaces.asset_topology import Busbar +from toop_engine_interfaces.asset_topology.assets import Busbar from toop_engine_interfaces.filesystem_helper import ( copy_file_fs, load_numpy_filesystem, diff --git a/packages/interfaces_pkg/tests/test_stored_action_set.py b/packages/interfaces_pkg/tests/test_stored_action_set.py index ee23761b6..1d5608fc1 100644 --- a/packages/interfaces_pkg/tests/test_stored_action_set.py +++ b/packages/interfaces_pkg/tests/test_stored_action_set.py @@ -12,7 +12,13 @@ import pytest from fsspec.implementations.dirfs import DirFileSystem from pydantic import ValidationError -from toop_engine_interfaces.asset_topology import Busbar, BusbarCoupler, Station, SwitchableAsset, Topology +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, + Topology, +) +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler, InjectionAsset, SwitchableAsset +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection from toop_engine_interfaces.stored_action_set import ( ActionSet, StationDiffArray, @@ -27,6 +33,116 @@ ) +def build_raw_station( + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + asset_ids: list[str], + asset_switching_table: np.ndarray, + injection_asset_ids: list[str] | None = None, + injection_switching_table: np.ndarray | None = None, + asset_terminals: list[str | None] | None = None, + asset_bay_ids: list[str | None] | None = None, +) -> RawStation: + """Build a raw station from explicit raw-topology fields. + + Parameters + ---------- + grid_model_id : str + Identifier of the station in the grid model. + busbars : list[Busbar] + Busbars belonging to the station. + couplers : list[BusbarCoupler] + Couplers belonging to the station. + asset_ids : list[str] + Grid model ids of the assets connected to the station. + asset_switching_table : np.ndarray + Busbar-to-asset switching matrix for the station. + asset_terminals : list[str | None] | None, optional + Optional branch-end metadata aligned with ``asset_ids``. + asset_bay_ids : list[str | None] | None, optional + Optional asset-bay metadata aligned with ``asset_ids``. + + Returns + ------- + RawStation + Raw station representation suitable for topology construction in tests. + """ + resolved_injection_asset_ids = injection_asset_ids if injection_asset_ids is not None else [] + resolved_injection_switching_table = ( + injection_switching_table + if injection_switching_table is not None + else np.zeros((len(busbars), len(resolved_injection_asset_ids)), dtype=bool) + ) + + return RawStation( + grid_model_id=grid_model_id, + name=None, + type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=couplers, + branch_connections=[ + StationAssetConnection(asset_id=asset_id, branch_end=asset_terminal, asset_bay_id=asset_bay_id) + for asset_id, asset_terminal, asset_bay_id in zip( + asset_ids, + asset_terminals if asset_terminals is not None else [None] * len(asset_ids), + asset_bay_ids if asset_bay_ids is not None else [None] * len(asset_ids), + strict=True, + ) + ], + injection_connections=[ + StationAssetConnection(asset_id=asset_id, branch_end=None, asset_bay_id=None) + for asset_id in resolved_injection_asset_ids + ], + branch_switching_table=asset_switching_table, + injection_switching_table=resolved_injection_switching_table, + branch_connectivity=None, + injection_connectivity=None, + model_log=None, + ) + + +def build_materialized_station( + grid_model_id: str, + busbars: list[Busbar], + couplers: list[BusbarCoupler], + assets: list[SwitchableAsset], + asset_switching_table: np.ndarray, + injection_assets: list[SwitchableAsset] | None = None, + injection_switching_table: np.ndarray | None = None, +) -> MaterializedStation: + """Build a materialized station using split branch/injection fields.""" + resolved_injection_assets = injection_assets if injection_assets is not None else [] + resolved_injection_switching_table = ( + injection_switching_table + if injection_switching_table is not None + else np.zeros((len(busbars), len(resolved_injection_assets)), dtype=bool) + ) + return MaterializedStation.model_construct( + grid_model_id=grid_model_id, + name=None, + type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=couplers, + branch_connections=[ + MaterializedAssetConnection.model_construct(asset=asset, branch_end=None, asset_bay=None) for asset in assets + ], + injection_connections=[ + MaterializedAssetConnection.model_construct(asset=asset, branch_end=None, asset_bay=None) + for asset in resolved_injection_assets + ], + branch_switching_table=asset_switching_table, + injection_switching_table=resolved_injection_switching_table, + branch_connectivity=None, + injection_connectivity=None, + model_log=None, + ) + + class DummyStation: def __init__(self, grid_model_id): self.grid_model_id = str(grid_model_id) @@ -109,12 +225,18 @@ def test_store_and_load_station_diff_io_random_roundtrip(tmp_path: Path): n_actions = int(rng.integers(1, 8)) n_couplers = int(rng.integers(1, 6)) n_busbars = int(rng.integers(1, 6)) - n_assets = int(rng.integers(1, 10)) + n_branch_assets = int(rng.integers(1, 10)) + n_injection_assets = int(rng.integers(0, 6)) station_diffs.append( StationDiffArray( grid_model_id=f"station_{station_idx}", coupler_open=rng.integers(0, 2, size=(n_actions, n_couplers), dtype=np.uint8).astype(bool), - switching_table=rng.integers(0, 2, size=(n_actions, n_busbars, n_assets), dtype=np.uint8).astype(bool), + branch_switching_table=rng.integers( + 0, 2, size=(n_actions, n_busbars, n_branch_assets), dtype=np.uint8 + ).astype(bool), + injection_switching_table=rng.integers( + 0, 2, size=(n_actions, n_busbars, n_injection_assets), dtype=np.uint8 + ).astype(bool), ) ) @@ -129,9 +251,11 @@ def test_store_and_load_station_diff_io_random_roundtrip(tmp_path: Path): result = loaded_by_id[original.grid_model_id] assert result.grid_model_id == original.grid_model_id assert result.coupler_open.dtype == bool - assert result.switching_table.dtype == bool + assert result.branch_switching_table.dtype == bool + assert result.injection_switching_table.dtype == bool assert np.array_equal(result.coupler_open, original.coupler_open) - assert np.array_equal(result.switching_table, original.switching_table) + assert np.array_equal(result.branch_switching_table, original.branch_switching_table) + assert np.array_equal(result.injection_switching_table, original.injection_switching_table) def test_store_and_load_station_diff_io_empty_list(tmp_path: Path): @@ -146,7 +270,8 @@ def test_station_diff_array_raises_for_mismatched_action_count() -> None: StationDiffArray( grid_model_id="station_1", coupler_open=np.zeros((5, 1), dtype=bool), - switching_table=np.zeros((10, 2, 7), dtype=bool), + branch_switching_table=np.zeros((10, 2, 7), dtype=bool), + injection_switching_table=np.zeros((10, 2, 0), dtype=bool), ) @@ -156,12 +281,14 @@ def test_store_and_load_station_diff_io_supports_different_station_action_counts StationDiffArray( grid_model_id="station_1", coupler_open=np.zeros((5, 1), dtype=bool), - switching_table=np.zeros((5, 2, 7), dtype=bool), + branch_switching_table=np.zeros((5, 2, 7), dtype=bool), + injection_switching_table=np.zeros((5, 2, 1), dtype=bool), ), StationDiffArray( grid_model_id="station_2", coupler_open=np.zeros((10, 2), dtype=bool), - switching_table=np.zeros((10, 3, 4), dtype=bool), + branch_switching_table=np.zeros((10, 3, 4), dtype=bool), + injection_switching_table=np.zeros((10, 3, 2), dtype=bool), ), ] @@ -172,7 +299,8 @@ def test_store_and_load_station_diff_io_supports_different_station_action_counts ("station_1", (5, 1)), ("station_2", (10, 2)), ] - assert [station_diff.switching_table.shape for station_diff in loaded] == [(5, 2, 7), (10, 3, 4)] + assert [station_diff.branch_switching_table.shape for station_diff in loaded] == [(5, 2, 7), (10, 3, 4)] + assert [station_diff.injection_switching_table.shape for station_diff in loaded] == [(5, 2, 1), (10, 3, 2)] def test_store_and_load_station_diff_io_preserves_station_order(tmp_path: Path) -> None: @@ -181,17 +309,20 @@ def test_store_and_load_station_diff_io_preserves_station_order(tmp_path: Path) StationDiffArray( grid_model_id="station_10", coupler_open=np.zeros((1, 1), dtype=bool), - switching_table=np.zeros((1, 1, 1), dtype=bool), + branch_switching_table=np.zeros((1, 1, 1), dtype=bool), + injection_switching_table=np.zeros((1, 1, 0), dtype=bool), ), StationDiffArray( grid_model_id="station_2", coupler_open=np.zeros((1, 1), dtype=bool), - switching_table=np.zeros((1, 1, 1), dtype=bool), + branch_switching_table=np.zeros((1, 1, 1), dtype=bool), + injection_switching_table=np.zeros((1, 1, 0), dtype=bool), ), StationDiffArray( grid_model_id="station_1", coupler_open=np.zeros((1, 1), dtype=bool), - switching_table=np.zeros((1, 1, 1), dtype=bool), + branch_switching_table=np.zeros((1, 1, 1), dtype=bool), + injection_switching_table=np.zeros((1, 1, 0), dtype=bool), ), ] @@ -206,7 +337,7 @@ def test_store_and_load_station_diff_io_preserves_station_order(tmp_path: Path) def test_validate_actions_grouped_accepts_grouped_actions(): - station_s1 = Station.model_construct( + station_s1 = MaterializedStation.model_construct( grid_model_id="s1", name=None, type=None, @@ -214,9 +345,12 @@ def test_validate_actions_grouped_accepts_grouped_actions(): voltage_level=None, busbars=[], couplers=[], - assets=[], - asset_switching_table=np.zeros((0, 0), dtype=bool), - asset_connectivity=None, + branch_connections=[], + injection_connections=[], + branch_switching_table=np.zeros((0, 0), dtype=bool), + injection_switching_table=np.zeros((0, 0), dtype=bool), + branch_connectivity=None, + injection_connectivity=None, model_log=None, ) station_s2 = station_s1.model_copy(update={"grid_model_id": "s2"}) @@ -227,7 +361,7 @@ def test_validate_actions_grouped_accepts_grouped_actions(): def test_validate_actions_grouped_raises_for_non_grouped_actions(): - station_s1 = Station.model_construct( + station_s1 = MaterializedStation.model_construct( grid_model_id="s1", name=None, type=None, @@ -235,9 +369,12 @@ def test_validate_actions_grouped_raises_for_non_grouped_actions(): voltage_level=None, busbars=[], couplers=[], - assets=[], - asset_switching_table=np.zeros((0, 0), dtype=bool), - asset_connectivity=None, + branch_connections=[], + injection_connections=[], + branch_switching_table=np.zeros((0, 0), dtype=bool), + injection_switching_table=np.zeros((0, 0), dtype=bool), + branch_connectivity=None, + injection_connectivity=None, model_log=None, ) station_s2 = station_s1.model_copy(update={"grid_model_id": "s2"}) @@ -259,17 +396,15 @@ def test_action_set_model_validator_rejects_non_grouped_local_actions(): ) ] assets = [ - SwitchableAsset.model_construct( + BranchAsset.model_construct( grid_model_id="station_a_asset_0", type=None, name=None, in_service=True, - branch_end=None, - asset_bay=None, ) ] - station_a = Station.model_construct( + station_a = MaterializedStation.model_construct( grid_model_id="station_a", name=None, type=None, @@ -277,18 +412,73 @@ def test_action_set_model_validator_rejects_non_grouped_local_actions(): voltage_level=None, busbars=busbars, couplers=[], - assets=assets, - asset_switching_table=np.zeros((1, 1), dtype=bool), - asset_connectivity=None, + branch_connections=[MaterializedAssetConnection.model_construct(asset=assets[0], branch_end=None, asset_bay=None)], + injection_connections=[], + branch_switching_table=np.zeros((1, 1), dtype=bool), + injection_switching_table=np.zeros((1, 0), dtype=bool), + branch_connectivity=None, + injection_connectivity=None, model_log=None, ) - station_b = station_a.model_copy(update={"grid_model_id": "station_b"}) + station_b = station_a.model_copy( + update={ + "grid_model_id": "station_b", + "branch_connections": [ + station_a.branch_connections[0].model_copy( + update={ + "asset": station_a.branch_connections[0].asset.model_copy( + update={"grid_model_id": "station_b_asset_0"} + ) + } + ) + ], + } + ) - starting_topology = Topology.model_construct( + starting_topology = Topology( topology_id="starting_topology", grid_model_file=None, name=None, - stations=[station_a, station_b], + raw_stations=[ + RawStation( + grid_model_id="station_a", + name=None, + type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=[], + branch_connections=[ + StationAssetConnection(asset_id="station_a_asset_0", branch_end=None, asset_bay_id=None) + ], + injection_connections=[], + branch_switching_table=np.zeros((1, 1), dtype=bool), + injection_switching_table=np.zeros((1, 0), dtype=bool), + branch_connectivity=None, + injection_connectivity=None, + model_log=None, + ), + RawStation( + grid_model_id="station_b", + name=None, + type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=[], + branch_connections=[ + StationAssetConnection(asset_id="station_b_asset_0", branch_end=None, asset_bay_id=None) + ], + injection_connections=[], + branch_switching_table=np.zeros((1, 1), dtype=bool), + injection_switching_table=np.zeros((1, 0), dtype=bool), + branch_connectivity=None, + injection_connectivity=None, + model_log=None, + ), + ], + branch_assets=[station_a.branch_connections[0].asset, station_b.branch_connections[0].asset], + injection_assets=[], asset_setpoints=None, timestamp=datetime.now(), metrics=None, @@ -310,25 +500,59 @@ def test_action_set_model_validator_rejects_non_grouped_local_actions(): def test_compress_and_expand_station_diffs_random_roundtrip(): rng = np.random.default_rng(20260313) - starting_stations: list[Station] = [] - actions: list[Station] = [] - expected_by_station: dict[str, list[Station]] = {} + starting_stations: list[MaterializedStation] = [] + starting_raw_stations: list[RawStation] = [] + starting_assets: list[SwitchableAsset] = [] + starting_injection_assets: list[SwitchableAsset] = [] + actions: list[MaterializedStation] = [] + expected_by_station: dict[str, list[MaterializedStation]] = {} n_stations = 4 for station_idx in range(n_stations): grid_model_id = f"station_{station_idx}" - n_busbars = int(rng.integers(1, 5)) - n_assets = int(rng.integers(1, 7)) + n_busbars = int(rng.integers(2, 5)) + n_branch_assets = int(rng.integers(1, 7)) + n_injection_assets = int(rng.integers(0, 4)) n_couplers = int(rng.integers(1, 6)) n_actions = int(rng.integers(1, 6)) + busbars = [ + Busbar.model_construct( + grid_model_id=f"{grid_model_id}_busbar_{busbar_idx}", + type=None, + name=None, + int_id=busbar_idx, + in_service=True, + bus_branch_bus_id=None, + ) + for busbar_idx in range(n_busbars) + ] + branch_assets = [ + BranchAsset.model_construct( + grid_model_id=f"{grid_model_id}_asset_{asset_idx}", + type=None, + name=None, + in_service=True, + ) + for asset_idx in range(n_branch_assets) + ] + injection_assets = [ + InjectionAsset.model_construct( + grid_model_id=f"{grid_model_id}_inj_{asset_idx}", + type=None, + name=None, + in_service=True, + ) + for asset_idx in range(n_injection_assets) + ] + starting_couplers = [ BusbarCoupler.model_construct( grid_model_id=f"{grid_model_id}_coupler_{coupler_idx}", type=None, name=None, - busbar_from_id=0, - busbar_to_id=0, + busbar_from_id=coupler_idx % n_busbars, + busbar_to_id=(coupler_idx + 1) % n_busbars, open=bool(rng.integers(0, 2)), in_service=True, asset_bay=None, @@ -336,32 +560,48 @@ def test_compress_and_expand_station_diffs_random_roundtrip(): for coupler_idx in range(n_couplers) ] - starting_station = Station.model_construct( + starting_branch_switching_table = rng.integers(0, 2, size=(n_busbars, n_branch_assets), dtype=np.uint8).astype(bool) + starting_injection_switching_table = rng.integers(0, 2, size=(n_busbars, n_injection_assets), dtype=np.uint8).astype( + bool + ) + starting_raw_stations.append( + build_raw_station( + grid_model_id, + busbars, + starting_couplers, + [asset.grid_model_id for asset in branch_assets], + starting_branch_switching_table, + injection_asset_ids=[asset.grid_model_id for asset in injection_assets], + injection_switching_table=starting_injection_switching_table, + ) + ) + starting_assets.extend(branch_assets) + starting_injection_assets.extend(injection_assets) + + starting_station = build_materialized_station( grid_model_id=grid_model_id, - name=None, - type=None, - region=None, - voltage_level=None, - busbars=[], + busbars=busbars, couplers=starting_couplers, - assets=[], - asset_switching_table=rng.integers(0, 2, size=(n_busbars, n_assets), dtype=np.uint8).astype(bool), - asset_connectivity=None, - model_log=None, + assets=branch_assets, + asset_switching_table=starting_branch_switching_table, + injection_assets=injection_assets, + injection_switching_table=starting_injection_switching_table, ) starting_stations.append(starting_station) - station_actions: list[Station] = [] + station_actions: list[MaterializedStation] = [] for _ in range(n_actions): couplers = [ coupler.model_copy(update={"open": bool(rng.integers(0, 2))}) for coupler in starting_station.couplers ] - switching_table = rng.integers(0, 2, size=(n_busbars, n_assets), dtype=np.uint8).astype(bool) + branch_switching_table = rng.integers(0, 2, size=(n_busbars, n_branch_assets), dtype=np.uint8).astype(bool) + injection_switching_table = rng.integers(0, 2, size=(n_busbars, n_injection_assets), dtype=np.uint8).astype(bool) station_actions.append( starting_station.model_copy( update={ "couplers": couplers, - "asset_switching_table": switching_table, + "branch_switching_table": branch_switching_table, + "injection_switching_table": injection_switching_table, } ) ) @@ -369,11 +609,13 @@ def test_compress_and_expand_station_diffs_random_roundtrip(): expected_by_station[grid_model_id] = station_actions actions.extend(station_actions) - starting_topology = Topology.model_construct( + starting_topology = Topology( topology_id="starting_topology", grid_model_file=None, name=None, - stations=starting_stations, + raw_stations=starting_raw_stations, + branch_assets=starting_assets, + injection_assets=starting_injection_assets, asset_setpoints=None, timestamp=datetime.now(), metrics=None, @@ -382,7 +624,7 @@ def test_compress_and_expand_station_diffs_random_roundtrip(): station_diffs = compress_actions_to_station_diffs(starting_topology, actions) expanded_actions = expand_station_diffs(starting_topology, station_diffs) - result_by_station: dict[str, list[Station]] = {grid_model_id: [] for grid_model_id in expected_by_station} + result_by_station: dict[str, list[MaterializedStation]] = {grid_model_id: [] for grid_model_id in expected_by_station} for action in expanded_actions: result_by_station[action.grid_model_id].append(action) @@ -396,79 +638,113 @@ def test_compress_and_expand_station_diffs_random_roundtrip(): expected_coupler_open = [coupler.open for coupler in expected_action.couplers] result_coupler_open = [coupler.open for coupler in result_action.couplers] assert result_coupler_open == expected_coupler_open - assert np.array_equal(result_action.asset_switching_table, expected_action.asset_switching_table) + assert np.array_equal(result_action.branch_switching_table, expected_action.branch_switching_table) + assert np.array_equal(result_action.injection_switching_table, expected_action.injection_switching_table) def test_compress_station_diffs_raises_on_non_diff_hypothesis_change(): - starting_station = Station.model_construct( - grid_model_id="station_x", - name=None, - type=None, - region=None, - voltage_level=None, - busbars=[], - couplers=[ - BusbarCoupler.model_construct( - grid_model_id="station_x_coupler_0", - type=None, - name=None, - busbar_from_id=1, - busbar_to_id=2, - open=False, - in_service=True, - asset_bay=None, - ) - ], - assets=[ - SwitchableAsset.model_construct( - grid_model_id="station_save_load_asset_1", - type=None, - name=None, - in_service=True, - branch_end=None, - asset_bay=None, - ), - SwitchableAsset.model_construct( - grid_model_id="station_save_load_asset_2", - type=None, - name=None, - in_service=True, - branch_end=None, - asset_bay=None, - ), - ], - asset_switching_table=np.array([[True, False], [False, True]], dtype=bool), - asset_connectivity=None, - model_log=None, - ) + busbars = [ + Busbar.model_construct( + grid_model_id="station_x_busbar_1", + type=None, + name=None, + int_id=1, + in_service=True, + bus_branch_bus_id=None, + ), + Busbar.model_construct( + grid_model_id="station_x_busbar_2", + type=None, + name=None, + int_id=2, + in_service=True, + bus_branch_bus_id=None, + ), + ] + couplers = [ + BusbarCoupler.model_construct( + grid_model_id="station_x_coupler_0", + type=None, + name=None, + busbar_from_id=1, + busbar_to_id=2, + open=False, + in_service=True, + asset_bay=None, + ) + ] + assets = [ + BranchAsset.model_construct( + grid_model_id="station_save_load_asset_1", + type=None, + name=None, + in_service=True, + ), + BranchAsset.model_construct( + grid_model_id="station_save_load_asset_2", + type=None, + name=None, + in_service=True, + ), + ] + asset_switching_table = np.array([[True, False], [False, True]], dtype=bool) - starting_topology = Topology.model_construct( + starting_topology = Topology( topology_id="starting_topology", grid_model_file=None, name=None, - stations=[starting_station], + raw_stations=[ + build_raw_station( + "station_x", + busbars, + couplers, + [asset.grid_model_id for asset in assets], + asset_switching_table, + ) + ], + branch_assets=assets, + injection_assets=[], asset_setpoints=None, timestamp=datetime.now(), metrics=None, ) + starting_station = build_materialized_station( + grid_model_id="station_x", + busbars=busbars, + couplers=couplers, + assets=assets, + asset_switching_table=asset_switching_table, + ) + valid_action = starting_station.model_copy( update={ "couplers": [ starting_station.couplers[0].model_copy(update={"open": True}), ], - "asset_switching_table": np.array([[False, True], [True, False]], dtype=bool), + "branch_switching_table": np.array([[False, True], [True, False]], dtype=bool), } ) - invalid_action = starting_station.model_copy( - update={ + invalid_action_payload = starting_station.model_dump(mode="python", round_trip=True) + invalid_action_payload.update( + { "couplers": [ - starting_station.couplers[0].model_copy(update={"busbar_from_id": 99}), + BusbarCoupler.model_construct( + grid_model_id=starting_station.couplers[0].grid_model_id, + type=starting_station.couplers[0].coupler_type, + name=starting_station.couplers[0].name, + busbar_from_id=99, + busbar_to_id=starting_station.couplers[0].busbar_to_id, + open=starting_station.couplers[0].open, + in_service=starting_station.couplers[0].in_service, + asset_bay=starting_station.couplers[0].asset_bay, + ) ], - "asset_switching_table": np.array([[True, True], [False, False]], dtype=bool), + "branch_switching_table": np.array([[True, True], [False, False]], dtype=bool), } ) + invalid_action = MaterializedStation.model_construct(**invalid_action_payload) actions = [valid_action, invalid_action] @@ -481,79 +757,84 @@ def test_compress_station_diffs_raises_on_non_diff_hypothesis_change(): def test_save_and_load_action_set_split_files_roundtrip(tmp_path: Path): - starting_station = Station.model_construct( - grid_model_id="station1", - name=None, - type=None, - region=None, - voltage_level=None, - busbars=[ - Busbar.model_construct( - grid_model_id="busbar1", - type=None, - name=None, - int_id=1, - in_service=True, - bus_branch_bus_id=None, - ), - Busbar.model_construct( - grid_model_id="busbar2", - type=None, - name=None, - int_id=2, - in_service=True, - bus_branch_bus_id=None, - ), - ], - couplers=[ - BusbarCoupler.model_construct( - grid_model_id="coupler1", - type=None, - name=None, - busbar_from_id=1, - busbar_to_id=2, - open=False, - in_service=True, - asset_bay=None, - ) - ], - assets=[ - SwitchableAsset.model_construct( - grid_model_id="asset1", - type=None, - name=None, - in_service=True, - branch_end=None, - asset_bay=None, - ), - SwitchableAsset.model_construct( - grid_model_id="asset2", - type=None, - name=None, - in_service=True, - branch_end=None, - asset_bay=None, - ), - ], - asset_switching_table=np.array([[True, False], [False, True]], dtype=bool), - asset_connectivity=None, - model_log=None, - ) + busbars = [ + Busbar.model_construct( + grid_model_id="busbar1", + type=None, + name=None, + int_id=1, + in_service=True, + bus_branch_bus_id=None, + ), + Busbar.model_construct( + grid_model_id="busbar2", + type=None, + name=None, + int_id=2, + in_service=True, + bus_branch_bus_id=None, + ), + ] + couplers = [ + BusbarCoupler.model_construct( + grid_model_id="coupler1", + type=None, + name=None, + busbar_from_id=1, + busbar_to_id=2, + open=False, + in_service=True, + asset_bay=None, + ) + ] + assets = [ + BranchAsset.model_construct( + grid_model_id="asset1", + type=None, + name=None, + in_service=True, + ), + BranchAsset.model_construct( + grid_model_id="asset2", + type=None, + name=None, + in_service=True, + ), + ] + asset_switching_table = np.array([[True, False], [False, True]], dtype=bool) - starting_topology = Topology.model_construct( + starting_topology = Topology( topology_id="starting_topology", grid_model_file=None, name=None, - stations=[starting_station], + raw_stations=[ + build_raw_station( + "station1", + busbars, + couplers, + [asset.grid_model_id for asset in assets], + asset_switching_table, + ) + ], + branch_assets=assets, + injection_assets=[], asset_setpoints=None, timestamp=datetime.now(), metrics=None, ) + starting_station = build_materialized_station( + grid_model_id="station1", + busbars=busbars, + couplers=couplers, + assets=assets, + asset_switching_table=asset_switching_table, + ) + local_action = starting_station.model_copy( update={ "couplers": [starting_station.couplers[0].model_copy(update={"open": True})], - "asset_switching_table": np.array([[False, True], [True, False]], dtype=bool), + "branch_switching_table": np.array([[False, True], [True, False]], dtype=bool), } ) action_set = ActionSet.model_construct( @@ -580,6 +861,6 @@ def test_save_and_load_action_set_split_files_roundtrip(tmp_path: Path): assert loaded_action.grid_model_id == local_action.grid_model_id assert [c.open for c in loaded_action.couplers] == [c.open for c in local_action.couplers] assert np.array_equal( - np.asarray(loaded_action.asset_switching_table), - np.asarray(local_action.asset_switching_table), + np.asarray(loaded_action.branch_switching_table), + np.asarray(local_action.branch_switching_table), ) diff --git a/packages/interfaces_pkg/tests/test_stored_action_set_performance.py b/packages/interfaces_pkg/tests/test_stored_action_set_performance.py index b05b61746..37b247dc4 100644 --- a/packages/interfaces_pkg/tests/test_stored_action_set_performance.py +++ b/packages/interfaces_pkg/tests/test_stored_action_set_performance.py @@ -12,7 +12,13 @@ import numpy as np import pytest from fsspec.implementations.local import LocalFileSystem -from toop_engine_interfaces.asset_topology import Busbar, BusbarCoupler, Station, SwitchableAsset, Topology +from toop_engine_interfaces.asset_topology.asset_topology import ( + RawStation, + Topology, +) +from toop_engine_interfaces.asset_topology.assets import BranchAsset, Busbar, BusbarCoupler +from toop_engine_interfaces.asset_topology.materialized_topology import MaterializedAssetConnection, MaterializedStation +from toop_engine_interfaces.asset_topology.station_models import StationAssetConnection from toop_engine_interfaces.filesystem_helper import save_pydantic_model_fs from toop_engine_interfaces.stored_action_set import ActionSet, load_action_set, load_action_set_fs, save_action_set @@ -46,8 +52,9 @@ def _build_large_random_action_set( ) asset_counts = np.tile(asset_counts, n_stations // len(asset_counts)) - stations: list[Station] = [] - local_actions: list[Station] = [] + raw_stations: list[RawStation] = [] + topology_assets: list[BranchAsset] = [] + local_actions: list[MaterializedStation] = [] for station_idx in range(n_stations): grid_model_id = f"station_{station_idx:03d}" @@ -81,18 +88,45 @@ def _build_large_random_action_set( ] assets = [ - SwitchableAsset.model_construct( + BranchAsset.model_construct( grid_model_id=f"{grid_model_id}_asset_{asset_idx}", type=None, name=None, in_service=True, - branch_end=None, - asset_bay=None, ) for asset_idx in range(n_assets) ] - starting_station = Station.model_construct( + branch_switching_table = rng.integers(0, 2, size=(n_busbars, n_assets), dtype=np.uint8).astype(bool) + injection_switching_table = np.zeros((n_busbars, 0), dtype=bool) + + raw_station = RawStation.model_construct( + grid_model_id=grid_model_id, + name=None, + type=None, + region=None, + voltage_level=None, + busbars=busbars, + couplers=couplers, + branch_connections=[ + StationAssetConnection.model_construct( + asset_id=asset.grid_model_id, + branch_end=None, + asset_bay_id=None, + ) + for asset in assets + ], + branch_switching_table=branch_switching_table, + branch_connectivity=None, + injection_connections=[], + injection_switching_table=injection_switching_table, + injection_connectivity=None, + model_log=None, + ) + raw_stations.append(raw_station) + topology_assets.extend(assets) + + starting_station = MaterializedStation.model_construct( grid_model_id=grid_model_id, name=None, type=None, @@ -100,14 +134,18 @@ def _build_large_random_action_set( voltage_level=None, busbars=busbars, couplers=couplers, - assets=assets, - asset_switching_table=rng.integers(0, 2, size=(n_busbars, n_assets), dtype=np.uint8).astype(bool), - asset_connectivity=None, + branch_connections=[ + MaterializedAssetConnection.model_construct(asset=asset, branch_end=None, asset_bay=None) for asset in assets + ], + branch_switching_table=branch_switching_table, + branch_connectivity=None, + injection_connections=[], + injection_switching_table=injection_switching_table, + injection_connectivity=None, model_log=None, ) - stations.append(starting_station) - base_switching_table = np.asarray(starting_station.asset_switching_table, dtype=bool) + base_switching_table = np.asarray(starting_station.branch_switching_table, dtype=bool) for _ in range(actions_per_station): action_couplers = [ coupler.model_copy(update={"open": bool(rng.integers(0, 2))}) for coupler in starting_station.couplers @@ -123,16 +161,18 @@ def _build_large_random_action_set( starting_station.model_copy( update={ "couplers": action_couplers, - "asset_switching_table": switching_table, + "branch_switching_table": switching_table, } ) ) - starting_topology = Topology.model_construct( + starting_topology = Topology( topology_id="performance_starting_topology", grid_model_file=None, name=None, - stations=stations, + raw_stations=raw_stations, + branch_assets=topology_assets, + injection_assets=[], asset_setpoints=None, timestamp=datetime.now(), metrics=None, @@ -175,11 +215,12 @@ def test_stored_action_set_large_performance(tmp_path: Path, record_property) -> ) # Sanity checks for requested test configuration. - assert len(action_set.starting_topology.stations) == n_stations + starting_stations = action_set.starting_topology.materialize_stations() + assert len(starting_stations) == n_stations assert len(action_set.local_actions) == n_actions - mean_assets = float(np.mean([len(station.assets) for station in action_set.starting_topology.stations])) + mean_assets = float(np.mean([len(station.branch_connections) for station in starting_stations])) assert mean_assets == avg_assets_per_station - assert all(len(station.couplers) == couplers_per_station for station in action_set.starting_topology.stations) + assert all(len(station.couplers) == couplers_per_station for station in starting_stations) old_file = tmp_path / "action_set_legacy.json" new_json = tmp_path / "action_set_split.json" diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/optimizer.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/optimizer.py index 7bb6d0421..1d0d18062 100644 --- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/optimizer.py +++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/optimizer.py @@ -10,10 +10,8 @@ from datetime import datetime, timedelta from functools import partial from pathlib import Path -from typing import cast import numpy as np -import pandera.typing.polars as patpl import pypowsybl import structlog from beartype.typing import Callable, Optional @@ -32,7 +30,7 @@ from toop_engine_interfaces.filesystem_helper import load_pydantic_model_fs from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS from toop_engine_interfaces.loadflow_result_helpers_polars import load_loadflow_results_polars, save_loadflow_results_polars -from toop_engine_interfaces.loadflow_results_polars import BranchResultSchemaPolars, LoadflowResultsPolars +from toop_engine_interfaces.loadflow_results_polars import LoadflowResultsPolars from toop_engine_interfaces.messages.lf_service.loadflow_results import StoredLoadflowReference from toop_engine_interfaces.nminus1_definition import Nminus1Definition from toop_engine_interfaces.stored_action_set import ActionSet, load_action_set_fs @@ -96,7 +94,7 @@ def update_initial_metrics_with_worst_k_contingencies( The number of worst contingencies to consider for the initial metrics. """ case_ids, top_k_overloads_n_1 = get_worst_k_contingencies_ac( - cast("patpl.LazyFrame[BranchResultSchemaPolars]", initial_loadflow.branch_results), + initial_loadflow.branch_results, k=worst_k, ) diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/scoring_functions.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/scoring_functions.py index 5c4a91d8c..fb23a8257 100644 --- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/scoring_functions.py +++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/ac/scoring_functions.py @@ -23,7 +23,7 @@ count_voltage_jumps, ) from toop_engine_dc_solver.postprocess.abstract_runner import AbstractLoadflowRunner, AdditionalActionInfo -from toop_engine_interfaces.asset_topology import RealizedTopology +from toop_engine_interfaces.asset_topology.applied_topology import RealizedTopology from toop_engine_interfaces.loadflow_result_helpers_polars import ( concatenate_loadflow_results_polars, subset_contingencies_polars, @@ -179,7 +179,7 @@ def extract_switching_distance(additional_info: AdditionalActionInfo) -> int: The switching distance, or 0 if not available """ if isinstance(additional_info, RealizedTopology): - return len(additional_info.reassignment_diff) + return len(additional_info.branch_reassignment_diff) + len(additional_info.injection_reassignment_diff) if isinstance(additional_info, pd.DataFrame): return len(additional_info) raise ValueError("Unknown format for additional info") diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/benchmark/benchmark_utils.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/benchmark/benchmark_utils.py index 0f0e1c148..771169da4 100644 --- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/benchmark/benchmark_utils.py +++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/benchmark/benchmark_utils.py @@ -27,7 +27,7 @@ # Domain-specific imports (may raise if not available in the environment) import pypowsybl import structlog -from beartype.typing import Literal, Optional, Tuple +from beartype.typing import Any, Literal, Optional, Tuple from fsspec.implementations.dirfs import DirFileSystem from fsspec.implementations.local import LocalFileSystem from omegaconf import DictConfig @@ -97,7 +97,7 @@ def suppress_jax_logs() -> None: """Disables jax debug logs spamming the console""" - def _drop_jax_logs(_logger, _method_name, event_dict: dict[str, str]) -> dict[str, str]: # noqa: ANN001 + def _drop_jax_logs(_logger, _method_name, event_dict: dict[str, Any]) -> dict[str, Any]: # noqa: ANN001 logger_name = event_dict.get("logger", "") if logger_name.startswith(("jax", "jaxlib", "xla", "absl")): raise DropEvent diff --git a/toop-engine-benchmark/assess_benchmarks.py b/toop-engine-benchmark/assess_benchmarks.py index a5c6ebb37..f4d24965d 100644 --- a/toop-engine-benchmark/assess_benchmarks.py +++ b/toop-engine-benchmark/assess_benchmarks.py @@ -21,7 +21,7 @@ import statistics as stats from pathlib import Path -from beartype.typing import Any, Iterable, cast +from beartype.typing import Any, Iterable # Disable beartype claw for this module to avoid decorating Hydra's generated main wrapper __beartype__ = False @@ -114,7 +114,7 @@ def _load_overrides(run_root: Path) -> tuple[list[str], dict[str, Any]]: resolved_cfg = OmegaConf.from_cli(overrides_list) container = OmegaConf.to_container(resolved_cfg, resolve=True) if isinstance(container, dict): - overrides_resolved = cast(dict[str, Any], container) + overrides_resolved = container except Exception as exc: # pragma: no cover - defensive logging logger.info(f"Failed to parse overrides for {overrides_file}: {exc}")