Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
9fc9c86
feat: Initial implementation with PSTs
spetznick-elia Jun 5, 2026
2aaecf3
refactor: use optional and None
spetznick-elia Jun 8, 2026
67059ac
feat: Remove csv and introduce new action set groups for PSTs
spetznick-elia Jun 11, 2026
5850446
chore: license header
spetznick-elia Jun 11, 2026
0078336
Merge remote-tracking branch 'origin/main' into feat/parallel-pst-opt
spetznick-elia Jun 11, 2026
df70ecc
fix: test
spetznick-elia Jun 11, 2026
8ee3340
Merge remote-tracking branch 'origin/main' into feat/parallel-pst-opt
spetznick-elia Jun 11, 2026
21b0f1a
fix: small fixes and merges
spetznick-elia Jun 11, 2026
68ed94c
refactor: proper logic
spetznick-elia Jun 11, 2026
ff3eaca
chore: add todo
spetznick-elia Jun 12, 2026
566a68a
refactor: Parse input to identify pst groups
spetznick-elia Jun 12, 2026
d5bbdba
Merge remote-tracking branch 'origin/main' into feat/parallel-pst-opt
spetznick-elia Jun 12, 2026
d83c507
fix: missing path in BranchModel for PST group
spetznick-elia Jun 12, 2026
a63f46e
fix: use python int annotation
spetznick-elia Jun 14, 2026
2d75b50
chore: Type annotation required
spetznick-elia Jun 15, 2026
7a200d9
feat: Add non-support for PandaPower backend
spetznick-elia Jun 15, 2026
ca7ce98
docs: Proper docs for new feature
spetznick-elia Jun 15, 2026
696c877
feat: add grouped pst example
BenjPetr Jun 15, 2026
7ad247b
refactor: Move grouping logic to importer and streamline identification
spetznick-elia Jun 16, 2026
b93d8b8
refactor: Add node breaker version of case57 and fix tests
spetznick-elia Jun 17, 2026
5f85997
refactor: Grid parsing for groups considers all PSTs
spetznick-elia Jun 18, 2026
3c81232
Merge remote-tracking branch 'origin/main' into feat/parallel-pst-opt
spetznick-elia Jun 18, 2026
a24337d
fix: Align parallel groups with filter
spetznick-elia Jun 18, 2026
a52e6c9
fix: order trafos correctly
spetznick-elia Jun 18, 2026
5fe507a
fix: Fix case 30 grid
spetznick-elia Jun 18, 2026
ed6c6c3
Initial plan
Copilot Jun 18, 2026
33c05a2
Merge remote-tracking branch 'origin/feat/parallel-pst-opt' into copi…
Copilot Jun 18, 2026
091c01a
fix: preserve PST masks for loadflow preprocessing
Copilot Jun 18, 2026
f5a32b1
fix: another case 30
spetznick-elia Jun 18, 2026
0b70e4c
Merge remote-tracking branch 'origin/main' into copilot/analyze-loadf…
spetznick-elia Jun 18, 2026
3442903
fix: missing import
spetznick-elia Jun 18, 2026
2edbd0d
fix: address loadflow preprocessing test failures
Copilot Jun 18, 2026
db534e6
fix: simplify inner logic and verify instead
spetznick-elia Jun 18, 2026
9ddd7cd
Merge branch 'copilot/analyze-loadflow-test-issues' of github.com:eli…
spetznick-elia Jun 18, 2026
a7379e0
Merge remote-tracking branch 'origin/main' into copilot/analyze-loadf…
spetznick-elia Jun 22, 2026
82bf704
fix: Revert math import
spetznick-elia Jun 22, 2026
86990ef
fix: Skip PST genotype deduplication for empty injections
spetznick-elia Jun 22, 2026
75e7c47
fix: Solver config parameter blocked proper import of group mask
spetznick-elia Jun 25, 2026
3c729d3
docs: Adjust docs after final refactor
spetznick-elia Jun 25, 2026
04734dd
Merge remote-tracking branch 'origin/main' into copilot/analyze-loadf…
spetznick-elia Jun 25, 2026
65a2ec9
Initial plan
Copilot Jun 25, 2026
2b35640
fix: add PST parallel group mask to static information
Copilot Jun 25, 2026
55bf096
fix: Pandapower import without PST groups
spetznick-elia Jun 25, 2026
5b867bc
fix: pandapower case without PSTs
spetznick-elia Jun 25, 2026
79a362a
fix: copilot feedback
spetznick-elia Jun 25, 2026
40cad0c
fix: feedback
spetznick-elia Jun 25, 2026
5cbda0c
chore: stable sort
spetznick-elia Jun 26, 2026
b372f53
Revert "chore: stable sort"
spetznick-elia Jun 26, 2026
c9d07c1
chore: fix beartype
spetznick-elia Jun 26, 2026
aa99509
test: add grouped pst test
BenjPetr Jun 26, 2026
985d148
Merge branch 'copilot/analyze-loadflow-test-issues' into feat/pst_gro…
BenjPetr Jun 26, 2026
3b6ffa8
chore: revert not needed changes
BenjPetr Jun 26, 2026
289140b
test: fix importer pst group
BenjPetr Jun 26, 2026
c16723e
test: grouped_pst_grid_backend_reads_parallel_group_masks
BenjPetr Jun 29, 2026
44054a1
Merge branch 'main' into feat/pst_group_example_grid
BenjPetr Jun 29, 2026
f3ef956
Merge branch 'main' into feat/pst_group_example_grid
BenjPetr Jun 30, 2026
5a3ba1a
Merge branch 'main' into feat/pst_group_example_grid
BenjPetr Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .pre-commit-hooks/sensitive-files-whitelist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ 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/action_set_diffs.hdf5
packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm
packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json
packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information_stats.json

# Grid helpers package test files
packages/grid_helpers_pkg/tests/files/test_ucte_powsybl_example.uct
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,27 @@ def test_translate_contingency_to_powsybl():
assert pow_basecase[0].id == basecase_contingency[0].id, "Id should stay the same"


def test_translate_contingency_to_powsybl_with_busbar(
powsybl_node_breaker_net: pypowsybl.network.Network,
) -> None:
identifiables = powsybl_node_breaker_net.get_identifiables(attributes=[]).index
busbar_id = powsybl_node_breaker_net.get_busbar_sections(attributes=[]).index[0]
contingency = Contingency(
id=busbar_id,
elements=[GridElement(id=busbar_id, type="BUSBAR_SECTION", kind="bus")],
)

pow_contingencies, missing_contingencies = translate_contingency_to_powsybl(
[contingency],
identifiables=identifiables,
)

assert missing_contingencies == []
assert len(pow_contingencies) == 1
assert pow_contingencies[0].id == busbar_id
assert pow_contingencies[0].elements == [busbar_id]


def test_translate_monitored_elements_to_powsybl(powsybl_bus_breaker_net: pypowsybl.network.Network) -> None:
branches = powsybl_bus_breaker_net.get_branches()
switches = powsybl_bus_breaker_net.get_switches()
Expand Down Expand Up @@ -574,6 +595,38 @@ def test_translate_nminus1_for_powsybl_split_helpers(powsybl_bus_breaker_net: py
assert translated_full.voltage_levels.equals(translated_rest.voltage_levels)


def test_translate_nminus1_for_powsybl_keeps_busbar_contingencies(
powsybl_node_breaker_net: pypowsybl.network.Network,
) -> None:
busbar_sections = powsybl_node_breaker_net.get_busbar_sections(attributes=["name"])
selected_busbar_id, selected_busbar = next(busbar_sections.iterrows())
nminus1_def = Nminus1Definition(
monitored_elements=[],
contingencies=[
Contingency(id="BASECASE", elements=[]),
Contingency(
id=selected_busbar_id,
name=selected_busbar.name or "",
elements=[
GridElement(
id=selected_busbar_id,
name=selected_busbar.name or "",
type="BUSBAR_SECTION",
kind="bus",
)
],
),
],
id_type="powsybl",
)

translated_nminus1 = translate_nminus1_components_for_powsybl(nminus1_def, powsybl_node_breaker_net)

assert [contingency.id for contingency in translated_nminus1.contingencies] == ["BASECASE", selected_busbar_id]
assert translated_nminus1.contingencies[1].elements == [selected_busbar_id]
assert translated_nminus1.missing_contingencies == []


def test_translate_nminus1_for_powsybl_with_branch_limit_cache(
powsybl_bus_breaker_net: pypowsybl.network.Network,
) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
# you can obtain one at https://mozilla.org/MPL/2.0/.
# Mozilla Public License, version 2.0

from dataclasses import replace

import numpy as np
import pandas as pd
import pandera as pa
import polars as pl
import pypowsybl
import pytest
from polars.testing import assert_frame_equal
Expand All @@ -22,7 +25,12 @@
build_branch_limit_cache,
run_contingency_analysis_powsybl,
)
from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK
from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK, SINGLE_SLACK
from toop_engine_importer.network_graph.powsybl_station_to_graph import (
get_relevant_voltage_levels,
get_station_list,
)
from toop_engine_importer.pypowsybl_import import powsybl_masks
from toop_engine_interfaces.loadflow_result_helpers import (
convert_pandas_loadflow_results_to_polars,
convert_polars_loadflow_results_to_pandas,
Expand All @@ -32,6 +40,35 @@
from toop_engine_interfaces.nminus1_definition import Contingency, GridElement, MonitoredElement, Nminus1Definition


def _normalize_contingency_frame(
frame: pl.DataFrame,
contingency_id: str,
excluded_element_prefixes: set[str] | None = None,
excluded_elements: set[str] | None = None,
excluded_branches: set[str] | None = None,
) -> pl.DataFrame:
"""Normalize contingency-specific result frames for direct equality checks."""
normalized = frame.filter(pl.col("contingency") == contingency_id)

if excluded_element_prefixes and "element" in normalized.columns:
excluded_prefix_expr = pl.lit(False)
for prefix in sorted(excluded_element_prefixes):
excluded_prefix_expr = excluded_prefix_expr | pl.col("element").str.starts_with(prefix)
normalized = normalized.filter(~excluded_prefix_expr)

if excluded_elements and "element" in normalized.columns:
normalized = normalized.filter(~pl.col("element").is_in(sorted(excluded_elements)))

if excluded_branches and {"element", "side"}.issubset(set(normalized.columns)):
normalized = normalized.filter(~pl.col("element").is_in(sorted(excluded_branches)))

if "contingency_name" in normalized.columns:
normalized = normalized.with_columns(pl.lit("").alias("contingency_name"))
normalized = normalized.with_columns(pl.lit("reference").alias("contingency"))
sort_columns = [column for column in normalized.columns if column not in {"contingency_name"}]
return normalized.sort(sort_columns)


def test_run_powsybl_analysis(powsybl_bus_breaker_net: pypowsybl.network.Network) -> None:
nminus1_definition = get_full_nminus1_definition_powsybl(powsybl_bus_breaker_net)

Expand Down Expand Up @@ -160,6 +197,235 @@ def test_contingency_analysis_validated_or_not(powsybl_node_breaker_net: pypowsy
assert lf_result_with_val_polars == lf_result_no_val_polars


def test_run_ac_contingency_analysis_powsybl_with_busbar_outage(
powsybl_node_breaker_net: pypowsybl.network.Network,
) -> None:
nminus1_definition = get_full_nminus1_definition_powsybl(powsybl_node_breaker_net)
busbar_sections = powsybl_node_breaker_net.get_busbar_sections()
# select a busbar section that is not the slack
lf_res = pypowsybl.loadflow.run_ac(powsybl_node_breaker_net)[0]
busbar_sections = busbar_sections[busbar_sections.bus_id != lf_res.reference_bus_id]
selected_busbar_id = selected_busbar_name = busbar_sections.index[0]
nminus1_definition.contingencies.append(
Contingency(
id=selected_busbar_id,
name=selected_busbar_name or "",
elements=[
GridElement(
id=selected_busbar_id,
name=selected_busbar_name or "",
type="BUSBAR_SECTION",
kind="bus",
)
],
)
)

lf_result = get_ac_loadflow_results(powsybl_node_breaker_net, nminus1_definition, job_id="test_job", n_processes=1)

converged = lf_result.converged.collect()
assert selected_busbar_id in converged["contingency"].to_list()
assert not any(selected_busbar_id in warning for warning in lf_result.warnings)


def test_busbar_outage_equals_connected_element_outage(
powsybl_node_breaker_net: pypowsybl.network.Network,
) -> None:
busbar_sections = powsybl_node_breaker_net.get_busbar_sections()
lf_res = pypowsybl.loadflow.run_ac(powsybl_node_breaker_net)[0]
non_slack_busbar_sections = busbar_sections[busbar_sections.bus_id != lf_res.reference_bus_id]
assert len(non_slack_busbar_sections) > 0

selected_voltage_levels = {"VL2", "VL3"}
selected_busbar_sections = non_slack_busbar_sections[
non_slack_busbar_sections["voltage_level_id"].astype(str).isin(selected_voltage_levels)
]
assert len(selected_busbar_sections) > 0

network_masks = powsybl_masks.create_default_network_masks(powsybl_node_breaker_net)
network_masks = replace(
network_masks,
relevant_subs=np.ones_like(network_masks.relevant_subs, dtype=bool),
busbar_for_nminus1=np.ones_like(network_masks.busbar_for_nminus1, dtype=bool),
)
relevant_voltage_level_with_region = get_relevant_voltage_levels(
network=powsybl_node_breaker_net,
network_masks=network_masks,
)
station_list = get_station_list(
network=powsybl_node_breaker_net,
relevant_voltage_level_with_region=relevant_voltage_level_with_region,
)
assert len(station_list) == len(powsybl_node_breaker_net.get_buses())

monitored_votlages = []
for vl in relevant_voltage_level_with_region["voltage_level_id"].values:
bus_breaker_topology = powsybl_node_breaker_net.get_bus_breaker_topology(vl)
elements = bus_breaker_topology.elements
elements.rename(columns={"bus_id": "bus_breaker_id"}, inplace=True)
elements = elements.merge(
powsybl_node_breaker_net.get_bus_breaker_view_buses()[["bus_id"]],
left_on="bus_breaker_id",
right_on="id",
how="left",
)
elements = elements[elements["type"] == "BUSBAR_SECTION"]
monitored_votlages += elements["bus_breaker_id"].to_list()

branches = powsybl_node_breaker_net.get_branches(attributes=["type"])
injections = powsybl_node_breaker_net.get_injections(attributes=["type"])

monitored_elements = get_full_nminus1_definition_powsybl(powsybl_node_breaker_net).monitored_elements
# filter monitored bus elements to only monitor buses from powsybl_node_breaker_net.get_buses()
monitored_elements = [
element
for element in monitored_elements
if (element.type == "BUS" and element.id in monitored_votlages) or (element.type != "BUS")
]

for selected_busbar_id in selected_busbar_sections.index:
selected_busbar_name = selected_busbar_id
selected_voltage_level_id = str(selected_busbar_sections.loc[selected_busbar_id, "voltage_level_id"])

selected_station = next(
station
for station in station_list
if any(busbar.grid_model_id == selected_busbar_id for busbar in station.busbars)
)
selected_busbar_index = next(
i for i, busbar in enumerate(selected_station.busbars) if busbar.grid_model_id == selected_busbar_id
)
connected_asset_ids = {
asset.grid_model_id
for i, asset in enumerate(selected_station.assets)
if selected_station.asset_switching_table[selected_busbar_index, i]
}

explicit_elements = [
GridElement(id=asset_id, name=asset_id, type=branches.loc[asset_id, "type"], kind="branch")
for asset_id in connected_asset_ids
if asset_id in branches.index
] + [
GridElement(
id=asset_id,
name=asset_id,
type=injections.loc[asset_id, "type"],
kind="injection",
)
for asset_id in connected_asset_ids
if asset_id in injections.index and injections.loc[asset_id, "type"] != "BUSBAR_SECTION"
]

nminus1_definition = Nminus1Definition(
monitored_elements=monitored_elements,
contingencies=[
Contingency(id="BASECASE", elements=[]),
Contingency(
id=selected_busbar_id,
name=selected_busbar_name or "",
elements=[
GridElement(
id=selected_busbar_id,
name=selected_busbar_name or "",
type="BUSBAR_SECTION",
kind="bus",
)
],
),
Contingency(id="explicit_busbar_outage", elements=explicit_elements),
],
id_type="powsybl",
)

lf_result = get_ac_loadflow_results(
powsybl_node_breaker_net, nminus1_definition, job_id="test_job", n_processes=1, lf_params=SINGLE_SLACK
)

selected_node_elements = set(
lf_result.node_results.collect().filter(pl.col("contingency") == selected_busbar_id)["element"].to_list()
)
explicit_node_elements = set(
lf_result.node_results.collect().filter(pl.col("contingency") == "explicit_busbar_outage")["element"].to_list()
)
excluded_selected_voltage_level_elements = {
element_id
for element_id in (explicit_node_elements - selected_node_elements)
if element_id.startswith(f"{selected_voltage_level_id}_")
}

selected_regulating_elements = set(
lf_result.regulating_element_results.collect()
.filter(pl.col("contingency") == selected_busbar_id)["element"]
.to_list()
)
explicit_regulating_elements = set(
lf_result.regulating_element_results.collect()
.filter(pl.col("contingency") == "explicit_busbar_outage")["element"]
.to_list()
)
excluded_explicit_regulating_elements = explicit_regulating_elements - selected_regulating_elements

selected_va_diff_elements = set(
lf_result.va_diff_results.collect().filter(pl.col("contingency") == selected_busbar_id)["element"].to_list()
)
explicit_va_diff_elements = set(
lf_result.va_diff_results.collect()
.filter(pl.col("contingency") == "explicit_busbar_outage")["element"]
.to_list()
)
excluded_explicit_va_diff_elements = explicit_va_diff_elements - selected_va_diff_elements

excluded_branch_ids = {asset_id for asset_id in connected_asset_ids if asset_id in branches.index}

assert_frame_equal(
_normalize_contingency_frame(
lf_result.branch_results.collect(),
selected_busbar_id,
excluded_branches=excluded_branch_ids,
),
_normalize_contingency_frame(
lf_result.branch_results.collect(),
"explicit_busbar_outage",
excluded_branches=excluded_branch_ids,
),
check_row_order=False,
check_exact=False,
abs_tol=1e-4,
)
assert_frame_equal(
_normalize_contingency_frame(lf_result.node_results.collect(), selected_busbar_id),
_normalize_contingency_frame(
lf_result.node_results.collect(),
"explicit_busbar_outage",
excluded_elements=excluded_selected_voltage_level_elements,
),
check_row_order=False,
)
assert_frame_equal(
_normalize_contingency_frame(lf_result.regulating_element_results.collect(), selected_busbar_id),
_normalize_contingency_frame(
lf_result.regulating_element_results.collect(),
"explicit_busbar_outage",
excluded_elements=excluded_explicit_regulating_elements,
),
check_row_order=False,
)
assert_frame_equal(
_normalize_contingency_frame(lf_result.va_diff_results.collect(), selected_busbar_id),
_normalize_contingency_frame(
lf_result.va_diff_results.collect(),
"explicit_busbar_outage",
excluded_elements=excluded_explicit_va_diff_elements,
),
check_row_order=False,
)
assert_frame_equal(
_normalize_contingency_frame(lf_result.converged.collect(), selected_busbar_id),
_normalize_contingency_frame(lf_result.converged.collect(), "explicit_busbar_outage"),
check_row_order=False,
)


@pytest.mark.parametrize("powsybl_net", ["powsybl_bus_breaker_net", "powsybl_node_breaker_net"])
def test_contingency_analysis_ray_vs_powsybl(powsybl_net: str, request, init_ray) -> None:
net = request.getfixturevalue(powsybl_net)
Expand Down
Loading
Loading