From 9fc9c86d497b98a3d4690c419b6c4c09d848be98 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 5 Jun 2026 16:05:34 +0000
Subject: [PATCH 01/44] feat: Initial implementation with PSTs
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_dc_solver/jax/inputs.py | 11 ++
.../jax/nodal_inj_optim.py | 21 ++-
.../src/toop_engine_dc_solver/jax/types.py | 8 +
.../preprocess/convert_to_jax.py | 1 +
.../preprocess/network_data.py | 11 ++
.../pandapower/pandapower_backend.py | 8 +
.../preprocess/parallel_pst_groups.py | 152 ++++++++++++++++++
.../preprocess/powsybl/powsybl_backend.py | 9 ++
.../preprocess/preprocess.py | 34 ++++
.../tests/jax/test_nodal_inj_optim.py | 28 +++-
.../src/toop_engine_interfaces/backend.py | 17 ++
.../folder_structure.py | 1 +
.../dc/genetic_functions/initialization.py | 29 +++-
.../dc/genetic_functions/mutation/config.py | 11 +-
.../mutation/mutate_nodal_inj.py | 51 ++++--
.../interfaces/messages/dc_params.py | 10 ++
.../test_mutate_nodal_inj.py | 28 ++++
17 files changed, 415 insertions(+), 15 deletions(-)
create mode 100644 packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
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 550abce5b..630436c8f 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
@@ -453,6 +453,7 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
file.attrs["enable_bb_outages"] = solver_config.enable_bb_outages
file.attrs["bb_outage_as_nminus1"] = solver_config.bb_outage_as_nminus1
file.attrs["clip_bb_outage_penalty"] = solver_config.clip_bb_outage_penalty
+ file.attrs["enable_parallel_pst_group_optim"] = solver_config.enable_parallel_pst_group_optim
file.create_dataset("susceptance", data=dynamic_information.susceptance)
file.create_dataset("relevant_injections", data=dynamic_information.relevant_injections)
file.create_dataset(
@@ -587,6 +588,10 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
"grid_model_low_tap",
data=nodal_inj_opt.grid_model_low_tap,
)
+ file.create_dataset(
+ "parallel_pst_group_mask",
+ data=nodal_inj_opt.parallel_pst_group_mask,
+ )
for idx, (branches, nodes) in enumerate(
zip(
@@ -810,6 +815,7 @@ def _get_array_if_exists(file: h5py.File, key: str) -> Optional[Array]:
enable_bb_outages=bool(file.attrs.get("enable_bb_outages", False)),
bb_outage_as_nminus1=bool(file.attrs.get("bb_outage_as_nminus1", True)),
clip_bb_outage_penalty=bool(file.attrs.get("clip_bb_outage_penalty", False)),
+ enable_parallel_pst_group_optim=bool(file.attrs.get("enable_parallel_pst_group_optim", False)),
contingency_ids=file["contingency_ids"].asstr()[:].tolist(),
),
)
@@ -923,6 +929,11 @@ def load_nodal_injection_optimization(
pst_tap_values=jnp.array(file["pst_tap_values"][:]),
starting_tap_idx=jnp.array(file["starting_tap_idx"][:]),
grid_model_low_tap=jnp.array(file["grid_model_low_tap"][:]),
+ parallel_pst_group_mask=(
+ jnp.array(file["parallel_pst_group_mask"][:])
+ if "parallel_pst_group_mask" in file
+ else jnp.eye(file["pst_n_taps"].shape[0], dtype=bool)
+ ),
)
return None
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
index 392a19311..ba4cfd44f 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
@@ -37,6 +37,23 @@ def make_start_options(
)
+def canonicalize_parallel_pst_taps(
+ pst_tap_indices: Int[Array, " batch_size n_timesteps n_controllable_pst"],
+ nodal_inj_info: NodalInjectionInformation,
+) -> Int[Array, " batch_size n_timesteps n_controllable_pst"]:
+ """Project PST taps onto the configured parallel-group constraint using one shared delta per group."""
+ if nodal_inj_info.parallel_pst_group_mask.size == 0:
+ return pst_tap_indices
+
+ group_mask = nodal_inj_info.parallel_pst_group_mask.astype(int)
+ representative_indices = jnp.argmax(group_mask, axis=1)
+ representative_start_taps = nodal_inj_info.starting_tap_idx[representative_indices]
+ group_deltas = pst_tap_indices[..., representative_indices] - representative_start_taps
+ pst_deltas = jnp.einsum("gp,...g->...p", group_mask, group_deltas)
+ canonical_taps = nodal_inj_info.starting_tap_idx + pst_deltas
+ return jnp.clip(canonical_taps, a_min=0, a_max=nodal_inj_info.pst_n_taps - 1)
+
+
def apply_pst_taps(
n_0: Float[Array, " batch_size n_timesteps n_branches"],
nodal_injections: Float[Array, " batch_size n_timesteps n_buses"],
@@ -114,7 +131,7 @@ def nodal_inj_optimization(
topo_res: TopologyResults,
start_options: NodalInjStartOptions,
dynamic_information: DynamicInformation,
- solver_config: SolverConfig, # noqa: ARG001
+ solver_config: SolverConfig,
) -> tuple[
Float[Array, " batch_size n_timesteps n_branches"],
Float[Array, " batch_size n_timesteps n_outages n_branches_monitored"],
@@ -156,6 +173,8 @@ def nodal_inj_optimization(
pst_tap_indices = start_options.previous_results.pst_tap_idx
if pst_tap_indices.ndim == 2:
pst_tap_indices = pst_tap_indices[None, :, :]
+ if solver_config.enable_parallel_pst_group_optim:
+ pst_tap_indices = canonicalize_parallel_pst_taps(pst_tap_indices=pst_tap_indices, nodal_inj_info=nodal_inj_info)
n_0_updated = apply_pst_taps(
n_0=n_0,
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
index 1a8452a53..c170c37ce 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
@@ -84,6 +84,11 @@ class NodalInjectionInformation(eqx.Module):
taps are to be reconstructed from indices into pst_tap_values then tap + grid_model_low_tap gives the actual tap position
in the original grid model."""
+ parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"] = eqx.field(
+ default_factory=lambda: jnp.zeros((0, 0), dtype=bool)
+ )
+ """Boolean masks describing groups of controllable PSTs that must move together."""
+
class N2BaselineAnalysis(eqx.Module):
"""The output of the N-2 baseline analysis, used to compare the split n-2 analysis against."""
@@ -476,6 +481,9 @@ class SolverConfig:
when we just want to ensure that the busbar outage problems are not exacerbated due to the optimiser, we set
this to True."""
+ enable_parallel_pst_group_optim: bool = False
+ """Whether controllable PSTs should be optimized and applied in configured parallel groups."""
+
def __hash__(self) -> int:
"""Get id as the hash for the static information.
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
index 10cd42a71..0c9821bbe 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
@@ -295,6 +295,7 @@ def convert_to_jax( # noqa: PLR0913
pst_tap_values=pst_tap_values,
starting_tap_idx=jnp.array(network_data.phase_shift_starting_tap_idx, dtype=int),
grid_model_low_tap=jnp.array(network_data.phase_shift_low_tap, dtype=int),
+ parallel_pst_group_mask=jnp.array(network_data.parallel_pst_group_mask, dtype=bool),
)
if network_data.controllable_pst_node_mask.any()
else None,
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 434509775..4315e69e2 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
@@ -310,6 +310,9 @@ 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."""
+ parallel_pst_group_mask: Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]] = None
+ """Boolean masks describing groups of parallel controllable PSTs aligned with PST arrays."""
+
realised_stations: Optional[list[list[Station]]] = 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
@@ -422,6 +425,7 @@ def fillna(a: np.ndarray, b: Union[np.ndarray, float]) -> np.ndarray:
phase_shift_starting_tap_idx=interface.get_phase_shift_starting_taps(),
phase_shift_low_tap=interface.get_phase_shift_low_taps(),
phase_shift_linearity=interface.get_phase_shift_linearity(),
+ parallel_pst_group_mask=interface.get_parallel_pst_group_mask(),
busbar_outage_map=interface.get_busbar_outage_map(),
)
@@ -462,6 +466,13 @@ def assert_network_data(network_data: NetworkData) -> None:
)
# We currently can't split the slack node - something in the BSDF doesn't work properly...
assert network_data.relevant_node_mask[network_data.slack].item() is False
+ if network_data.parallel_pst_group_mask is not None:
+ assert network_data.parallel_pst_group_mask.shape[1] == int(np.sum(network_data.controllable_phase_shift_mask)), (
+ "Parallel PST group mask must align with controllable PST arrays"
+ )
+ assert np.all(network_data.parallel_pst_group_mask.sum(axis=0) == 1), (
+ "Each controllable PST must belong to exactly one parallel PST group"
+ )
# ruff: noqa: PLR0915
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..71a08d8a9 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
@@ -18,6 +18,7 @@
from jaxtyping import Bool, Float, Int
from pandapower.pypower.idx_brch import F_BUS, SHIFT, T_BUS
from pandapower.pypower.makeBdc import calc_b_from_branch
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_group_mask
from toop_engine_grid_helpers.pandapower.pandapower_helpers import (
get_dc_bus_voltage,
get_pandapower_branch_loadflow_results_sequence,
@@ -490,6 +491,13 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
return self.net.trafo.loc[controllable_trafo_mask, "tap_min"].astype(int).to_numpy(dtype=int)
+ def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ """Get the parallel PST groups aligned with the controllable PST arrays."""
+ return load_or_create_parallel_pst_group_mask(
+ filesystem=self.data_folder_dirfs,
+ pst_ids=self.get_controllable_phase_shift_ids(),
+ )
+
def get_monitored_branch_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get mask of monitored branches for the reward calculation
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
new file mode 100644
index 000000000..4e4a34b7a
--- /dev/null
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -0,0 +1,152 @@
+"""Helpers for loading and generating parallel PST group definitions."""
+
+import csv
+import io
+
+import numpy as np
+import structlog
+from beartype.typing import Sequence
+from fsspec import AbstractFileSystem
+from jaxtyping import Bool
+from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
+
+logger = structlog.get_logger(__name__)
+
+PARALLEL_PSTS_CSV_HEADERS = ("pst_id", "group")
+
+
+def load_or_create_parallel_pst_group_mask(
+ filesystem: AbstractFileSystem,
+ pst_ids: Sequence[str | int],
+) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ """Load the parallel PST grouping from CSV or generate a default one.
+
+ Parameters
+ ----------
+ filesystem : AbstractFileSystem
+ Filesystem rooted at the preprocessing directory.
+ pst_ids : Sequence[str | int]
+ Ordered controllable PST ids the output mask should align with.
+
+ Returns
+ -------
+ Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]
+ A boolean group mask aligned with ``pst_ids``. Each column belongs to exactly one group.
+ """
+ pst_id_list = [str(pst_id) for pst_id in pst_ids]
+ if not pst_id_list:
+ return np.zeros((0, 0), dtype=bool)
+
+ file_path = PREPROCESSING_PATHS["parallel_psts_file_path"]
+ if not filesystem.exists(file_path):
+ logger.warning(
+ "parallel_psts.csv is missing. Generating a default file with one group per PST.",
+ file_path=file_path,
+ )
+ _write_default_parallel_psts_csv(filesystem=filesystem, file_path=file_path, pst_ids=pst_id_list)
+ return np.eye(len(pst_id_list), dtype=bool)
+
+ with filesystem.open(file_path, "r", encoding="utf-8") as file:
+ csv_content = file.read()
+
+ if not csv_content.strip():
+ logger.warning(
+ (
+ "parallel_psts.csv is empty. Generating a default file with one group per PST. "
+ "Fill parallel_psts.csv with PST id and group name columns to configure parallel optimization groups."
+ ),
+ file_path=file_path,
+ )
+ _write_default_parallel_psts_csv(filesystem=filesystem, file_path=file_path, pst_ids=pst_id_list)
+ return np.eye(len(pst_id_list), dtype=bool)
+
+ pst_group_rows = _read_parallel_pst_rows(csv_content=csv_content, file_path=file_path)
+ pst_to_group = _build_pst_group_mapping(pst_group_rows=pst_group_rows, file_path=file_path)
+
+ missing_pst_ids = [pst_id for pst_id in pst_id_list if pst_id not in pst_to_group]
+ if missing_pst_ids:
+ raise ValueError(f"parallel_psts.csv does not contain all controllable PST ids. Missing ids: {missing_pst_ids}.")
+
+ unknown_pst_ids = [pst_id for pst_id in pst_to_group if pst_id not in set(pst_id_list)]
+ if unknown_pst_ids:
+ logger.warning(
+ "parallel_psts.csv contains PST ids that are not part of the controllable PST set. Ignoring them.",
+ file_path=file_path,
+ pst_ids=unknown_pst_ids,
+ )
+
+ ordered_group_names: list[str] = []
+ group_index_by_name: dict[str, int] = {}
+ for pst_id in pst_id_list:
+ group_name = pst_to_group[pst_id]
+ if group_name not in group_index_by_name:
+ group_index_by_name[group_name] = len(ordered_group_names)
+ ordered_group_names.append(group_name)
+
+ parallel_pst_group_mask = np.zeros((len(ordered_group_names), len(pst_id_list)), dtype=bool)
+ for pst_index, pst_id in enumerate(pst_id_list):
+ parallel_pst_group_mask[group_index_by_name[pst_to_group[pst_id]], pst_index] = True
+
+ return parallel_pst_group_mask
+
+
+def _build_pst_group_mapping(pst_group_rows: list[tuple[str, str]], file_path: str) -> dict[str, str]:
+ """Build a mapping from PST id to group name and check for duplicates.
+
+ Parameters
+ ----------
+ pst_group_rows : list[tuple[str, str]]
+ List of (pst_id, group_name) tuples parsed from the CSV.
+ file_path : str
+ Path to the CSV file, used for error messages.
+
+ Returns
+ -------
+ dict[str, str]
+ Mapping from PST id to group name.
+
+ Raises
+ ------
+ ValueError
+ If a duplicate PST id is found.
+
+ """
+ pst_to_group: dict[str, str] = {}
+ for pst_id, group_name in pst_group_rows:
+ if pst_id in pst_to_group:
+ raise ValueError(f"Duplicate PST id '{pst_id}' found in {file_path}.")
+ pst_to_group[pst_id] = group_name
+ return pst_to_group
+
+
+def _read_parallel_pst_rows(csv_content: str, file_path: str) -> list[tuple[str, str]]:
+ """Parse the parallel PST CSV into ordered rows."""
+ reader = csv.reader(io.StringIO(csv_content))
+ rows = [row for row in reader if any(cell.strip() for cell in row)]
+ if not rows:
+ return []
+
+ header = rows[0]
+ if len(header) < 2:
+ raise ValueError(f"{file_path} must contain at least two columns for PST id and group, got header {header}.")
+
+ parsed_rows: list[tuple[str, str]] = []
+ for row in rows[1:]:
+ if len(row) < 2:
+ raise ValueError(f"Every row in {file_path} must contain PST id and group, got row {row}.")
+ pst_id = row[0].strip()
+ group_name = row[1].strip()
+ if not pst_id or not group_name:
+ raise ValueError(f"Every row in {file_path} must have non-empty PST id and group values, got row {row}.")
+ parsed_rows.append((pst_id, group_name))
+
+ return parsed_rows
+
+
+def _write_default_parallel_psts_csv(filesystem: AbstractFileSystem, file_path: str, pst_ids: Sequence[str]) -> None:
+ """Write a default CSV where each PST is its own optimization group."""
+ with filesystem.open(file_path, "w", encoding="utf-8", newline="") as file:
+ writer = csv.writer(file)
+ writer.writerow(PARALLEL_PSTS_CSV_HEADERS)
+ for pst_id in pst_ids:
+ writer.writerow((pst_id, pst_id))
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 85c4aa16a..5a2315120 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
@@ -18,6 +18,7 @@
from beartype.typing import Optional, Sequence, Union
from fsspec import AbstractFileSystem
from jaxtyping import Bool, Float, Int
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_group_mask
from toop_engine_dc_solver.preprocess.powsybl.powsybl_helpers import (
BranchModel,
get_lines,
@@ -481,6 +482,14 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
tap_changers = self.net.get_phase_tap_changers().loc[psts]
return tap_changers["low_tap"].values.astype(int)
+ @functools.lru_cache
+ def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ """Get the parallel PST groups aligned with the controllable PST arrays."""
+ return load_or_create_parallel_pst_group_mask(
+ filesystem=self.data_folder_dirfs,
+ pst_ids=self.get_controllable_phase_shift_ids(),
+ )
+
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
"""Get a mask of relevant nodes"""
return self._get_nodes()["relevant"].values
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..a3f8dfd33 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
@@ -562,6 +562,10 @@ def reduce_branch_dimension(network_data: NetworkData) -> NetworkData:
)
relevant_phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx[kept_pst_branches]
relevant_phase_shift_low_tap = network_data.phase_shift_low_tap[kept_pst_branches]
+ relevant_parallel_pst_group_mask = None
+ if network_data.parallel_pst_group_mask is not None:
+ relevant_parallel_pst_group_mask = network_data.parallel_pst_group_mask[:, kept_pst_branches]
+ relevant_parallel_pst_group_mask = relevant_parallel_pst_group_mask[np.any(relevant_parallel_pst_group_mask, axis=1)]
# PST branches carry a node injection as well, so we need to adjust the injection indices
pst_node_indices = np.flatnonzero(network_data.controllable_pst_node_mask)
# Assert that the number of PST branches and nodes is the same
@@ -595,6 +599,7 @@ def reduce_branch_dimension(network_data: NetworkData) -> NetworkData:
phase_shift_taps=relevant_phase_shift_taps,
phase_shift_starting_tap_idx=relevant_phase_shift_starting_tap_idx,
phase_shift_low_tap=relevant_phase_shift_low_tap,
+ parallel_pst_group_mask=relevant_parallel_pst_group_mask,
controllable_pst_node_mask=kept_controllable_pst_node_mask,
monitored_branch_mask=network_data.monitored_branch_mask[relevant_branches],
disconnectable_branch_mask=network_data.disconnectable_branch_mask[relevant_branches],
@@ -1287,9 +1292,37 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
"since they cannot be handled correctly in the backend."
)
pst_linearity = network_data.phase_shift_linearity
+ parallel_pst_group_mask = network_data.parallel_pst_group_mask
+ if parallel_pst_group_mask is not None:
+ assert parallel_pst_group_mask.shape[1] == pst_linearity.shape[0], (
+ "Parallel PST group mask must align with controllable PST linearity information."
+ )
+ linear_group_counts = parallel_pst_group_mask.astype(int) @ pst_linearity.astype(int)
+ group_sizes = parallel_pst_group_mask.sum(axis=1)
+ mixed_parallel_groups = (linear_group_counts > 0) & (linear_group_counts < group_sizes)
+ if np.any(mixed_parallel_groups):
+ raise ValueError(
+ "Parallel PST groups cannot mix linear and non-linear controllable PSTs when grouped optimization data "
+ "is prepared. Update parallel_psts.csv so each group only contains linear or only non-linear PSTs."
+ )
+ parallel_pst_group_mask = parallel_pst_group_mask[:, pst_linearity]
+ parallel_pst_group_mask = parallel_pst_group_mask[np.any(parallel_pst_group_mask, axis=1)]
+
phase_shift_low_tap = network_data.phase_shift_low_tap[pst_linearity]
phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx[pst_linearity]
phase_shift_taps = [taps for taps, linear in zip(network_data.phase_shift_taps, pst_linearity, strict=True) if linear]
+ if parallel_pst_group_mask is not None:
+ for group_idx, group_mask in enumerate(parallel_pst_group_mask):
+ if np.sum(group_mask) <= 1:
+ continue
+ group_starting_taps = phase_shift_starting_tap_idx[group_mask]
+ if not np.all(group_starting_taps == group_starting_taps[0]):
+ logger.warning(
+ "Parallel PST group members do not share the same starting tap. "
+ "Grouped optimization will use a shared delta and clip individually.",
+ group_index=group_idx,
+ starting_taps=group_starting_taps.tolist(),
+ )
controllable_pst_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
controllable_phase_shift_mask = np.zeros_like(network_data.controllable_phase_shift_mask, dtype=bool)
@@ -1301,6 +1334,7 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
phase_shift_starting_tap_idx=phase_shift_starting_tap_idx,
phase_shift_taps=phase_shift_taps,
phase_shift_linearity=np.ones_like(phase_shift_low_tap, dtype=bool),
+ parallel_pst_group_mask=parallel_pst_group_mask,
)
diff --git a/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py b/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py
index b339265fe..f2f6d34b1 100644
--- a/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py
+++ b/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py
@@ -11,8 +11,9 @@
from fsspec.implementations.dirfs import DirFileSystem
from jax_dataclasses import replace
from toop_engine_dc_solver.example_grids import case30_with_psts_powsybl
-from toop_engine_dc_solver.jax.nodal_inj_optim import nodal_inj_optimization
+from toop_engine_dc_solver.jax.nodal_inj_optim import canonicalize_parallel_pst_taps, nodal_inj_optimization
from toop_engine_dc_solver.jax.types import (
+ NodalInjectionInformation,
NodalInjOptimResults,
NodalInjStartOptions,
TopologyResults,
@@ -112,3 +113,28 @@ def test_compare_nodal_inj_to_powsybl(tmp_path: Path) -> None:
# Verify that when we apply different taps, we get different flows than with starting taps
assert not jnp.allclose(n_0_changed, n_0_unchanged), "N-0 flows should differ between different tap settings"
assert n_0_changed.shape == n_0_batched.shape, "Output shape should match input shape"
+
+
+def test_canonicalize_parallel_pst_taps_uses_shared_group_delta() -> None:
+ nodal_inj_info = NodalInjectionInformation(
+ controllable_pst_indices=jnp.array([0, 1, 2]),
+ shift_degree_min=jnp.array([-10.0, -10.0, -10.0]),
+ shift_degree_max=jnp.array([10.0, 10.0, 10.0]),
+ pst_n_taps=jnp.array([10, 10, 10]),
+ pst_tap_values=jnp.zeros((3, 10)),
+ starting_tap_idx=jnp.array([4, 4, 2]),
+ grid_model_low_tap=jnp.array([0, 0, 0]),
+ parallel_pst_group_mask=jnp.array(
+ [
+ [True, True, False],
+ [False, False, True],
+ ]
+ ),
+ )
+ pst_tap_indices = jnp.array([[[6, 1, 5]]])
+
+ canonical = canonicalize_parallel_pst_taps(pst_tap_indices=pst_tap_indices, nodal_inj_info=nodal_inj_info)
+
+ assert canonical.shape == pst_tap_indices.shape
+ assert canonical[0, 0, 0] == canonical[0, 0, 1]
+ assert canonical[0, 0, 0] == 6
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
index e88c9b5d9..3ee838e1c 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
@@ -296,6 +296,23 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
"""
return np.zeros(sum(self.get_controllable_phase_shift_mask()), dtype=int)
+ def get_controllable_phase_shift_ids(self) -> list[str]:
+ """Get branch ids of controllable PSTs aligned with controllable PST arrays."""
+ branch_ids = self.get_branch_ids()
+ controllable_pst_mask = self.get_controllable_phase_shift_mask()
+ return [
+ str(branch_id)
+ for branch_id, is_controllable in zip(branch_ids, controllable_pst_mask, strict=True)
+ if is_controllable
+ ]
+
+ def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]]:
+ """Get a PST group mask aligned with the controllable PST arrays.
+
+ Returns None when no explicit grouping metadata is available.
+ """
+ return None
+
@abstractmethod
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
"""Get true if a node is part of the relevant nodes
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
index 57091ce20..880a52cd9 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
@@ -17,6 +17,7 @@
PREPROCESSING_PATHS: Final[dict[str, str]] = {
"grid_file_path_powsybl": "grid.xiidm",
"grid_file_path_pandapower": "grid.json",
+ "parallel_psts_file_path": "parallel_psts.csv",
"masks_path": "masks",
"static_information_file_path": "static_information.hdf5",
"importer_auxiliary_file_path": "importer_auxiliary_data.json",
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
index d6227507b..4d8e5c9fc 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
@@ -166,6 +166,7 @@ def verify_static_information(
static_informations: Iterable[StaticInformation],
max_num_disconnections: int,
enable_nodal_inj_optim: bool,
+ enable_parallel_pst_group_optim: bool = False,
) -> None:
"""Verify the static information.
@@ -181,6 +182,9 @@ def verify_static_information(
enable_nodal_inj_optim: bool
Whether to enable the nodal injection optimization. If so, all static informations are verified to contain
nodal injection information with at least one controllable PST.
+ enable_parallel_pst_group_optim: bool
+ Whether to enable parallel PST group optimization. If so, all static informations are verified to contain
+ the parallel_pst_group_mask in the nodal injection information.
Raises
------
@@ -250,12 +254,17 @@ def verify_static_information(
"This requires at least one controllable PST in the nodal injection information. "
"Disable nodal injection optimization or provide correct static information. "
)
+ if enable_parallel_pst_group_optim:
+ assert first_static_information.dynamic_information.nodal_injection_information is not None, (
+ "Parallel PST group optimization requires nodal injection information with controllable PSTs."
+ )
def update_static_information(
static_informations: tuple[StaticInformation, ...],
batch_size: int,
enable_nodal_inj_optim: bool,
+ enable_parallel_pst_group_optim: bool,
enable_bb_outage: bool,
bb_outage_as_nminus1: bool,
clip_bb_outage_penalty: bool,
@@ -274,6 +283,10 @@ def update_static_information(
enable_nodal_inj_optim: bool
Whether to enable the nodal injection optimization, if False, nodal_inj_optim related information will be removed
from the dynamic information to save GPU memory.
+ enable_parallel_pst_group_optim: bool
+ Whether to enable parallel PST group optimization, which requires the presence of the parallel_pst_group_mask
+ in the nodal injection information. If False, this mask will be removed from the dynamic information to save
+ GPU memory.
enable_bb_outage : bool
Whether the optimizer should include busbar outage effects. This is only enabled when
the loaded static information contains busbar outage data.
@@ -299,6 +312,7 @@ def update_static_information(
dynamic_information=static_information.dynamic_information,
batch_size=batch_size,
enable_nodal_inj_optim=enable_nodal_inj_optim,
+ enable_parallel_pst_group_optim=enable_parallel_pst_group_optim,
)
solver_config, dynamic_information = update_single_pair_bb_outage_information(
solver_config=solver_config,
@@ -327,6 +341,7 @@ def update_single_pair_branch_limit_information(
dynamic_information: DynamicInformation,
batch_size: int,
enable_nodal_inj_optim: bool,
+ enable_parallel_pst_group_optim: bool,
) -> tuple[SolverConfig, DynamicInformation]:
"""Normalize branch-limit and nodal-injection data for one timestep.
@@ -344,6 +359,9 @@ def update_single_pair_branch_limit_information(
The batch size to write into both batch dimensions of the solver config.
enable_nodal_inj_optim : bool
Whether nodal injection optimization is enabled.
+ enable_parallel_pst_group_optim : bool
+ Whether parallel PST group optimization is enabled, which requires the presence of the parallel_pst_group_mask
+ in the nodal injection information.
Returns
-------
@@ -354,6 +372,7 @@ def update_single_pair_branch_limit_information(
solver_config,
batch_size_bsdf=batch_size,
batch_size_injection=batch_size,
+ enable_parallel_pst_group_optim=enable_parallel_pst_group_optim,
)
updated_dynamic_information = replace(
dynamic_information,
@@ -722,13 +741,17 @@ def algo_setup(
logger.info(f"Running {n_devices} GPUs with config {ga_args}, {lf_args}")
verify_static_information(
- static_informations, lf_args.max_num_disconnections, enable_nodal_inj_optim=ga_args.enable_nodal_inj_optim
+ static_informations,
+ lf_args.max_num_disconnections,
+ enable_nodal_inj_optim=ga_args.enable_nodal_inj_optim,
+ enable_parallel_pst_group_optim=ga_args.enable_parallel_pst_group_optim,
)
static_informations = update_static_information(
static_informations,
lf_args.batch_size,
enable_nodal_inj_optim=ga_args.enable_nodal_inj_optim,
+ enable_parallel_pst_group_optim=ga_args.enable_parallel_pst_group_optim,
enable_bb_outage=ga_args.enable_bb_outage,
bb_outage_as_nminus1=ga_args.bb_outage_as_nminus1,
clip_bb_outage_penalty=ga_args.clip_bb_outage_penalty,
@@ -787,6 +810,10 @@ def algo_setup(
pst_reset_probability=ga_args.pst_reset_probability,
pst_n_taps=static_informations[0].dynamic_information.nodal_injection_information.pst_n_taps,
pst_start_tap_idx=static_informations[0].dynamic_information.nodal_injection_information.starting_tap_idx,
+ enable_parallel_pst_group_optim=ga_args.enable_parallel_pst_group_optim,
+ parallel_pst_group_mask=(
+ static_informations[0].dynamic_information.nodal_injection_information.parallel_pst_group_mask
+ ),
)
if static_informations[0].dynamic_information.nodal_injection_information is not None
else None,
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
index d5e9df762..344611efc 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
@@ -8,8 +8,9 @@
"""Mutation configuration classes for the genetic algorithm."""
import equinox as eqx
+import jax.numpy as jnp
from beartype.typing import Optional
-from jaxtyping import Array, Int
+from jaxtyping import Array, Bool, Int
class SubstationMutationConfig(eqx.Module):
@@ -79,6 +80,14 @@ class NodalInjectionMutationConfig(eqx.Module):
pst_start_tap_idx: Int[Array, " n_controllable_pst"]
"""The starting tap position as an index into the tap range of each controllable PST"""
+ enable_parallel_pst_group_optim: bool = eqx.field(static=True, default=False)
+ """Whether PST mutations should be sampled once per configured parallel group."""
+
+ parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"] = eqx.field(
+ default_factory=lambda: jnp.zeros((0, 0), dtype=bool)
+ )
+ """Boolean masks that map each controllable PST to exactly one parallel-optimization group."""
+
class MutationConfig(eqx.Module):
"""Configuration for the mutation operation."""
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
index 40ad0335c..1ed6c0fdf 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
@@ -25,6 +25,8 @@ def mutate_psts(
pst_mutation_sigma: float | int,
pst_mutation_probability: float = 0.2,
pst_reset_probability: float = 0.1,
+ enable_parallel_pst_group_optim: bool = False,
+ parallel_pst_group_mask: Int[Array, " n_parallel_pst_groups n_controllable_pst"] | None = None,
) -> Int[Array, " n_controllable_pst"]:
"""Mutate the PST taps of a single topology.
@@ -48,6 +50,16 @@ def mutate_psts(
pst_reset_probability: float
The probability for an individual PST to be reverted to its initial set point. A value of 0.0 means no reset. A
value of 1.0 means all PSTs will be reset. Default 0.1
+ enable_parallel_pst_group_optim: bool
+ Whether to enable parallel PST group optimization, which requires the presence of the parallel_pst_group
+ mask. If enabled, whole groups of PSTs defined in the parallel_pst_group_mask will be mutated together, meaning
+ that the mutation will be the same for all PSTs in a group.
+ The pst_mutation_probability and pst_reset_probability will then apply to the groups instead of individual PSTs.
+ parallel_pst_group_mask: Int[Array, " n_parallel_pst_groups n_controllable_pst"] | None
+ A boolean mask defining the parallel PST groups, where True indicates that a PST belongs to a group. The shape of
+ the mask should be (n_parallel_pst_groups, n_controllable_pst). Each column should have exactly one True value,
+ indicating that each PST belongs to exactly one group. If None or empty, parallel group optimization
+ will be disabled regardless of the value of enable_parallel_pst_group_optim.
Returns
-------
@@ -57,17 +69,32 @@ def mutate_psts(
# Sample number of PSTs to adjust from a n_controllable_pst-dimensional uniform distribution
key, key_mutate, key_reset = jax.random.split(random_key, 3)
- pst_indices_to_mutate = jax.random.bernoulli(key=key, p=pst_mutation_probability, shape=pst_taps.shape)
-
- # Keep the sample shape static so this function can run under vmap/jit.
- mutation_samples = jax.random.normal(key_mutate, shape=pst_taps.shape) * pst_mutation_sigma
- mutation = jnp.where(pst_indices_to_mutate, mutation_samples, 0.0)
- mutation = jnp.round(mutation).astype(int)
- new_pst_taps = pst_taps + mutation
-
- # Reset random PSTs
- pst_indices_to_reset = jax.random.bernoulli(key=key_reset, p=pst_reset_probability, shape=pst_taps.shape)
- new_pst_taps = jnp.where(pst_indices_to_reset, pst_starting_taps, new_pst_taps)
+ if enable_parallel_pst_group_optim and parallel_pst_group_mask is not None and parallel_pst_group_mask.size > 0:
+ n_parallel_groups = parallel_pst_group_mask.shape[0]
+ group_indices_to_mutate = jax.random.bernoulli(key=key, p=pst_mutation_probability, shape=(n_parallel_groups,))
+ mutation_samples = jax.random.normal(key_mutate, shape=(n_parallel_groups,)) * pst_mutation_sigma
+ group_mutation = jnp.where(group_indices_to_mutate, mutation_samples, 0.0)
+ group_mutation = jnp.round(group_mutation).astype(int)
+ pst_mutation = jnp.einsum("gp,g->p", parallel_pst_group_mask.astype(int), group_mutation)
+ new_pst_taps = pst_taps + pst_mutation
+
+ group_indices_to_reset = jax.random.bernoulli(key=key_reset, p=pst_reset_probability, shape=(n_parallel_groups,))
+ pst_indices_to_reset = jnp.einsum(
+ "gp,g->p", parallel_pst_group_mask.astype(int), group_indices_to_reset.astype(int)
+ ).astype(bool)
+ new_pst_taps = jnp.where(pst_indices_to_reset, pst_starting_taps, new_pst_taps)
+ else:
+ pst_indices_to_mutate = jax.random.bernoulli(key=key, p=pst_mutation_probability, shape=pst_taps.shape)
+
+ # Keep the sample shape static so this function can run under vmap/jit.
+ mutation_samples = jax.random.normal(key_mutate, shape=pst_taps.shape) * pst_mutation_sigma
+ mutation = jnp.where(pst_indices_to_mutate, mutation_samples, 0.0)
+ mutation = jnp.round(mutation).astype(int)
+ new_pst_taps = pst_taps + mutation
+
+ # Reset random PSTs
+ pst_indices_to_reset = jax.random.bernoulli(key=key_reset, p=pst_reset_probability, shape=pst_taps.shape)
+ new_pst_taps = jnp.where(pst_indices_to_reset, pst_starting_taps, new_pst_taps)
new_pst_taps = jnp.clip(new_pst_taps, a_min=0, a_max=pst_n_taps - 1)
return new_pst_taps
@@ -114,6 +141,8 @@ def mutate_nodal_injections(
pst_mutation_sigma=nodal_mutation_config.pst_mutation_sigma,
pst_mutation_probability=nodal_mutation_config.pst_mutation_probability,
pst_reset_probability=nodal_mutation_config.pst_reset_probability,
+ enable_parallel_pst_group_optim=nodal_mutation_config.enable_parallel_pst_group_optim,
+ parallel_pst_group_mask=nodal_mutation_config.parallel_pst_group_mask,
)
)
)(
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/dc_params.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/dc_params.py
index 402ce77c8..200577de5 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/dc_params.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/dc_params.py
@@ -43,6 +43,9 @@ class BatchedMEParameters(BaseModel):
"""Whether to enable the nodal injection optimization stage. This can optimize PSTs (currently) and soon HVDC and
potentially even redispatch clusters. Using this will increase runtime."""
+ enable_parallel_pst_group_optim: bool = False
+ """Whether grouped PST optimization should enforce shared tap updates for configured parallel PST groups."""
+
plot: bool = False
"""Whether to plot the repertoire"""
@@ -184,6 +187,13 @@ def probabilities_less_than_one(self) -> "BatchedMEParameters":
raise ValueError("The random topology probability cannot be larger than 1.")
return self
+ @model_validator(mode="after")
+ def grouped_pst_optimization_requires_nodal_optimization(self) -> "BatchedMEParameters":
+ """Ensure grouped PST optimization is only enabled together with nodal injection optimization."""
+ if self.enable_parallel_pst_group_optim and not self.enable_nodal_inj_optim:
+ raise ValueError("Grouped PST optimization requires enable_nodal_inj_optim=True.")
+ return self
+
@model_validator(mode="after")
def me_descriptors_cannot_be_empty(self) -> "BatchedMEParameters":
"""Check that MeDescriptors tuple is not empty."""
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
index 6f162fa34..b216118d5 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
@@ -218,3 +218,31 @@ def test_resetting_psts() -> None:
assert jnp.any(difference > 0)
assert jnp.any(difference == 0)
assert jnp.any(mutated_pst_taps == pst_starting_taps)
+
+
+def test_grouped_mutate_psts_keeps_parallel_members_equal() -> None:
+ random_key = jax.random.PRNGKey(1234)
+ pst_taps = jnp.array([5, 5, 9, 9])
+ pst_starting_taps = jnp.array([5, 5, 9, 9])
+ pst_n_taps = jnp.array([20, 20, 20, 20])
+ parallel_pst_group_mask = jnp.array(
+ [
+ [True, True, False, False],
+ [False, False, True, True],
+ ]
+ )
+
+ mutated_pst_taps = mutate_psts(
+ random_key=random_key,
+ pst_taps=pst_taps,
+ pst_n_taps=pst_n_taps,
+ pst_starting_taps=pst_starting_taps,
+ pst_mutation_sigma=3.0,
+ pst_mutation_probability=1.0,
+ pst_reset_probability=0.0,
+ enable_parallel_pst_group_optim=True,
+ parallel_pst_group_mask=parallel_pst_group_mask,
+ )
+
+ assert mutated_pst_taps[0] == mutated_pst_taps[1]
+ assert mutated_pst_taps[2] == mutated_pst_taps[3]
From 2aaecf3d4e335934b39a1604a7c06b2a99d4fa51 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Mon, 8 Jun 2026 07:59:03 +0000
Subject: [PATCH 02/44] refactor: use optional and None
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py | 4 +---
.../dc/genetic_functions/mutation/config.py | 5 ++---
.../dc/genetic_functions/mutation/mutate_nodal_inj.py | 6 +++---
.../tests/dc/genetic_functions/test_initialization.py | 2 ++
.../tests/dc/genetic_functions/test_mutate_nodal_inj.py | 3 ++-
5 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
index c170c37ce..14f34bbfd 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
@@ -84,9 +84,7 @@ class NodalInjectionInformation(eqx.Module):
taps are to be reconstructed from indices into pst_tap_values then tap + grid_model_low_tap gives the actual tap position
in the original grid model."""
- parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"] = eqx.field(
- default_factory=lambda: jnp.zeros((0, 0), dtype=bool)
- )
+ parallel_pst_group_mask: Optional[Bool[Array, " n_parallel_pst_groups n_controllable_pst"]] = None
"""Boolean masks describing groups of controllable PSTs that must move together."""
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
index 344611efc..0b281550a 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
@@ -8,7 +8,6 @@
"""Mutation configuration classes for the genetic algorithm."""
import equinox as eqx
-import jax.numpy as jnp
from beartype.typing import Optional
from jaxtyping import Array, Bool, Int
@@ -83,8 +82,8 @@ class NodalInjectionMutationConfig(eqx.Module):
enable_parallel_pst_group_optim: bool = eqx.field(static=True, default=False)
"""Whether PST mutations should be sampled once per configured parallel group."""
- parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"] = eqx.field(
- default_factory=lambda: jnp.zeros((0, 0), dtype=bool)
+ parallel_pst_group_mask: Optional[Bool[Array, " n_parallel_pst_groups n_controllable_pst"]] = eqx.field(
+ static=True, default=None
)
"""Boolean masks that map each controllable PST to exactly one parallel-optimization group."""
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
index 1ed6c0fdf..d2a0c139c 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
@@ -12,7 +12,7 @@
import jax
import jax.numpy as jnp
from beartype.typing import Optional
-from jaxtyping import Array, Int, PRNGKeyArray
+from jaxtyping import Array, Bool, Int, PRNGKeyArray
from toop_engine_dc_solver.jax.types import NodalInjOptimResults
from toop_engine_topology_optimizer.dc.genetic_functions.mutation.config import NodalInjectionMutationConfig
@@ -26,7 +26,7 @@ def mutate_psts(
pst_mutation_probability: float = 0.2,
pst_reset_probability: float = 0.1,
enable_parallel_pst_group_optim: bool = False,
- parallel_pst_group_mask: Int[Array, " n_parallel_pst_groups n_controllable_pst"] | None = None,
+ parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"] | None = None,
) -> Int[Array, " n_controllable_pst"]:
"""Mutate the PST taps of a single topology.
@@ -69,7 +69,7 @@ def mutate_psts(
# Sample number of PSTs to adjust from a n_controllable_pst-dimensional uniform distribution
key, key_mutate, key_reset = jax.random.split(random_key, 3)
- if enable_parallel_pst_group_optim and parallel_pst_group_mask is not None and parallel_pst_group_mask.size > 0:
+ if enable_parallel_pst_group_optim and parallel_pst_group_mask is not None:
n_parallel_groups = parallel_pst_group_mask.shape[0]
group_indices_to_mutate = jax.random.bernoulli(key=key, p=pst_mutation_probability, shape=(n_parallel_groups,))
mutation_samples = jax.random.normal(key_mutate, shape=(n_parallel_groups,)) * pst_mutation_sigma
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py
index 604d97127..c312d639e 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py
@@ -93,6 +93,7 @@ def test_update_static_information_overrides_busbar_penalty(static_information_f
(static_information,),
batch_size=3,
enable_nodal_inj_optim=False,
+ enable_parallel_pst_group_optim=False,
enable_bb_outage=True,
bb_outage_as_nminus1=False,
clip_bb_outage_penalty=False,
@@ -128,6 +129,7 @@ def test_update_static_information_removes_busbar_data_when_disabled(static_info
(static_information,),
batch_size=3,
enable_nodal_inj_optim=False,
+ enable_parallel_pst_group_optim=False,
enable_bb_outage=False,
bb_outage_as_nminus1=False,
clip_bb_outage_penalty=False,
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
index b216118d5..e665cb7ce 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
@@ -229,7 +229,8 @@ def test_grouped_mutate_psts_keeps_parallel_members_equal() -> None:
[
[True, True, False, False],
[False, False, True, True],
- ]
+ ],
+ dtype=bool,
)
mutated_pst_taps = mutate_psts(
From 67059ac63e5294c72ba897ab79dfe7c53757bd4e Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 11 Jun 2026 09:48:49 +0000
Subject: [PATCH 03/44] feat: Remove csv and introduce new action set groups
for PSTs
Add documenation
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
README.md | 10 +-
docs/dc_solver/preprocessing.md | 44 ++++--
docs/dc_solver/quickstart.md | 14 +-
docs/importer/worker/worker.md | 10 +-
docs/index.md | 14 +-
docs/quickstart.md | 2 +
docs/usage.md | 10 +-
packages/contingency_analysis_pkg/README.md | 2 +-
packages/dc_solver_pkg/README.md | 4 +-
.../preprocess/network_data.py | 48 ++++++-
.../pandapower/pandapower_backend.py | 16 ++-
.../preprocess/parallel_pst_groups.py | 122 +++++++---------
.../preprocess/powsybl/powsybl_backend.py | 16 ++-
.../preprocess/preprocess.py | 89 +++++++++++-
packages/dc_solver_pkg/tests/conftest.py | 7 +
.../preprocessing/test_parallel_pst_groups.py | 132 ++++++++++++++++++
.../tests/preprocessing/test_preprocess.py | 73 ++++++++++
.../preprocessing/test_write_aux_data.py | 50 +++++++
packages/importer_pkg/README.md | 4 +
packages/interfaces_pkg/README.md | 2 +-
.../src/toop_engine_interfaces/backend.py | 4 +
.../folder_structure.py | 1 -
.../stored_action_set.py | 13 ++
.../tests/test_stored_action_set.py | 58 ++++++++
packages/topology_optimizer_pkg/README.md | 18 +--
.../dc/genetic_functions/mutation/config.py | 13 +-
.../mutation/mutate_nodal_inj.py | 5 +-
.../test_mutate_nodal_inj.py | 28 +++-
28 files changed, 674 insertions(+), 135 deletions(-)
create mode 100644 packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
diff --git a/README.md b/README.md
index f78d85afe..746c0844e 100644
--- a/README.md
+++ b/README.md
@@ -26,12 +26,12 @@ Welcome to our ToOp (engine) repository at Elia Group.
ToOp is short for Topology Optimization and describes the approach to reduce grid congestion by topological actions. Topological actions are non-costly actions that can be applied to the grid to "steer" the electricity flow.
Our goal is to propose (potentially) new topology strategies to the operators with the goal to lower redispatch costs and carbon emissions.
-This repository builds the engine behind the topology optimization product ToOp at Elia Group. ToOp provides tools to perform topology optimization on a grid file including import, DC optimization and AC validation. It also includes the gpu-based DC load flow solver. At the current stage it considers transmission line switching, busbar splitting and busbar reassignments.
+This repository builds the engine behind the topology optimization product ToOp at Elia Group. ToOp provides tools to perform topology optimization on operational grid data through an importer, a DC optimization stage, and AC validation. It also includes the GPU-based DC load flow solver. At the current stage it considers transmission line switching, busbar splitting, busbar reassignments, and grouped PST tap optimization.
## About this repository
-This repo builds the engine behind the topology optimization project ToOp at Elia Group. This provides a tool to perform topology optimization on a grid file including import, DC optimization and AC validation. Note that this does NOT provide a GUI or system integration code, you are expected to interact with the module through either python or kafka commands. You can check the [paper](https://arxiv.org/abs/2605.10128) for a high level academic introduction.
+This repo builds the engine behind the topology optimization project ToOp at Elia Group. The standard workflow first normalizes a raw grid into a processed grid folder containing the backend grid snapshot, masks, loadflow parameters, topology metadata, and an initial contingency definition. The DC preprocessing stage then adds `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, and the final `nminus1_definition.json` used by the solver, optimizer, and postprocessing. Note that this does NOT provide a GUI or system integration code, you are expected to interact with the module through either python or kafka commands. You can check the [paper](https://arxiv.org/abs/2605.10128) for a high level academic introduction.
Please check out our [full documentation](https://eliagroup.github.io/ToOp).
@@ -56,7 +56,7 @@ You can follow our installation guide on our [Contributing page](./CONTRIBUTING.
In order to understand the functionalities of this repo, please have a look at our examples in `notebooks/`.
There you can find several Jupyter notebooks that explain how to use the engine.
-For example, you can load a grid file and compute the DC loadflow using our GPU-based loadflow solver.
+For example, you can import a grid file, build the preprocessing artifacts, and compute the DC loadflow using our GPU-based loadflow solver.
Or you can load an example grid and minimise the branch overload by running the topology optimizer.
You can also build the documentation and open it on your web browser by running
@@ -83,7 +83,7 @@ You are expected to interact with the module through either python or kafka comm
## High-level architecture

-The topology optimizer takes as an input operational grid files (e.g. UCT, CGMES) which are imported by open-source libraries (PowSyBl, pandapower) and pre-processed. The pre-processed files are then optimized in a gpu-native set-up (optimizer + gpu-based load flow solver). The optimal results are stored as a pareto-front, so a set of all solutions that are "Pareto optimal". This means that no other solution exists that improves at least one objective without worsening another one. These results are then validated and filtered using an AC power flow. In the end the results are displayed in a frontend where an end user can review and evaluate the proposed actions. The proposed topological actions can then be exported to other systems.
+The topology optimizer takes as an input operational grid files (e.g. UCT, CGMES) which are imported by open-source libraries (PowSyBl, pandapower) and normalized into a processed grid folder. The importer stage writes the backend grid snapshot together with masks, loadflow parameters, and topology metadata; the DC preprocessing stage adds `static_information.hdf5`, `action_set.json`, and the final contingency definition. The pre-processed files are then optimized in a GPU-native set-up (optimizer + GPU-based load flow solver). The optimal results are stored as a pareto-front, so a set of all solutions that are "Pareto optimal". This means that no other solution exists that improves at least one objective without worsening another one. These results are then validated and filtered using an AC power flow. In the end the results are displayed in a frontend where an end user can review and evaluate the proposed actions. The proposed topological actions can then be exported to other systems.
#### Description the GPU-based DC load Flow solver
@@ -99,7 +99,7 @@ Under the hood, it is using PTDF/(G)LODF/BSDF approaches to achieve this.
## Roadmap
-Next to some smaller improvements, we currently plan to integrate PST into the optimization loop until Q2. We will work on sharing a more high-level roadmap in the future.
+Next to some smaller improvements, current work focuses on broadening controllable asset support, improving preprocessing fidelity, and hardening the end-to-end optimization workflow. We will work on sharing a more high-level roadmap in the future.
## Let us work together
diff --git a/docs/dc_solver/preprocessing.md b/docs/dc_solver/preprocessing.md
index c66a797c1..c26aead87 100644
--- a/docs/dc_solver/preprocessing.md
+++ b/docs/dc_solver/preprocessing.md
@@ -1,24 +1,46 @@
# Preprocessing
-The preprocessing process is split into three parts:
-- An importing procedure that prepares the grid data—this is specific to the data source and power system modelling framework. The code for this is not hosted in this repository, but in the importer repo.
-
+The preprocessing flow is split into three parts:
+- An importing procedure that prepares a processed grid folder from the raw source data. In this repository this is handled by the Importer package through [`convert_file`][toop_engine_importer.pypowsybl_import.preprocessing.convert_file]. It writes the backend-readable grid snapshot together with masks, loadflow parameters, topology metadata, and an initial contingency definition.
- A [`preprocess`][toop_engine_dc_solver.preprocess.preprocess] routine which extracts DC-loadflow relevant information from a backend and performs various data transformations.
- A [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_to_jax] routine which reformats the data from the Python format used during preprocessing to the format required by the solver. All processing happens in `preprocess`; this function purely reformats. The only exception is currently the N-2 unsplit analysis.
-The [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] routine combines the two and runs an initial loadflow. This routine serves as a top-level entrypoint for preprocessing.
+The [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] routine combines the latter two steps, runs an initial loadflow, and persists the standard solver artifacts back into the same processed grid folder.
## Data artifacts
-Output data pieces are defined in the [`folder_structure`][toop_engine_interfaces.folder_structure.PREPROCESSING_PATHS]. Most notably, the following data objects are written out:
+The processed grid folder layout is defined in the [`folder_structure`][toop_engine_interfaces.folder_structure.PREPROCESSING_PATHS]. The most important artifacts are split across the importer step and the DC solver step:
+
+| Stage | Artifact | Purpose |
+| --- | --- | --- |
+| Importer | `grid.xiidm` or `grid.json` | Backend-readable grid snapshot used by the powsybl or pandapower backend. |
+| Importer | `masks/` | Branch, node, and injection masks that define relevance, controllability, and contingency handling. |
+| Importer | `loadflow_parameters.json` | Loadflow parameters selected during import. |
+| Importer | `importer_auxiliary_data.json` | Import statistics and auxiliary metadata produced during normalization. |
+| Importer | `initial_topology/asset_topology.json` | Asset-topology view of the imported grid. |
+| Importer | `nminus1_definition.json` | Initial contingency definition derived from the imported grid and masks. |
+| DC solver | `static_information.hdf5` | JAX-native solver input used by the DC solver and optimizer. |
+| DC solver | `static_information_stats.json` | Summary statistics extracted from the preprocessed solver input. |
+| DC solver | `action_set.json` | Persisted switching actions and controllable asset ranges used by postprocessing and optimization. |
+| DC solver | `action_set_diffs.hdf5` | Companion diff representation for the persisted action set. |
+| DC solver | `nminus1_definition.json` | Refreshed contingency definition after preprocessing filters have been applied. |
+
+The same processed grid folder is therefore both an input and an output of [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid]. In particular, `action_set.json` is no longer only a postprocessing artifact: if it already exists when preprocessing starts, the backend reads its PST grouping metadata and preserves it through the preprocessing pipeline.
+
+## Parallel PST grouping in `action_set.json`
+
+Controllable PSTs are serialized in `ActionSet.pst_ranges`. Each PST range carries a `pst_group` field that defines which PSTs must move together during optimization.
+
+- PSTs with the same `pst_group` are treated as one optimization group.
+- If `action_set.json` is missing, or a controllable PST is absent from it, preprocessing falls back to one group per PST.
+- During preprocessing, grouped PSTs are clipped to their common tap domain before the action set is written back to disk.
+- Mixed linear and non-linear PSTs are rejected and cannot share the same group (We currently do not support optimization of non-linear/asymmetric PSTs).
-- A`static_information.hdf5` file containing all JAX data—this is all the DC solver and optimizer need to run an optimization.
-- An `action_set.json` containing an asset-topology-based representation of the action set, used for postprocessing the raw bus/branch DC topologies to node/breaker topologies.
-- A `nminus1_definition.json` containing the N-1 cases in the grid. AC validation can happen with the grid, action set, and N-1 definition.
+The persisted `action_set.json` always writes the group explicitly, so downstream tools and subsequent preprocessing runs see the same grouping.
## Backend interface
-The [`backend`][toop_engine_interfaces.backend.BackendInterface] interface exposes a common format for both pandapower and powsybl-based grids. The main task of the backend is loading the masks and exposing the information in the required format. Instead of modelling lines, trafos, etc., the backend exposes branches, nodes, and injections.
+The [`backend`][toop_engine_interfaces.backend.BackendInterface] interface exposes a common format for both pandapower and powsybl-based grids. The main task of the backend is loading the processed grid folder and exposing the information in the required format. Instead of modelling lines, trafos, etc., the backend exposes branches, nodes, and injections.
## `preprocess()` routine
@@ -64,10 +86,10 @@ The [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_t
The [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] routine performs the following tasks:
-- Instantiate the backend, depending on whether it is a [`PandaPowerBackend`][toop_engine_dc_solver.preprocess.pandapower.pandapower_backend.PandaPowerBackend] or [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend] grid. (`load_grid_into_loadflow_solver_backend`)
+- Instantiate the backend, depending on whether it is a [`PandaPowerBackend`][toop_engine_dc_solver.preprocess.pandapower.pandapower_backend.PandaPowerBackend] or [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend] grid. The backend reads the normalized grid files, masks, loadflow parameters, and any existing PST grouping metadata from the processed grid folder. (`load_grid_into_loadflow_solver_backend`)
- Call the [`preprocess`][toop_engine_dc_solver.preprocess.preprocess] routine.
- Call the [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_to_jax] routine.
- [`Validate`][toop_engine_dc_solver.jax.inputs.validate_static_information] the resulting static information.
- Run an [`initial loadflow`][toop_engine_dc_solver.preprocess.convert_to_jax.run_initial_loadflow] and update the double limits accordingly (`compute_base_loadflows`).
- Extract some [`StaticInformationStats`][toop_engine_interfaces.messages.preprocess.preprocess_results.StaticInformationStats].
-- Save the [data artifacts](#data-artifacts) (`save_artifacts`).
+- Save the [data artifacts](#data-artifacts), including `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the refreshed `nminus1_definition.json` (`save_artifacts`).
diff --git a/docs/dc_solver/quickstart.md b/docs/dc_solver/quickstart.md
index ec1857e5f..ec0f2a4b1 100644
--- a/docs/dc_solver/quickstart.md
+++ b/docs/dc_solver/quickstart.md
@@ -2,7 +2,7 @@
## Preprocessing
-To get the solver running, we first need to preprocess a grid file. In addition to the grid file, the backends usually expect some mask to define additional information that is not typically stored in a grid file, such as which branches to use in the N-1 computation. Consult the documentation of the backends for more information. There are three noteworthy steps in the preprocessing:
+To get the solver running, we first need to preprocess a grid file. In practice this means working from a processed grid folder that contains a backend grid snapshot (`grid.xiidm` or `grid.json`), masks, and loadflow parameters. If an `action_set.json` is already present in that folder, preprocessing also reuses its `pst_ranges[*].pst_group` information to keep grouped PSTs synchronized. There are three noteworthy steps in the preprocessing:
- **[`BackendInterface`][toop_engine_interfaces.backend.BackendInterface]**: A backend offers a read-only interface to a power systems modelling software and translates the information from the modelling software into a software-agnostic node-branch representation. Currently, there is a [`PandaPowerBackend`][toop_engine_dc_solver.preprocess.pandapower.pandapower_backend.PandaPowerBackend] and a [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend] available.
- **[`NetworkData`][toop_engine_dc_solver.preprocess.network_data.NetworkData]**: This is a backend-agnostic representation of the grid with all the information needed for post-processing and execution of the loadflow solver. You can obtain a filled instance of this through [`preprocess`][toop_engine_dc_solver.preprocess.preprocess] and generate a [`StaticInformation`][toop_engine_dc_solver.jax.types.StaticInformation] dataclass from it.
- **[`StaticInformation`][toop_engine_dc_solver.jax.types.StaticInformation]**: Stores only the relevant data for the loadflow computation directly on GPU VRAM, fully in jax format. You can obtain this from a filled network data instance through [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax].
@@ -19,6 +19,18 @@ network_data = preprocess(backend)
static_information = convert_to_jax(network_data)
```
+If you want the repository-standard artifact flow on disk, prefer [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid]:
+
+```python
+from fsspec.implementations.dirfs import DirFileSystem
+from toop_engine_dc_solver.preprocess import load_grid
+
+stats, static_information, network_data = load_grid(DirFileSystem("path_to_processed_grid"))
+```
+
+This reads the processed grid folder and writes `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and a refreshed `nminus1_definition.json`. The saved action set contains explicit `pst_group` values for controllable PSTs.
+If you want to change the values of `pst_group`, you can run your workflow, adjust the grouping manually, and the function will retain the grouping information instead of defaulting to unique groups per PST.
+
The `jax.config.update` statement is recommended because otherwise the static information will be in 32 bit, as jax is [automatically converting everything to 32 bit by default](https://jax.readthedocs.io/en/latest/notebooks/Common_Gotchas_in_JAX.html#double-64bit-precision) and setting this config flag will stop it from doing so. You can still switch to 32 bit during the execution, but by running the preprocessing in 64 bit, you will retain the option to choose at the expense of a bit of disk space.
After preprocessing, you can then save the data to load it later on the machine where you want to compute the loadflows:
diff --git a/docs/importer/worker/worker.md b/docs/importer/worker/worker.md
index 798644af8..c9e909033 100644
--- a/docs/importer/worker/worker.md
+++ b/docs/importer/worker/worker.md
@@ -1,6 +1,10 @@
# Importer worker
-The importer worker is designed to run as one of the components in the ToOp architecture. The role is to preprocess gridfiles from the raw format (CGMES, UCTE) to an internal representation that can be used in the optimizers. The worker takes preprocessing [`Command`][toop_engine_interfaces.messages.preprocess.preprocess_commands.Command] objects and, upon reception starts the importing process. This entails
-- Grid file creation
-- Preprocessing through the [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] function
+The importer worker is designed to run as one of the components in the ToOp architecture. The role is to preprocess grid files from the raw format (CGMES, UCTE) into the processed grid folder used by the optimizers. The worker takes preprocessing [`Command`][toop_engine_interfaces.messages.preprocess.preprocess_commands.Command] objects and, upon reception, starts the importing process. This entails:
+
+- Creating the backend grid snapshot (`grid.xiidm` for powsybl or `grid.json` for pandapower).
+- Writing masks, loadflow parameters, importer auxiliary data, asset topology metadata, and an initial `nminus1_definition.json`.
+- Preprocessing through the [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] function to create `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the final filtered `nminus1_definition.json`.
- Running an initial loadflow using the contingency_analysis module.
+
+If an `action_set.json` is already present when [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] starts, its `pst_ranges[*].pst_group` values are reused so grouped PSTs stay synchronized through preprocessing and optimization.
diff --git a/docs/index.md b/docs/index.md
index 9b8d99236..2672ed0fe 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,14 +11,14 @@ Welcome to our ToOp (engine) repository at Elia Group.
ToOp is short for Topology Optimization and describes the approach to reduce grid congestion by topological actions. Topological actions are non-costly actions that can be applied to the grid to "steer" the electrcitiy flow.
Our goal is to propose (potentially) new topology strategies to the operators with the goal to lower redispatch costs and carbon emissions.
-This repository builds the engine behind the topology optimization product ToOp at Elia Group. ToOp provides tools to perform topology optimization on a grid file including import, DC optimization and AC validation. It also includes the gpu-based DC load flow solver. At the current stage it considers transmission line switching, busbar splitting and busbar reassignments.
+This repository builds the engine behind the topology optimization product ToOp at Elia Group. ToOp provides tools to perform topology optimization on operational grid data through an importer, a DC optimization stage, and AC validation. It also includes the GPU-based DC load flow solver. At the current stage it considers transmission line switching, busbar splitting, busbar reassignments, and grouped, linear PST tap optimization.
## About this repository
-This repo builds the engine behind the topology optimization project ToOp at Elia Group. This provides a tool to perform topology optimization on a grid file including import, DC optimization and AC validation. Note that this does NOT provide a GUI or system integration code, you are expected to interact with the module through either python or kafka commands. You can check the [paper](https://arxiv.org/abs/2501.17529) for a high level academic introduction.
+This repo builds the engine behind the topology optimization project ToOp at Elia Group. The standard workflow first normalizes a raw grid into a processed grid folder containing the backend grid snapshot, masks, loadflow parameters, topology metadata, and an initial contingency definition. The DC preprocessing stage then adds `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, and the final `nminus1_definition.json` used by the solver, optimizer, and postprocessing. Note that this does NOT provide a GUI or system integration code, you are expected to interact with the module through either python or kafka commands. You can check the [paper](https://arxiv.org/abs/2501.17529) for a high level academic introduction.
Please check out our [full documentation](https://eliagroup.github.io/ToOp).
@@ -43,7 +43,7 @@ You can follow our installation guide on our [Contributing page](./contribution_
In order to understand the functionalities of this repo, please have a look at our examples in `notebooks/`.
There you can find several Jupyter notebooks that explain how to use the engine.
-For example, you can load a grid file and compute the DC loadflow using our GPU-based loadflow solver.
+For example, you can import a grid file, build the preprocessing artifacts, and compute the DC loadflow using our GPU-based loadflow solver.
Or you can load an example grid and minimise the branch overload by running the topology optimizer.
You can also build the documentation and open it on your web browser by running
@@ -70,7 +70,7 @@ You are expected to interact with the module through either python or kafka comm
## High-level architecture

-The topology optimizer takes as an input operational grid files (e.g. UCT, CGMES) which are imported by open-source libraries (PowSyBl, pandapower) and pre-processed. The pre-processed files are then optimized in a gpu-native set-up (optimizer + gpu-based load flow solver). The optimal results are stored as a pareto-front, so a set of all solutions that are "Pareto optimal". This means that no other solution exists that improves at least one objective without worsening another one. These results are then validated and filtered using an AC power flow. In the end the results are displayed in a frontend where an end user can review and evaluate the proposed actions. The proposed topological actions can then be exported to other systems.
+The topology optimizer takes as an input operational grid files (e.g. UCT, CGMES) which are imported by open-source libraries (PowSyBl, pandapower) and normalized into a processed grid folder. The importer stage writes the backend grid snapshot together with masks, loadflow parameters, and topology metadata; the DC preprocessing stage adds `static_information.hdf5`, `action_set.json`, and the final contingency definition. The pre-processed files are then optimized in a GPU-native set-up (optimizer + GPU-based load flow solver). The optimal results are stored as a pareto-front, so a set of all solutions that are "Pareto optimal". This means that no other solution exists that improves at least one objective without worsening another one. These results are then validated and filtered using an AC power flow. In the end the results are displayed in a frontend where an end user can review and evaluate the proposed actions. The proposed topological actions can then be exported to other systems.
#### Description the GPU-based DC load Flow solver
@@ -87,7 +87,11 @@ If your workflow suits these requirements like it is the case for topology optim
## Roadmap
-Next to some smaller improvements, we currently plan to integrate PST into the optimization loop until Q2. We will work on sharing a more high-level roadmap in the future.
+Next to some smaller improvements, current work focuses on broadening controllable asset support, improving preprocessing fidelity, and hardening the end-to-end optimization workflow:
+- Support of a wider range of asset topologies by refactoring the our abstraction layer
+- Support of optimization of non-linear and/or asymmetric phase-shifting transformers
+
+We will work on sharing a more high-level roadmap in the future.
## Let us work together
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 05ee6fb39..679e912f3 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -15,4 +15,6 @@ You can run any grid file (compatible with PyPowsybl) of your choice and investi
If you want to take smaller steps, start with **[`notebooks/example1_dc_loadflow_example.ipynb`](https://github.com/eliagroup/ToOp/blob/main/notebooks/example1_dc_loadflow_example.ipynb)**.
After that, take a look at **[`notebooks/example2_small_grid_toop.ipynb`](https://github.com/eliagroup/ToOp/blob/main/notebooks/example2_small_grid_toop.ipynb)**.
This notebook optimizes a small node-breaker grid and displays the changes in the effected substation.
+
+Most workflows start by turning a raw grid file into a processed grid folder. The importer creates the backend grid snapshot, masks, loadflow parameters, and topology metadata; [`load_grid`][toop_engine_dc_solver.preprocess.load_grid] then adds `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, and the final `nminus1_definition.json` used by the solver and optimizer.
diff --git a/docs/usage.md b/docs/usage.md
index 6ca835780..6275da47b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -32,12 +32,14 @@ If you want to use Kafka workers instead, read on.
We will extend the usage guide of this package incrementally. For now, please refer to the example notebooks.
If you are interested in creating Kafka workers, inspect the interfaces for the Kafka topics and trace their usage.
-### Step 1: Import a grid file
+### Step 1: Prepare a processed grid folder
-To use the tool, you need to import the grid into your file. This entails two fundamental steps:
+To use the tool, you first create a processed grid folder and then derive the solver artifacts from it. This entails two fundamental steps:
-- The [convert_file][toop_engine_importer.pypowsybl_import.preprocessing.convert_file] function, taking an import command. This will prepare masks and perform initial preprocessing tasks in the grid.
-- The [load_grid][toop_engine_dc_solver.preprocess.load_grid] function writes data into the data folder, creating a folder with several artifacts. The most relevant one being the `static_information.hdf5` which holds the data relevant for the DC GPU optimizer.
+- The [convert_file][toop_engine_importer.pypowsybl_import.preprocessing.convert_file] function takes an import command and writes the normalized backend grid file, masks, loadflow parameters, asset topology metadata, importer auxiliary data, and an initial `nminus1_definition.json`.
+- The [load_grid][toop_engine_dc_solver.preprocess.load_grid] function consumes that processed grid folder and writes the solver-facing artifacts, most notably `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and a refreshed `nminus1_definition.json`.
+
+If an `action_set.json` is already present before `load_grid` runs, its `pst_ranges[*].pst_group` values are reused to keep grouped PSTs synchronized. Missing PST group entries fall back to one group per controllable PST.
### Step 2: Perform an optimization
diff --git a/packages/contingency_analysis_pkg/README.md b/packages/contingency_analysis_pkg/README.md
index 9408abd26..fd2b2cdf4 100644
--- a/packages/contingency_analysis_pkg/README.md
+++ b/packages/contingency_analysis_pkg/README.md
@@ -8,5 +8,5 @@ The Contingency Analysis package provides the implementation to the [`Load-flow-
- Compute metrics (polars only) for the results using [`compute_metrics`][toop_engine_contingency_analysis.ac_loadflow_service.compute_metrics]
- Direct entrypoint for PyPowSyBl: [`run_contingency_analysis_powsybl`][toop_engine_contingency_analysis.pypowsybl.contingency_analysis_powsybl.run_contingency_analysis_powsybl]
- Direct entrypoint for Pandapower: [`run_contingency_analysis_pandapower`][toop_engine_contingency_analysis.pandapower.contingency_analysis_pandapower.run_contingency_analysis_pandapower]
-- Contingency definition is created during the import process: [`convert_file`][toop_engine_importer.pypowsybl_import.preprocessing.convert_file]
+- The contingency definition used downstream is first created during [`convert_file`][toop_engine_importer.pypowsybl_import.preprocessing.convert_file] and then refreshed during [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] after preprocessing filters have been applied.
- Manually: Create a converter from you Contingency list to the [`Nminus1Definition`][toop_engine_interfaces.nminus1_definition.Nminus1Definition]
diff --git a/packages/dc_solver_pkg/README.md b/packages/dc_solver_pkg/README.md
index 72aa4369c..207d96b4b 100644
--- a/packages/dc_solver_pkg/README.md
+++ b/packages/dc_solver_pkg/README.md
@@ -32,7 +32,9 @@ There are various concepts that are required to grasp in order to make sense of
## Preprocessing
-The solver can not directly work with grid data in common grid formats (ucte, cgmes, etc). Instead, it needs to load the relevant information for loadflow computations from a backend. Currently, the supported backends are [pandapower](https://pandapower.readthedocs.io/) and [powsybl](https://powsybl.org). During a preprocessing step, the solver will compute the PTDF matrix and other relevant information for the solver. The aim of the preprocessing is to obtain a [`StaticInformation`][toop_engine_dc_solver.jax.types.StaticInformation] dataclass with relevant information for the loadflow solving and a [`NetworkData`][toop_engine_dc_solver.preprocess.network_data.NetworkData] dataclass with additional information useful for postprocessing.
+The solver can not directly work with grid data in common grid formats (ucte, cgmes, etc). Instead, it needs to load the relevant information for loadflow computations from a backend. Currently, the supported backends are [pandapower](https://pandapower.readthedocs.io/) and [powsybl](https://powsybl.org). Standard usage points [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] at a processed grid folder that already contains the backend grid snapshot, masks, loadflow parameters, topology metadata, and an initial contingency definition. During preprocessing, the solver computes the PTDF matrix and other relevant information, then persists `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, and a refreshed `nminus1_definition.json`.
+
+If `action_set.json` already defines `pst_ranges[*].pst_group`, grouped PSTs are preserved through preprocessing, clipped to their common tap domain, and written back explicitly for downstream tools.
Read more on the [`preprocessing page`](https://eliagroup.github.io/ToOp/dc_solver/preprocessing/).
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 4315e69e2..1cce54d47 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
@@ -313,6 +313,13 @@ class NetworkData:
parallel_pst_group_mask: Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]] = None
"""Boolean masks describing groups of parallel controllable PSTs aligned with PST arrays."""
+ parallel_pst_group_ids: Optional[list[str]] = None
+ """Optional identifiers aligned one-to-one with rows of parallel_pst_group_mask.
+
+ This is per parallel PST group, not per controllable PST member. If present, its length must match
+ `parallel_pst_group_mask.shape[0]`.
+ """
+
realised_stations: Optional[list[list[Station]]] = 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
@@ -426,6 +433,7 @@ def fillna(a: np.ndarray, b: Union[np.ndarray, float]) -> np.ndarray:
phase_shift_low_tap=interface.get_phase_shift_low_taps(),
phase_shift_linearity=interface.get_phase_shift_linearity(),
parallel_pst_group_mask=interface.get_parallel_pst_group_mask(),
+ parallel_pst_group_ids=interface.get_parallel_pst_group_ids(),
busbar_outage_map=interface.get_busbar_outage_map(),
)
@@ -473,6 +481,10 @@ def assert_network_data(network_data: NetworkData) -> None:
assert np.all(network_data.parallel_pst_group_mask.sum(axis=0) == 1), (
"Each controllable PST must belong to exactly one parallel PST group"
)
+ if network_data.parallel_pst_group_ids is not None:
+ assert len(network_data.parallel_pst_group_ids) == network_data.parallel_pst_group_mask.shape[0], (
+ "parallel_pst_group_ids must contain one identifier per parallel PST group row"
+ )
# ruff: noqa: PLR0915
@@ -537,6 +549,10 @@ def validate_network_data(network_data: NetworkData) -> None:
assert np.sum(network_data.controllable_phase_shift_mask) == np.sum(network_data.controllable_pst_node_mask)
assert len(network_data.phase_shift_taps) == network_data.controllable_phase_shift_mask.sum()
assert all(len(tap) > 0 for tap in network_data.phase_shift_taps)
+ if network_data.parallel_pst_group_mask is not None:
+ assert network_data.parallel_pst_group_mask.shape[1] == network_data.controllable_phase_shift_mask.sum()
+ if network_data.parallel_pst_group_ids is not None:
+ assert len(network_data.parallel_pst_group_ids) == network_data.parallel_pst_group_mask.shape[0]
assert network_data.monitored_branch_mask.shape == (n_branch,)
assert network_data.disconnectable_branch_mask.shape == (n_branch,)
assert network_data.outaged_branch_mask.shape == (n_branch,)
@@ -694,13 +710,16 @@ def extract_action_set(network_data: NetworkData) -> ActionSet:
starting_tap=start + low, # Convert from index to absolute grid model tap position
low_tap=low,
high_tap=low + len(taps),
+ pst_group=_get_parallel_pst_group_id(network_data=network_data, pst_idx=pst_idx, branch_idx=index),
)
- for (index, start, low, taps) in zip(
- controllable_pst_indices,
- network_data.phase_shift_starting_tap_idx,
- network_data.phase_shift_low_tap,
- network_data.phase_shift_taps,
- strict=True,
+ for pst_idx, (index, start, low, taps) in enumerate(
+ zip(
+ controllable_pst_indices,
+ network_data.phase_shift_starting_tap_idx,
+ network_data.phase_shift_low_tap,
+ network_data.phase_shift_taps,
+ strict=True,
+ )
)
]
@@ -717,6 +736,23 @@ def extract_action_set(network_data: NetworkData) -> ActionSet:
)
+def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: Int, branch_idx: Int) -> str:
+ """Return the persisted PST group id for one controllable PST.
+
+ If no parallel PST group information is available, or if the PST does not belong to any
+ parallel group, return the branch id as default.
+ """
+ if network_data.parallel_pst_group_mask is None or network_data.parallel_pst_group_ids is None:
+ return str(network_data.branch_ids[branch_idx])
+
+ group_membership = network_data.parallel_pst_group_mask[:, pst_idx]
+ if not np.any(group_membership):
+ return str(network_data.branch_ids[branch_idx])
+
+ group_idx = int(np.argmax(group_membership))
+ return network_data.parallel_pst_group_ids[group_idx]
+
+
def extract_nminus1_definition(network_data: NetworkData) -> Nminus1Definition:
"""Extract an N-1 definition from a filled network data
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 71a08d8a9..1d584bf7f 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
@@ -18,7 +18,7 @@
from jaxtyping import Bool, Float, Int
from pandapower.pypower.idx_brch import F_BUS, SHIFT, T_BUS
from pandapower.pypower.makeBdc import calc_b_from_branch
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_group_mask
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_groups
from toop_engine_grid_helpers.pandapower.pandapower_helpers import (
get_dc_bus_voltage,
get_pandapower_branch_loadflow_results_sequence,
@@ -491,13 +491,21 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
return self.net.trafo.loc[controllable_trafo_mask, "tap_min"].astype(int).to_numpy(dtype=int)
- def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
- """Get the parallel PST groups aligned with the controllable PST arrays."""
- return load_or_create_parallel_pst_group_mask(
+ def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
+ """Get parallel PST grouping metadata aligned with controllable PST arrays."""
+ return load_or_create_parallel_pst_groups(
filesystem=self.data_folder_dirfs,
pst_ids=self.get_controllable_phase_shift_ids(),
)
+ def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ """Get the parallel PST groups aligned with the controllable PST arrays."""
+ return self._get_parallel_pst_groups()[0]
+
+ def get_parallel_pst_group_ids(self) -> list[str]:
+ """Get the parallel PST group ids aligned with the group mask rows."""
+ return self._get_parallel_pst_groups()[1]
+
def get_monitored_branch_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get mask of monitored branches for the reward calculation
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
index 4e4a34b7a..70db4e09c 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -1,7 +1,4 @@
-"""Helpers for loading and generating parallel PST group definitions."""
-
-import csv
-import io
+"""Helpers for loading and defaulting parallel PST group definitions."""
import numpy as np
import structlog
@@ -9,17 +6,16 @@
from fsspec import AbstractFileSystem
from jaxtyping import Bool
from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
+from toop_engine_interfaces.stored_action_set import load_action_set_fs
logger = structlog.get_logger(__name__)
-PARALLEL_PSTS_CSV_HEADERS = ("pst_id", "group")
-
-def load_or_create_parallel_pst_group_mask(
+def load_or_create_parallel_pst_groups(
filesystem: AbstractFileSystem,
pst_ids: Sequence[str | int],
-) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
- """Load the parallel PST grouping from CSV or generate a default one.
+) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
+ """Load the parallel PST grouping and preserve the configured group identifiers.
Parameters
----------
@@ -30,47 +26,44 @@ def load_or_create_parallel_pst_group_mask(
Returns
-------
- Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]
- A boolean group mask aligned with ``pst_ids``. Each column belongs to exactly one group.
+ tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"],
+ list[str]]
+ A boolean group mask aligned with ``pst_ids``. Each row corresponds to a group.
+ A list of group identifiers corresponding to the rows of the mask.
"""
pst_id_list = [str(pst_id) for pst_id in pst_ids]
if not pst_id_list:
- return np.zeros((0, 0), dtype=bool)
+ return np.zeros((0, 0), dtype=bool), []
- file_path = PREPROCESSING_PATHS["parallel_psts_file_path"]
+ file_path = PREPROCESSING_PATHS["action_set_file_path"]
if not filesystem.exists(file_path):
logger.warning(
- "parallel_psts.csv is missing. Generating a default file with one group per PST.",
+ "action_set.json is missing. Using a default PST group mapping with one group per PST.",
file_path=file_path,
)
- _write_default_parallel_psts_csv(filesystem=filesystem, file_path=file_path, pst_ids=pst_id_list)
- return np.eye(len(pst_id_list), dtype=bool)
+ return np.eye(len(pst_id_list), dtype=bool), pst_id_list.copy()
- with filesystem.open(file_path, "r", encoding="utf-8") as file:
- csv_content = file.read()
+ action_set = load_action_set_fs(filesystem=filesystem, json_file_path=file_path, diff_file_path=None)
+ pst_to_group = _build_pst_group_mapping(
+ pst_groups=[(str(pst.id), pst.pst_group or str(pst.id)) for pst in action_set.pst_ranges],
+ file_path=file_path,
+ )
- if not csv_content.strip():
+ missing_pst_ids = [pst_id for pst_id in pst_id_list if pst_id not in pst_to_group]
+ if missing_pst_ids:
logger.warning(
- (
- "parallel_psts.csv is empty. Generating a default file with one group per PST. "
- "Fill parallel_psts.csv with PST id and group name columns to configure parallel optimization groups."
- ),
+ "action_set.json does not define groups for all controllable PST ids. "
+ "Falling back to one group per missing PST.",
file_path=file_path,
+ pst_ids=missing_pst_ids,
)
- _write_default_parallel_psts_csv(filesystem=filesystem, file_path=file_path, pst_ids=pst_id_list)
- return np.eye(len(pst_id_list), dtype=bool)
-
- pst_group_rows = _read_parallel_pst_rows(csv_content=csv_content, file_path=file_path)
- pst_to_group = _build_pst_group_mapping(pst_group_rows=pst_group_rows, file_path=file_path)
-
- missing_pst_ids = [pst_id for pst_id in pst_id_list if pst_id not in pst_to_group]
- if missing_pst_ids:
- raise ValueError(f"parallel_psts.csv does not contain all controllable PST ids. Missing ids: {missing_pst_ids}.")
+ for pst_id in missing_pst_ids:
+ pst_to_group[pst_id] = pst_id
unknown_pst_ids = [pst_id for pst_id in pst_to_group if pst_id not in set(pst_id_list)]
if unknown_pst_ids:
logger.warning(
- "parallel_psts.csv contains PST ids that are not part of the controllable PST set. Ignoring them.",
+ "action_set.json contains PST ids that are not part of the controllable PST set. Ignoring them.",
file_path=file_path,
pst_ids=unknown_pst_ids,
)
@@ -87,16 +80,38 @@ def load_or_create_parallel_pst_group_mask(
for pst_index, pst_id in enumerate(pst_id_list):
parallel_pst_group_mask[group_index_by_name[pst_to_group[pst_id]], pst_index] = True
+ return parallel_pst_group_mask, ordered_group_names
+
+
+def load_or_create_parallel_pst_group_mask(
+ filesystem: AbstractFileSystem,
+ pst_ids: Sequence[str | int],
+) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ """Load the parallel PST grouping from action_set.json or generate a default one.
+
+ Parameters
+ ----------
+ filesystem : AbstractFileSystem
+ Filesystem rooted at the preprocessing directory.
+ pst_ids : Sequence[str | int]
+ Ordered controllable PST ids the output mask should align with.
+
+ Returns
+ -------
+ Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]
+ A boolean group mask aligned with ``pst_ids``. Each column belongs to exactly one group.
+ """
+ parallel_pst_group_mask, _ = load_or_create_parallel_pst_groups(filesystem=filesystem, pst_ids=pst_ids)
return parallel_pst_group_mask
-def _build_pst_group_mapping(pst_group_rows: list[tuple[str, str]], file_path: str) -> dict[str, str]:
+def _build_pst_group_mapping(pst_groups: list[tuple[str, str]], file_path: str) -> dict[str, str]:
"""Build a mapping from PST id to group name and check for duplicates.
Parameters
----------
- pst_group_rows : list[tuple[str, str]]
- List of (pst_id, group_name) tuples parsed from the CSV.
+ pst_groups : list[tuple[str, str]]
+ List of (pst_id, group_name) tuples parsed from the action set.
file_path : str
Path to the CSV file, used for error messages.
@@ -112,41 +127,8 @@ def _build_pst_group_mapping(pst_group_rows: list[tuple[str, str]], file_path: s
"""
pst_to_group: dict[str, str] = {}
- for pst_id, group_name in pst_group_rows:
+ for pst_id, group_name in pst_groups:
if pst_id in pst_to_group:
raise ValueError(f"Duplicate PST id '{pst_id}' found in {file_path}.")
pst_to_group[pst_id] = group_name
return pst_to_group
-
-
-def _read_parallel_pst_rows(csv_content: str, file_path: str) -> list[tuple[str, str]]:
- """Parse the parallel PST CSV into ordered rows."""
- reader = csv.reader(io.StringIO(csv_content))
- rows = [row for row in reader if any(cell.strip() for cell in row)]
- if not rows:
- return []
-
- header = rows[0]
- if len(header) < 2:
- raise ValueError(f"{file_path} must contain at least two columns for PST id and group, got header {header}.")
-
- parsed_rows: list[tuple[str, str]] = []
- for row in rows[1:]:
- if len(row) < 2:
- raise ValueError(f"Every row in {file_path} must contain PST id and group, got row {row}.")
- pst_id = row[0].strip()
- group_name = row[1].strip()
- if not pst_id or not group_name:
- raise ValueError(f"Every row in {file_path} must have non-empty PST id and group values, got row {row}.")
- parsed_rows.append((pst_id, group_name))
-
- return parsed_rows
-
-
-def _write_default_parallel_psts_csv(filesystem: AbstractFileSystem, file_path: str, pst_ids: Sequence[str]) -> None:
- """Write a default CSV where each PST is its own optimization group."""
- with filesystem.open(file_path, "w", encoding="utf-8", newline="") as file:
- writer = csv.writer(file)
- writer.writerow(PARALLEL_PSTS_CSV_HEADERS)
- for pst_id in pst_ids:
- writer.writerow((pst_id, pst_id))
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 5a2315120..9cb478567 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
@@ -18,7 +18,7 @@
from beartype.typing import Optional, Sequence, Union
from fsspec import AbstractFileSystem
from jaxtyping import Bool, Float, Int
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_group_mask
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_groups
from toop_engine_dc_solver.preprocess.powsybl.powsybl_helpers import (
BranchModel,
get_lines,
@@ -483,13 +483,21 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
return tap_changers["low_tap"].values.astype(int)
@functools.lru_cache
- def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
- """Get the parallel PST groups aligned with the controllable PST arrays."""
- return load_or_create_parallel_pst_group_mask(
+ def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
+ """Get parallel PST grouping metadata aligned with controllable PST arrays."""
+ return load_or_create_parallel_pst_groups(
filesystem=self.data_folder_dirfs,
pst_ids=self.get_controllable_phase_shift_ids(),
)
+ def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ """Get the parallel PST groups aligned with the controllable PST arrays."""
+ return self._get_parallel_pst_groups()[0]
+
+ def get_parallel_pst_group_ids(self) -> list[str]:
+ """Get the parallel PST group ids aligned with the group mask rows."""
+ return self._get_parallel_pst_groups()[1]
+
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
"""Get a mask of relevant nodes"""
return self._get_nodes()["relevant"].values
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 a3f8dfd33..6ea8fce62 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
@@ -20,7 +20,7 @@
import numpy as np
import structlog
from beartype.typing import Callable, Optional
-from jaxtyping import Bool, Int
+from jaxtyping import Bool, Float, Int
from toop_engine_dc_solver.preprocess.action_set import (
determine_injection_topology,
enumerate_branch_actions,
@@ -563,9 +563,15 @@ def reduce_branch_dimension(network_data: NetworkData) -> NetworkData:
relevant_phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx[kept_pst_branches]
relevant_phase_shift_low_tap = network_data.phase_shift_low_tap[kept_pst_branches]
relevant_parallel_pst_group_mask = None
+ relevant_parallel_pst_group_ids = None
if network_data.parallel_pst_group_mask is not None:
relevant_parallel_pst_group_mask = network_data.parallel_pst_group_mask[:, kept_pst_branches]
- relevant_parallel_pst_group_mask = relevant_parallel_pst_group_mask[np.any(relevant_parallel_pst_group_mask, axis=1)]
+ kept_group_rows = np.any(relevant_parallel_pst_group_mask, axis=1)
+ relevant_parallel_pst_group_mask = relevant_parallel_pst_group_mask[kept_group_rows]
+ if network_data.parallel_pst_group_ids is not None:
+ relevant_parallel_pst_group_ids = [
+ group_id for group_id, keep in zip(network_data.parallel_pst_group_ids, kept_group_rows, strict=True) if keep
+ ]
# PST branches carry a node injection as well, so we need to adjust the injection indices
pst_node_indices = np.flatnonzero(network_data.controllable_pst_node_mask)
# Assert that the number of PST branches and nodes is the same
@@ -600,6 +606,7 @@ def reduce_branch_dimension(network_data: NetworkData) -> NetworkData:
phase_shift_starting_tap_idx=relevant_phase_shift_starting_tap_idx,
phase_shift_low_tap=relevant_phase_shift_low_tap,
parallel_pst_group_mask=relevant_parallel_pst_group_mask,
+ parallel_pst_group_ids=relevant_parallel_pst_group_ids,
controllable_pst_node_mask=kept_controllable_pst_node_mask,
monitored_branch_mask=network_data.monitored_branch_mask[relevant_branches],
disconnectable_branch_mask=network_data.disconnectable_branch_mask[relevant_branches],
@@ -1293,6 +1300,7 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
)
pst_linearity = network_data.phase_shift_linearity
parallel_pst_group_mask = network_data.parallel_pst_group_mask
+ parallel_pst_group_ids = network_data.parallel_pst_group_ids
if parallel_pst_group_mask is not None:
assert parallel_pst_group_mask.shape[1] == pst_linearity.shape[0], (
"Parallel PST group mask must align with controllable PST linearity information."
@@ -1303,10 +1311,15 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
if np.any(mixed_parallel_groups):
raise ValueError(
"Parallel PST groups cannot mix linear and non-linear controllable PSTs when grouped optimization data "
- "is prepared. Update parallel_psts.csv so each group only contains linear or only non-linear PSTs."
+ "is prepared. Update action_set.json so each group only contains linear or only non-linear PSTs."
)
parallel_pst_group_mask = parallel_pst_group_mask[:, pst_linearity]
- parallel_pst_group_mask = parallel_pst_group_mask[np.any(parallel_pst_group_mask, axis=1)]
+ kept_group_rows = np.any(parallel_pst_group_mask, axis=1)
+ parallel_pst_group_mask = parallel_pst_group_mask[kept_group_rows]
+ if parallel_pst_group_ids is not None:
+ parallel_pst_group_ids = [
+ group_id for group_id, keep in zip(parallel_pst_group_ids, kept_group_rows, strict=True) if keep
+ ]
phase_shift_low_tap = network_data.phase_shift_low_tap[pst_linearity]
phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx[pst_linearity]
@@ -1315,14 +1328,20 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
for group_idx, group_mask in enumerate(parallel_pst_group_mask):
if np.sum(group_mask) <= 1:
continue
- group_starting_taps = phase_shift_starting_tap_idx[group_mask]
+ group_starting_taps = phase_shift_low_tap[group_mask] + phase_shift_starting_tap_idx[group_mask]
if not np.all(group_starting_taps == group_starting_taps[0]):
logger.warning(
"Parallel PST group members do not share the same starting tap. "
- "Grouped optimization will use a shared delta and clip individually.",
+ "Grouped optimization will use a shared delta after clipping members to the shared tap domain.",
group_index=group_idx,
starting_taps=group_starting_taps.tolist(),
)
+ phase_shift_low_tap, phase_shift_starting_tap_idx, phase_shift_taps = _clip_parallel_pst_group_ranges(
+ parallel_pst_group_mask=parallel_pst_group_mask,
+ phase_shift_low_tap=phase_shift_low_tap,
+ phase_shift_starting_tap_idx=phase_shift_starting_tap_idx,
+ phase_shift_taps=phase_shift_taps,
+ )
controllable_pst_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
controllable_phase_shift_mask = np.zeros_like(network_data.controllable_phase_shift_mask, dtype=bool)
@@ -1335,9 +1354,67 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
phase_shift_taps=phase_shift_taps,
phase_shift_linearity=np.ones_like(phase_shift_low_tap, dtype=bool),
parallel_pst_group_mask=parallel_pst_group_mask,
+ parallel_pst_group_ids=parallel_pst_group_ids,
)
+def _clip_parallel_pst_group_ranges(
+ parallel_pst_group_mask: Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"],
+ phase_shift_low_tap: Int[np.ndarray, " n_controllable_pst"],
+ phase_shift_starting_tap_idx: Int[np.ndarray, " n_controllable_pst"],
+ phase_shift_taps: list[Float[np.ndarray, " n_tap_positions"]],
+) -> tuple[
+ Int[np.ndarray, " n_controllable_pst"],
+ Int[np.ndarray, " n_controllable_pst"],
+ list[Float[np.ndarray, " n_tap_positions"]],
+]:
+ """Clip PST tap domains to the shared interval of each configured parallel group."""
+ clipped_low_tap = phase_shift_low_tap.copy()
+ clipped_starting_tap_idx = phase_shift_starting_tap_idx.copy()
+ clipped_taps = [np.array(taps, copy=True) for taps in phase_shift_taps]
+
+ for group_idx, group_mask in enumerate(parallel_pst_group_mask):
+ group_members = np.flatnonzero(group_mask)
+ if len(group_members) <= 1:
+ continue
+
+ group_low_tap = max(int(clipped_low_tap[pst_idx]) for pst_idx in group_members)
+ group_high_tap = min(int(clipped_low_tap[pst_idx]) + len(clipped_taps[pst_idx]) for pst_idx in group_members)
+ if group_high_tap <= group_low_tap:
+ raise ValueError(
+ "Parallel PST groups must have at least one shared tap position. "
+ f"Group {group_idx} in action_set.json has no common tap domain."
+ )
+
+ for pst_idx in group_members:
+ pst_low_tap = int(clipped_low_tap[pst_idx])
+ pst_high_tap = pst_low_tap + len(clipped_taps[pst_idx])
+ start_tap_abs = pst_low_tap + int(clipped_starting_tap_idx[pst_idx])
+ clipped_start_tap_abs = int(np.clip(start_tap_abs, group_low_tap, group_high_tap - 1))
+
+ if clipped_start_tap_abs != start_tap_abs:
+ logger.warning(
+ "Parallel PST starting tap lies outside the shared tap domain. Clipping to the group range.",
+ group_index=group_idx,
+ pst_index=int(pst_idx),
+ starting_tap=start_tap_abs,
+ clipped_starting_tap=clipped_start_tap_abs,
+ shared_low_tap=group_low_tap,
+ shared_high_tap=group_high_tap,
+ )
+
+ slice_start = group_low_tap - pst_low_tap
+ slice_end = group_high_tap - pst_low_tap
+ assert 0 <= slice_start < pst_high_tap - pst_low_tap
+ assert slice_start < slice_end <= pst_high_tap - pst_low_tap
+
+ clipped_taps[pst_idx] = clipped_taps[pst_idx][slice_start:slice_end]
+ clipped_low_tap[pst_idx] = group_low_tap
+ clipped_starting_tap_idx[pst_idx] = clipped_start_tap_abs - group_low_tap
+
+ return clipped_low_tap, clipped_starting_tap_idx, clipped_taps
+
+
def preprocess( # noqa: PLR0915
interface: BackendInterface,
logging_fn: Optional[Callable[[PreprocessStage, Optional[str]], None]] = None,
diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py
index 31893bdf7..1009046d4 100644
--- a/packages/dc_solver_pkg/tests/conftest.py
+++ b/packages/dc_solver_pkg/tests/conftest.py
@@ -1330,6 +1330,13 @@ def three_node_pst_example_data_folder(tmp_path_factory: pytest.TempPathFactory)
return tmp_path
+@pytest.fixture(scope="function")
+def three_node_pst_network_data(three_node_pst_example_data_folder: Path) -> Path:
+ tmp_path = three_node_pst_example_data_folder
+ network_data = load_network_data(tmp_path / "network_data.pkl")
+ return network_data
+
+
@pytest.fixture
def default_nodal_inj_start_options(static_information: StaticInformation):
"""Create default nodal injection start options for tests.
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
new file mode 100644
index 000000000..d787e622a
--- /dev/null
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
@@ -0,0 +1,132 @@
+# 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
+
+from datetime import datetime
+
+import numpy as np
+from fsspec.implementations.dirfs import DirFileSystem
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_group_mask
+from toop_engine_interfaces.asset_topology import Topology
+from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
+from toop_engine_interfaces.stored_action_set import ActionSet, PSTRange
+
+
+def _make_action_set(*pst_ranges: PSTRange) -> ActionSet:
+ topology = Topology.model_construct(
+ topology_id="topology",
+ grid_model_file=None,
+ name=None,
+ stations=[],
+ asset_setpoints=None,
+ timestamp=datetime.now(),
+ metrics=None,
+ )
+ return ActionSet.model_construct(
+ starting_topology=topology,
+ simplified_starting_topology=topology,
+ connectable_branches=[],
+ disconnectable_branches=[],
+ pst_ranges=list(pst_ranges),
+ hvdc_ranges=[],
+ local_actions=[],
+ )
+
+
+def test_load_parallel_pst_group_mask_defaults_to_identity_when_action_set_missing(tmp_path) -> None:
+ filesystem = DirFileSystem(str(tmp_path))
+
+ group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2"])
+
+ assert np.array_equal(group_mask, np.eye(2, dtype=bool))
+
+
+def test_load_parallel_pst_group_mask_reads_groups_from_action_set(tmp_path) -> None:
+ filesystem = DirFileSystem(str(tmp_path))
+ action_set = _make_action_set(
+ PSTRange(
+ id="PST1",
+ name="PST1",
+ type="TWO_WINDINGS_TRANSFORMER",
+ kind="branch",
+ starting_tap=0,
+ low_tap=-30,
+ high_tap=31,
+ pst_group="group_a",
+ ),
+ PSTRange(
+ id="PST2",
+ name="PST2",
+ type="TWO_WINDINGS_TRANSFORMER",
+ kind="branch",
+ starting_tap=0,
+ low_tap=-30,
+ high_tap=31,
+ pst_group="group_a",
+ ),
+ PSTRange(
+ id="PST3",
+ name="PST3",
+ type="TWO_WINDINGS_TRANSFORMER",
+ kind="branch",
+ starting_tap=0,
+ low_tap=-20,
+ high_tap=21,
+ pst_group="group_b",
+ ),
+ )
+ with filesystem.open(PREPROCESSING_PATHS["action_set_file_path"], "w", encoding="utf-8") as file:
+ file.write(action_set.model_dump_json())
+
+ group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2", "PST3"])
+
+ expected = np.array([[True, True, False], [False, False, True]], dtype=bool)
+ assert np.array_equal(group_mask, expected)
+
+
+def test_load_parallel_pst_group_mask_defaults_missing_pst_entries(tmp_path) -> None:
+ filesystem = DirFileSystem(str(tmp_path))
+ action_set = _make_action_set(
+ PSTRange(
+ id="PST1",
+ name="PST1",
+ type="TWO_WINDINGS_TRANSFORMER",
+ kind="branch",
+ starting_tap=0,
+ low_tap=-30,
+ high_tap=31,
+ pst_group="group_a",
+ )
+ )
+ with filesystem.open(PREPROCESSING_PATHS["action_set_file_path"], "w", encoding="utf-8") as file:
+ file.write(action_set.model_dump_json())
+
+ group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2"])
+
+ expected = np.array([[True, False], [False, True]], dtype=bool)
+ assert np.array_equal(group_mask, expected)
+
+
+def test_load_parallel_pst_group_mask_defaults_and_fills_missing_pst_entries(tmp_path) -> None:
+ filesystem = DirFileSystem(str(tmp_path))
+ action_set = _make_action_set(
+ PSTRange(
+ id="PST1",
+ name="PST1",
+ type="TWO_WINDINGS_TRANSFORMER",
+ kind="branch",
+ starting_tap=0,
+ low_tap=-30,
+ high_tap=31,
+ )
+ )
+ with filesystem.open(PREPROCESSING_PATHS["action_set_file_path"], "w", encoding="utf-8") as file:
+ file.write(action_set.model_dump_json())
+
+ group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2"])
+
+ expected = np.array([[True, False], [False, True]], dtype=bool)
+ assert np.array_equal(group_mask, expected)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
index ad79ba33c..7645cd5d9 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
@@ -16,6 +16,7 @@
from beartype.typing import Optional, get_args
from fsspec.implementations.dirfs import DirFileSystem
from pandapower.pypower.makePTDF import makePTDF
+from tests.network_data_pickle import load_network_data
from toop_engine_dc_solver.jax.inputs import (
load_static_information,
save_static_information,
@@ -53,6 +54,7 @@
compute_separation_set_for_stations,
convert_multi_outages,
exclude_bridges_from_outage_masks,
+ exclude_nonlinear_psts_from_controllable,
filter_disconnectable_branches_nminus2,
filter_inactive_injections,
filter_relevant_nodes_branch_count,
@@ -172,6 +174,77 @@ def test_add_nodal_injections_to_network_data(data_folder: str, network_data: Ne
assert len(network_data.relevant_node_mask) == len(nodal_injections)
+def test_exclude_nonlinear_psts_from_controllable_clips_parallel_group_ranges(
+ complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
+) -> None:
+ """Verify that grouped linear PSTs are clipped to their shared tap domain.
+
+ This uses a fixture with exactly two controllable PSTs. The PST-specific arrays already refer to
+ controllable PST order, so the test only overrides PST-local tap metadata and the single group row.
+ """
+ grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder
+ network_data = load_network_data(grid_folder / "network_data.pkl")
+ assert network_data.controllable_phase_shift_mask.sum() == 2
+
+ network_data = replace(
+ network_data,
+ phase_shift_linearity=np.array([True, True]),
+ phase_shift_low_tap=np.array([0, 1]),
+ phase_shift_starting_tap_idx=np.array([3, 1]),
+ phase_shift_taps=[np.array([0.0, 1.0, 2.0, 3.0]), np.array([10.0, 11.0])],
+ parallel_pst_group_mask=np.array([[True, True]], dtype=bool),
+ parallel_pst_group_ids=["group_1"],
+ )
+
+ updated_network_data = exclude_nonlinear_psts_from_controllable(network_data)
+
+ assert np.array_equal(updated_network_data.phase_shift_low_tap, np.array([1, 1]))
+ assert np.array_equal(updated_network_data.phase_shift_starting_tap_idx, np.array([1, 1]))
+ assert np.array_equal(updated_network_data.phase_shift_taps[0], np.array([1.0, 2.0]))
+ assert np.array_equal(updated_network_data.phase_shift_taps[1], np.array([10.0, 11.0]))
+
+
+def test_exclude_nonlinear_psts_from_controllable_rejects_empty_parallel_group_range(
+ complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
+) -> None:
+ """Verify that grouped PSTs fail fast when their tap domains have no shared interval."""
+ grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder
+ network_data = load_network_data(grid_folder / "network_data.pkl")
+ assert network_data.controllable_phase_shift_mask.sum() == 2
+
+ network_data = replace(
+ network_data,
+ phase_shift_linearity=np.array([True, True]),
+ phase_shift_low_tap=np.array([0, 3]),
+ phase_shift_starting_tap_idx=np.array([0, 0]),
+ phase_shift_taps=[np.array([0.0, 1.0]), np.array([10.0, 11.0])],
+ parallel_pst_group_mask=np.array([[True, True]], dtype=bool),
+ parallel_pst_group_ids=["group_1"],
+ )
+
+ with pytest.raises(ValueError, match="no common tap domain"):
+ exclude_nonlinear_psts_from_controllable(network_data)
+
+
+def test_exclude_nonlinear_psts_from_controllable_rejects_mixed_parallel_group_linearity(
+ complex_grid_battery_hvdc_svc_3w_trafo_linear_0_1_data_folder: Path,
+) -> None:
+ """Verify that a configured parallel PST group cannot mix linear and non-linear members."""
+ grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_0_1_data_folder
+ network_data = load_network_data(grid_folder / "network_data.pkl")
+ assert network_data.controllable_phase_shift_mask.sum() == 2
+
+ network_data = replace(
+ network_data,
+ phase_shift_linearity=np.array([False, True]),
+ parallel_pst_group_mask=np.array([[True, True]], dtype=bool),
+ parallel_pst_group_ids=["group_1"],
+ )
+
+ with pytest.raises(ValueError, match="cannot mix linear and non-linear"):
+ exclude_nonlinear_psts_from_controllable(network_data)
+
+
def test_compute_bridging_branches(data_folder: str, network_data: NetworkData) -> None:
np.random.seed(0)
num_test_branches = 100
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..a1c1b298f 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
@@ -5,6 +5,9 @@
# 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 pytest
from fsspec.implementations.dirfs import DirFileSystem
from toop_engine_dc_solver.example_grids import case30_with_psts_pandapower
@@ -99,3 +102,50 @@ def test_write_aux_data_pst_ranges(tmp_path_factory: pytest.TempPathFactory) ->
assert len(action_set.local_actions) == sum(len(x) for x in network_data.branch_action_set)
assert len(action_set.pst_ranges) == network_data.controllable_phase_shift_mask.sum()
+
+
+def test_write_aux_data_persists_pst_groups_and_clipped_ranges(
+ network_data_preprocessed: NetworkData,
+ tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+ tmp_path = tmp_path_factory.mktemp("test_write_aux_data_parallel_pst_groups")
+
+ controllable_phase_shift_mask = network_data_preprocessed.controllable_phase_shift_mask.copy()
+ controllable_phase_shift_mask[:] = False
+ controllable_phase_shift_mask[:2] = True
+
+ phase_shift_mask = network_data_preprocessed.phase_shift_mask.copy()
+ phase_shift_mask[:2] = True
+
+ branch_ids = network_data_preprocessed.branch_ids.copy()
+ branch_names = network_data_preprocessed.branch_names.copy()
+ branch_types = network_data_preprocessed.branch_types.copy()
+ branch_ids[:2] = ["PST1", "PST2"]
+ branch_names[:2] = ["PST1", "PST2"]
+ branch_types[:2] = ["TWO_WINDINGS_TRANSFORMER", "TWO_WINDINGS_TRANSFORMER"]
+
+ network_data = replace(
+ network_data_preprocessed,
+ controllable_phase_shift_mask=controllable_phase_shift_mask,
+ phase_shift_mask=phase_shift_mask,
+ phase_shift_taps=[np.array([1.0, 2.0]), np.array([10.0, 11.0])],
+ phase_shift_starting_tap_idx=np.array([1, 1]),
+ phase_shift_low_tap=np.array([5, 5]),
+ phase_shift_linearity=np.array([True, True]),
+ parallel_pst_group_mask=np.array([[True, True]], dtype=bool),
+ parallel_pst_group_ids=["shared_group"],
+ branch_ids=branch_ids,
+ branch_names=branch_names,
+ branch_types=branch_types,
+ )
+
+ write_aux_data(tmp_path, network_data)
+ action_set = load_action_set(
+ tmp_path / PREPROCESSING_PATHS["action_set_file_path"],
+ tmp_path / PREPROCESSING_PATHS["action_set_diff_path"],
+ )
+
+ assert [pst_range.pst_group for pst_range in action_set.pst_ranges] == ["shared_group", "shared_group"]
+ assert [pst_range.low_tap for pst_range in action_set.pst_ranges] == [5, 5]
+ assert [pst_range.high_tap for pst_range in action_set.pst_ranges] == [7, 7]
+ assert [pst_range.starting_tap for pst_range in action_set.pst_ranges] == [6, 6]
diff --git a/packages/importer_pkg/README.md b/packages/importer_pkg/README.md
index 781c8725c..c9084f1d7 100644
--- a/packages/importer_pkg/README.md
+++ b/packages/importer_pkg/README.md
@@ -25,4 +25,8 @@ The Importer package is organized into several focused modules, each addressing
Main entry point: [`convert_file`][toop_engine_importer.pypowsybl_import.preprocessing.convert_file]
+`convert_file` writes the processed grid folder consumed by the DC solver: the normalized backend grid snapshot, masks, loadflow parameters, importer auxiliary data, asset topology metadata, and an initial `nminus1_definition.json`.
+
+The downstream [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] step augments that same folder with `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the final filtered contingency definition used during optimization.
+
TODO: add Importer example
diff --git a/packages/interfaces_pkg/README.md b/packages/interfaces_pkg/README.md
index 344074851..8c2192458 100644
--- a/packages/interfaces_pkg/README.md
+++ b/packages/interfaces_pkg/README.md
@@ -18,7 +18,7 @@ Implementations can be found in the repository: [`powsybl_backend.py`][toop_engi
[N-minus-1 Definition][toop_engine_interfaces.nminus1_definition] - Defines contingency scenarios for reliability analysis where one component is out of service.
-[Stored Action Set][toop_engine_interfaces.stored_action_set] - Contains pre-computed optimization actions and topology configurations for the optimizer.
+[Stored Action Set][toop_engine_interfaces.stored_action_set] - Contains pre-computed optimization actions and controllable asset ranges, including PST grouping metadata used to keep grouped PSTs synchronized.
[Types][toop_engine_interfaces.types] - Provides type definitions and metric types used throughout the optimization engine.
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
index 3ee838e1c..908b860f0 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
@@ -313,6 +313,10 @@ def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_
"""
return None
+ def get_parallel_pst_group_ids(self) -> Optional[list[str]]:
+ """Get PST group identifiers aligned with rows of get_parallel_pst_group_mask()."""
+ return None
+
@abstractmethod
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
"""Get true if a node is part of the relevant nodes
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
index 880a52cd9..57091ce20 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
@@ -17,7 +17,6 @@
PREPROCESSING_PATHS: Final[dict[str, str]] = {
"grid_file_path_powsybl": "grid.xiidm",
"grid_file_path_pandapower": "grid.json",
- "parallel_psts_file_path": "parallel_psts.csv",
"masks_path": "masks",
"static_information_file_path": "static_information.hdf5",
"importer_auxiliary_file_path": "importer_auxiliary_data.json",
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..aad500846 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
@@ -66,6 +66,19 @@ class PSTRange(GridElement):
high_tap: int
"""The highest tap the PST supports"""
+ pst_group: str | None = None
+ """The optimization group of the PST.
+
+ When omitted in serialized action sets, this defaults to the PST id for backward compatibility.
+ """
+
+ @model_validator(mode="after")
+ def _default_pst_group(self) -> "PSTRange":
+ """Default missing group ids to the PST id for backward compatibility."""
+ if self.pst_group is None:
+ self.pst_group = str(self.id)
+ return self
+
class HVDCRange(GridElement):
"""High voltage direct current lines can be set within the scope of non-costly optimization.
diff --git a/packages/interfaces_pkg/tests/test_stored_action_set.py b/packages/interfaces_pkg/tests/test_stored_action_set.py
index ee23761b6..d36dfba33 100644
--- a/packages/interfaces_pkg/tests/test_stored_action_set.py
+++ b/packages/interfaces_pkg/tests/test_stored_action_set.py
@@ -15,6 +15,7 @@
from toop_engine_interfaces.asset_topology import Busbar, BusbarCoupler, Station, SwitchableAsset, Topology
from toop_engine_interfaces.stored_action_set import (
ActionSet,
+ PSTRange,
StationDiffArray,
compress_actions_to_station_diffs,
expand_station_diffs,
@@ -583,3 +584,60 @@ def test_save_and_load_action_set_split_files_roundtrip(tmp_path: Path):
np.asarray(loaded_action.asset_switching_table),
np.asarray(local_action.asset_switching_table),
)
+
+
+def test_pst_range_defaults_missing_group_to_id() -> None:
+ pst_range = PSTRange.model_validate(
+ {
+ "id": "PST1",
+ "name": "Transformer 1",
+ "type": "TWO_WINDINGS_TRANSFORMER",
+ "kind": "branch",
+ "starting_tap": 0,
+ "low_tap": -30,
+ "high_tap": 31,
+ }
+ )
+
+ assert pst_range.pst_group == "PST1"
+
+
+def test_save_and_load_action_set_preserves_pst_group(tmp_path: Path) -> None:
+ starting_topology = Topology.model_construct(
+ topology_id="starting_topology",
+ grid_model_file=None,
+ name=None,
+ stations=[],
+ asset_setpoints=None,
+ timestamp=datetime.now(),
+ metrics=None,
+ )
+
+ action_set = ActionSet.model_construct(
+ starting_topology=starting_topology,
+ simplified_starting_topology=starting_topology,
+ connectable_branches=[],
+ disconnectable_branches=[],
+ pst_ranges=[
+ PSTRange(
+ id="PST1",
+ name="Transformer 1",
+ type="TWO_WINDINGS_TRANSFORMER",
+ kind="branch",
+ starting_tap=0,
+ low_tap=-30,
+ high_tap=31,
+ pst_group="group_a",
+ )
+ ],
+ hvdc_ranges=[],
+ local_actions=[],
+ )
+
+ json_file = tmp_path / "action_set.json"
+ diff_file = tmp_path / "action_set.hdf5"
+ save_action_set(json_file, diff_file, action_set)
+
+ loaded_action_set = load_action_set(json_file, diff_file)
+
+ assert loaded_action_set.pst_ranges[0].pst_group == "group_a"
diff --git a/packages/topology_optimizer_pkg/README.md b/packages/topology_optimizer_pkg/README.md
index 3eb482328..813c5c863 100644
--- a/packages/topology_optimizer_pkg/README.md
+++ b/packages/topology_optimizer_pkg/README.md
@@ -19,8 +19,8 @@ Entry point: [`initialize_optimization`][toop_engine_topology_optimizer.dc.worke
### 2. AC Stage (Validation and Refinement)
The AC optimizer validates promising DC solutions using full AC loadflow calculations and applies further evolutionary refinements:
-- **Validation**: Full AC power flow analysis with N-1 contingency checking. Use [`optimization_loop`][toop_engine_topology_optimizer.ac.worker.optimization_loop]
-- **Evolution operators**: Pull (from DC), reconnection, coupler closing: Use [`evolution_try`][toop_engine_topology_optimizer.ac.evolution_functions.evolution_try]
+- **Validation**: Full AC power flow analysis with N-1 contingency checking. Use [`optimization_loop`][toop_engine_topology_optimizer.ac.worker.optimization_loop]
+- **Evolution operators**: Pull (from DC), reconnection, coupler closing: Use [`evolution_try`][toop_engine_topology_optimizer.ac.evolution_functions.evolution_try]
- **[Early stopping](https://eliagroup.github.io/ToOp/topology_optimizer/ac/early_stopping/)**: Rejection based on worst-case overload comparison
- **[Selection strategy](https://eliagroup.github.io/ToOp/topology_optimizer/ac/select_strategy/)**: Filtering method using median, dominator, and discriminator filters
@@ -29,10 +29,10 @@ The AC optimizer validates promising DC solutions using full AC loadflow calcula
### Topology Representation
- **Actions**: List of substation switching indices from the [`ActionSet`][toop_engine_dc_solver.preprocess.action_set]
- **Disconnections**: Branch outage specifications for N-1 analysis
-- **PST Setpoints**: Phase-shifting transformer positions
+- **PST Setpoints**: Phase-shifting transformer positions and groups as described in the [`ActionSet`][toop_engine_dc_solver.preprocess.action_set]
- **Metrics**: Multi-objective fitness values and constraint violations
-### Strategy Collections
+### Strategy Collections
- **DC Repertoire**: Map-Elites grid maintaining diversity across descriptor dimensions
- **AC Database**: SQLite storage for validated topologies with loadflow references
- **Message Protocols**: Standardized formats for inter-optimizer communication
@@ -41,8 +41,8 @@ The AC optimizer validates promising DC solutions using full AC loadflow calcula
The optimization process requires preprocessed grid data from the **[`Importer`](https://eliagroup.github.io/ToOp/importer/intro/)** package:
1. **Static Information**: Grid electrical parameters and topology
-2. **Action Set**: Enumerated switching possibilities
-3. **N-1 Definition**: Contingency analysis specifications
+2. **Action Set**: Enumerated switching possibilities and controllable asset ranges. For PSTs, `pst_ranges[*].pst_group` keeps grouped transformers synchronized during optimization.
+3. **N-1 Definition**: Contingency analysis specifications aligned with the preprocessed network data
## Running an Optimization
@@ -57,7 +57,7 @@ cd packages/topology_optimizer_pkg
python -m topology_optimizer.dc.worker.worker \
--processed_gridfile_folder=/path/to/preprocessed/data
-# Launch AC optimizer worker
+# Launch AC optimizer worker
python -m topology_optimizer.ac.worker \
--processed_gridfile_folder=/path/to/preprocessed/data \
--loadflow_result_folder=/path/to/results
@@ -77,7 +77,7 @@ params = DCOptimizerParameters(
# Initialize and run optimization
optimizer_data, stats, initial_strategy = initialize_optimization(
params=params,
- optimization_id="my_optimization",
+ optimization_id="my_optimization",
static_information_files=tuple(["grid_data.pkl"])
)
```
@@ -85,6 +85,6 @@ optimizer_data, stats, initial_strategy = initialize_optimization(
## Advanced Topics
- **[AC Selection Strategy](https://eliagroup.github.io/ToOp/topology_optimizer/ac/select_strategy/)**: Sophisticated filtering for AC candidate selection
-- **[Early Stopping](https://eliagroup.github.io/ToOp/topology_optimizer/ac/early_stopping/)**: Efficient topology rejection in N-1 analysis
+- **[Early Stopping](https://eliagroup.github.io/ToOp/topology_optimizer/ac/early_stopping/)**: Efficient topology rejection in N-1 analysis
- **[DC Solver Configuration](https://eliagroup.github.io/ToOp/dc_solver/intro/)**: GPU optimization and batch processing parameters
- **[Interface Definitions](https://eliagroup.github.io/ToOp/interfaces/intro/)**: Data structure specifications and message protocols
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
index 0b281550a..debffa54b 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
@@ -82,11 +82,22 @@ class NodalInjectionMutationConfig(eqx.Module):
enable_parallel_pst_group_optim: bool = eqx.field(static=True, default=False)
"""Whether PST mutations should be sampled once per configured parallel group."""
- parallel_pst_group_mask: Optional[Bool[Array, " n_parallel_pst_groups n_controllable_pst"]] = eqx.field(
+ parallel_pst_group_mask: Optional[Bool[Array, " n_parallel_pst_groups n_parallel_pst_group_width"]] = eqx.field(
static=True, default=None
)
"""Boolean masks that map each controllable PST to exactly one parallel-optimization group."""
+ def __post_init__(self) -> None:
+ """Validate parallel PST grouping metadata only when grouped optimization is enabled."""
+ if not self.enable_parallel_pst_group_optim:
+ return
+
+ if self.parallel_pst_group_mask is None:
+ raise ValueError("parallel_pst_group_mask must be provided when grouped PST optimization is enabled.")
+
+ if self.parallel_pst_group_mask.shape[1] != self.pst_n_taps.shape[0]:
+ raise ValueError("parallel_pst_group_mask must align with pst_n_taps when grouped PST optimization is enabled.")
+
class MutationConfig(eqx.Module):
"""Configuration for the mutation operation."""
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
index d2a0c139c..8ca76007f 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
@@ -130,6 +130,9 @@ def mutate_nodal_injections(
batch_size = nodal_inj_info.pst_tap_idx.shape[0]
n_timesteps = nodal_inj_info.pst_tap_idx.shape[1]
random_key = jax.random.split(random_key, (batch_size, n_timesteps))
+ parallel_pst_group_mask = nodal_mutation_config.parallel_pst_group_mask
+ if not nodal_mutation_config.enable_parallel_pst_group_optim or parallel_pst_group_mask is None:
+ parallel_pst_group_mask = None
# vmap to mutate the PST taps for each timestep + batch independently
new_pst_taps = jax.vmap(
@@ -142,7 +145,7 @@ def mutate_nodal_injections(
pst_mutation_probability=nodal_mutation_config.pst_mutation_probability,
pst_reset_probability=nodal_mutation_config.pst_reset_probability,
enable_parallel_pst_group_optim=nodal_mutation_config.enable_parallel_pst_group_optim,
- parallel_pst_group_mask=nodal_mutation_config.parallel_pst_group_mask,
+ parallel_pst_group_mask=parallel_pst_group_mask,
)
)
)(
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
index e665cb7ce..0a459dc6a 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
@@ -7,7 +7,12 @@
import jax
import jax.numpy as jnp
-from toop_engine_topology_optimizer.dc.genetic_functions.mutation.mutate_nodal_inj import mutate_psts
+from toop_engine_dc_solver.jax.types import NodalInjOptimResults
+from toop_engine_topology_optimizer.dc.genetic_functions.mutation.config import NodalInjectionMutationConfig
+from toop_engine_topology_optimizer.dc.genetic_functions.mutation.mutate_nodal_inj import (
+ mutate_nodal_injections,
+ mutate_psts,
+)
def test_mutate_psts() -> None:
@@ -247,3 +252,24 @@ def test_grouped_mutate_psts_keeps_parallel_members_equal() -> None:
assert mutated_pst_taps[0] == mutated_pst_taps[1]
assert mutated_pst_taps[2] == mutated_pst_taps[3]
+
+
+def test_mutate_nodal_injections_ignores_empty_group_mask_when_group_optim_disabled() -> None:
+ nodal_inj_info = NodalInjOptimResults(pst_tap_idx=jnp.array([[[1, 2]]], dtype=int))
+ mutation_config = NodalInjectionMutationConfig(
+ pst_mutation_sigma=0.0,
+ pst_mutation_probability=0.0,
+ pst_reset_probability=0.0,
+ pst_n_taps=jnp.array([5, 5], dtype=int),
+ pst_start_tap_idx=jnp.array([1, 2], dtype=int),
+ enable_parallel_pst_group_optim=False,
+ parallel_pst_group_mask=jnp.zeros((0, 0), dtype=bool),
+ )
+
+ mutated = mutate_nodal_injections(
+ random_key=jax.random.PRNGKey(0),
+ nodal_inj_info=nodal_inj_info,
+ nodal_mutation_config=mutation_config,
+ )
+
+ assert mutated == nodal_inj_info
From 5850446e956cd74fbe16a56fadf205597bed6aad Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 11 Jun 2026 09:58:31 +0000
Subject: [PATCH 04/44] chore: license header
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../preprocess/parallel_pst_groups.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
index 70db4e09c..7651249fa 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -1,3 +1,10 @@
+# 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
+
"""Helpers for loading and defaulting parallel PST group definitions."""
import numpy as np
From df70ecc51be97c697a7ba17a666c0b1d94b965f5 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 11 Jun 2026 10:26:50 +0000
Subject: [PATCH 05/44] fix: test
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../dc_solver_pkg/tests/preprocessing/test_preprocess.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
index 7645cd5d9..5de0fb798 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
@@ -227,10 +227,12 @@ def test_exclude_nonlinear_psts_from_controllable_rejects_empty_parallel_group_r
def test_exclude_nonlinear_psts_from_controllable_rejects_mixed_parallel_group_linearity(
- complex_grid_battery_hvdc_svc_3w_trafo_linear_0_1_data_folder: Path,
+ complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
- """Verify that a configured parallel PST group cannot mix linear and non-linear members."""
- grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_0_1_data_folder
+ """Verify that a configured parallel PST group cannot mix linear and non-linear members.
+
+ We load two linear PSTs but then configure one of them as non-linear to trigger the validation error."""
+ grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder
network_data = load_network_data(grid_folder / "network_data.pkl")
assert network_data.controllable_phase_shift_mask.sum() == 2
From 21b0f1a828f9f8fbb5546bd326e879da8ebbd9f7 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 11 Jun 2026 12:10:05 +0000
Subject: [PATCH 06/44] fix: small fixes and merges
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../preprocess/parallel_pst_groups.py | 2 +-
packages/dc_solver_pkg/tests/conftest.py | 7 -------
.../dc/genetic_functions/mutation/config.py | 2 +-
.../dc/genetic_functions/mutation/mutate_nodal_inj.py | 2 +-
4 files changed, 3 insertions(+), 10 deletions(-)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
index 7651249fa..712f339b6 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -120,7 +120,7 @@ def _build_pst_group_mapping(pst_groups: list[tuple[str, str]], file_path: str)
pst_groups : list[tuple[str, str]]
List of (pst_id, group_name) tuples parsed from the action set.
file_path : str
- Path to the CSV file, used for error messages.
+ Path to the ActionSet (action_set.json) file, used for error messages.
Returns
-------
diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py
index 1009046d4..31893bdf7 100644
--- a/packages/dc_solver_pkg/tests/conftest.py
+++ b/packages/dc_solver_pkg/tests/conftest.py
@@ -1330,13 +1330,6 @@ def three_node_pst_example_data_folder(tmp_path_factory: pytest.TempPathFactory)
return tmp_path
-@pytest.fixture(scope="function")
-def three_node_pst_network_data(three_node_pst_example_data_folder: Path) -> Path:
- tmp_path = three_node_pst_example_data_folder
- network_data = load_network_data(tmp_path / "network_data.pkl")
- return network_data
-
-
@pytest.fixture
def default_nodal_inj_start_options(static_information: StaticInformation):
"""Create default nodal injection start options for tests.
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
index debffa54b..14aa3f01c 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/config.py
@@ -82,7 +82,7 @@ class NodalInjectionMutationConfig(eqx.Module):
enable_parallel_pst_group_optim: bool = eqx.field(static=True, default=False)
"""Whether PST mutations should be sampled once per configured parallel group."""
- parallel_pst_group_mask: Optional[Bool[Array, " n_parallel_pst_groups n_parallel_pst_group_width"]] = eqx.field(
+ parallel_pst_group_mask: Optional[Bool[Array, " n_parallel_pst_groups n_controllable_pst"]] = eqx.field(
static=True, default=None
)
"""Boolean masks that map each controllable PST to exactly one parallel-optimization group."""
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
index 8ca76007f..98b944c7e 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
@@ -55,7 +55,7 @@ def mutate_psts(
mask. If enabled, whole groups of PSTs defined in the parallel_pst_group_mask will be mutated together, meaning
that the mutation will be the same for all PSTs in a group.
The pst_mutation_probability and pst_reset_probability will then apply to the groups instead of individual PSTs.
- parallel_pst_group_mask: Int[Array, " n_parallel_pst_groups n_controllable_pst"] | None
+ parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"] | None
A boolean mask defining the parallel PST groups, where True indicates that a PST belongs to a group. The shape of
the mask should be (n_parallel_pst_groups, n_controllable_pst). Each column should have exactly one True value,
indicating that each PST belongs to exactly one group. If None or empty, parallel group optimization
From 68ed94c3721cbca88035878dddd5491a8acbe072 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 11 Jun 2026 18:58:36 +0000
Subject: [PATCH 07/44] refactor: proper logic
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_dc_solver/jax/nodal_inj_optim.py | 2 +-
.../dc/genetic_functions/initialization.py | 16 ++++++++++++++--
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
index ba4cfd44f..d39d92180 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
@@ -42,7 +42,7 @@ def canonicalize_parallel_pst_taps(
nodal_inj_info: NodalInjectionInformation,
) -> Int[Array, " batch_size n_timesteps n_controllable_pst"]:
"""Project PST taps onto the configured parallel-group constraint using one shared delta per group."""
- if nodal_inj_info.parallel_pst_group_mask.size == 0:
+ if nodal_inj_info.parallel_pst_group_mask is None or nodal_inj_info.parallel_pst_group_mask.shape[0] == 0:
return pst_tap_indices
group_mask = nodal_inj_info.parallel_pst_group_mask.astype(int)
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
index 4d8e5c9fc..1311838f1 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
@@ -372,8 +372,20 @@ def update_single_pair_branch_limit_information(
solver_config,
batch_size_bsdf=batch_size,
batch_size_injection=batch_size,
- enable_parallel_pst_group_optim=enable_parallel_pst_group_optim,
+ enable_parallel_pst_group_optim=enable_parallel_pst_group_optim if enable_nodal_inj_optim else False,
)
+ nodal_injection_info = None
+ if dynamic_information.nodal_injection_information is not None:
+ if not enable_nodal_inj_optim:
+ nodal_injection_info = replace(dynamic_information.nodal_injection_information, parallel_pst_group_mask=None)
+ else:
+ nodal_injection_info = replace(
+ dynamic_information.nodal_injection_information,
+ parallel_pst_group_mask=dynamic_information.nodal_injection_information.parallel_pst_group_mask
+ if enable_parallel_pst_group_optim
+ else None,
+ )
+
updated_dynamic_information = replace(
dynamic_information,
branch_limits=replace(
@@ -389,7 +401,7 @@ def update_single_pair_branch_limit_information(
else dynamic_information.branch_limits.n0_n1_max_diff
),
),
- nodal_injection_information=(dynamic_information.nodal_injection_information if enable_nodal_inj_optim else None),
+ nodal_injection_information=nodal_injection_info,
)
return updated_solver_config, updated_dynamic_information
From ff3eaca99659bd340822a211e71a58f330395f61 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 12 Jun 2026 14:02:36 +0200
Subject: [PATCH 08/44] chore: add todo
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_dc_solver/preprocess/preprocess.py | 1 +
.../src/toop_engine_importer/pypowsybl_import/powsybl_masks.py | 3 +++
2 files changed, 4 insertions(+)
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 6ea8fce62..1314e9817 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
@@ -1294,6 +1294,7 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
"""
if network_data.phase_shift_mask is None or network_data.controllable_phase_shift_mask is None:
return network_data
+
logger.info(
"Excluding nonlinear phase shifters from the controllable mask, "
"since they cannot be handled correctly in the backend."
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index a9de9008e..2f75feec5 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -538,6 +538,9 @@ def update_trafo_masks(
disconnectable_mask = disconnectable_mask & ~blacklisted_trafos
pst_controllable_mask = pst_controllable_mask & ~blacklisted_trafos
+ # TODO: Use remaining controllable PSTs to build groups of PSTs via identical parameters
+ # and same origin and destination bus
+ # TODO: Don't forget to load/create empty masks
outage_mask = outage_mask & ~blacklisted_trafos
reward_mask = reward_mask & ~blacklisted_trafos
From 566a68a3742c04275c7236c0de4529e4893754e0 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 12 Jun 2026 15:53:29 +0200
Subject: [PATCH 09/44] refactor: Parse input to identify pst groups
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../pandapower/pandapower_backend.py | 16 --
.../preprocess/parallel_pst_groups.py | 161 +++++-------------
.../preprocess/powsybl/powsybl_backend.py | 23 ++-
.../preprocess/preprocess.py | 78 +--------
.../preprocessing/test_parallel_pst_groups.py | 138 ++++-----------
.../tests/preprocessing/test_preprocess.py | 63 +++----
.../pypowsybl_import/powsybl_masks.py | 100 ++++++++++-
.../pypowsybl_import/test_powsybl_masks.py | 35 ++++
.../folder_structure.py | 1 +
.../test_mutate_nodal_inj.py | 3 +-
10 files changed, 257 insertions(+), 361 deletions(-)
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 1d584bf7f..293e2feaf 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
@@ -18,7 +18,6 @@
from jaxtyping import Bool, Float, Int
from pandapower.pypower.idx_brch import F_BUS, SHIFT, T_BUS
from pandapower.pypower.makeBdc import calc_b_from_branch
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_groups
from toop_engine_grid_helpers.pandapower.pandapower_helpers import (
get_dc_bus_voltage,
get_pandapower_branch_loadflow_results_sequence,
@@ -491,21 +490,6 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
return self.net.trafo.loc[controllable_trafo_mask, "tap_min"].astype(int).to_numpy(dtype=int)
- def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
- """Get parallel PST grouping metadata aligned with controllable PST arrays."""
- return load_or_create_parallel_pst_groups(
- filesystem=self.data_folder_dirfs,
- pst_ids=self.get_controllable_phase_shift_ids(),
- )
-
- def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
- """Get the parallel PST groups aligned with the controllable PST arrays."""
- return self._get_parallel_pst_groups()[0]
-
- def get_parallel_pst_group_ids(self) -> list[str]:
- """Get the parallel PST group ids aligned with the group mask rows."""
- return self._get_parallel_pst_groups()[1]
-
def get_monitored_branch_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get mask of monitored branches for the reward calculation
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
index 712f339b6..34335da13 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -5,137 +5,66 @@
# you can obtain one at https://mozilla.org/MPL/2.0/.
# Mozilla Public License, version 2.0
-"""Helpers for loading and defaulting parallel PST group definitions."""
+"""Helpers for building parallel PST group masks from per-PST group labels.
+
+The group membership itself is identified during importing (see ``pst_group_masks`` in
+``powsybl_masks.py``); this module only reshapes the per-PST integer labels into the boolean group
+mask consumed by the preprocessing and optimization stages.
+"""
import numpy as np
-import structlog
from beartype.typing import Sequence
-from fsspec import AbstractFileSystem
-from jaxtyping import Bool
-from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
-from toop_engine_interfaces.stored_action_set import load_action_set_fs
-
-logger = structlog.get_logger(__name__)
+from jaxtyping import Bool, Int
-def load_or_create_parallel_pst_groups(
- filesystem: AbstractFileSystem,
+def build_parallel_pst_group_mask(
+ group_labels: Int[np.ndarray, " n_controllable_pst"],
pst_ids: Sequence[str | int],
) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
- """Load the parallel PST grouping and preserve the configured group identifiers.
+ """Build the parallel PST group mask and group ids from per-PST group labels.
+
+ PSTs that share a (non-negative) label belong to the same parallel group. The sentinel ``-1``
+ marks an ungrouped PST, which is given its own singleton group, so the default with no parallel
+ PSTs reduces to one group per PST.
Parameters
----------
- filesystem : AbstractFileSystem
- Filesystem rooted at the preprocessing directory.
+ group_labels : Int[np.ndarray, " n_controllable_pst"]
+ Integer group label for each controllable PST, aligned with ``pst_ids``.
pst_ids : Sequence[str | int]
- Ordered controllable PST ids the output mask should align with.
+ Ordered controllable PST ids the output mask aligns with.
Returns
-------
- tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"],
- list[str]]
- A boolean group mask aligned with ``pst_ids``. Each row corresponds to a group.
- A list of group identifiers corresponding to the rows of the mask.
+ tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]
+ A boolean group mask with one row per distinct group (in first-seen order), where each
+ column has exactly one ``True``, and a list of group identifiers (the first member's PST id
+ per row).
"""
pst_id_list = [str(pst_id) for pst_id in pst_ids]
- if not pst_id_list:
+ n_psts = len(pst_id_list)
+ if group_labels.shape[0] != n_psts:
+ raise ValueError(f"group_labels has {group_labels.shape[0]} entries but {n_psts} controllable PST ids were given.")
+ if n_psts == 0:
return np.zeros((0, 0), dtype=bool), []
- file_path = PREPROCESSING_PATHS["action_set_file_path"]
- if not filesystem.exists(file_path):
- logger.warning(
- "action_set.json is missing. Using a default PST group mapping with one group per PST.",
- file_path=file_path,
- )
- return np.eye(len(pst_id_list), dtype=bool), pst_id_list.copy()
-
- action_set = load_action_set_fs(filesystem=filesystem, json_file_path=file_path, diff_file_path=None)
- pst_to_group = _build_pst_group_mapping(
- pst_groups=[(str(pst.id), pst.pst_group or str(pst.id)) for pst in action_set.pst_ranges],
- file_path=file_path,
- )
-
- missing_pst_ids = [pst_id for pst_id in pst_id_list if pst_id not in pst_to_group]
- if missing_pst_ids:
- logger.warning(
- "action_set.json does not define groups for all controllable PST ids. "
- "Falling back to one group per missing PST.",
- file_path=file_path,
- pst_ids=missing_pst_ids,
- )
- for pst_id in missing_pst_ids:
- pst_to_group[pst_id] = pst_id
-
- unknown_pst_ids = [pst_id for pst_id in pst_to_group if pst_id not in set(pst_id_list)]
- if unknown_pst_ids:
- logger.warning(
- "action_set.json contains PST ids that are not part of the controllable PST set. Ignoring them.",
- file_path=file_path,
- pst_ids=unknown_pst_ids,
- )
-
- ordered_group_names: list[str] = []
- group_index_by_name: dict[str, int] = {}
- for pst_id in pst_id_list:
- group_name = pst_to_group[pst_id]
- if group_name not in group_index_by_name:
- group_index_by_name[group_name] = len(ordered_group_names)
- ordered_group_names.append(group_name)
-
- parallel_pst_group_mask = np.zeros((len(ordered_group_names), len(pst_id_list)), dtype=bool)
- for pst_index, pst_id in enumerate(pst_id_list):
- parallel_pst_group_mask[group_index_by_name[pst_to_group[pst_id]], pst_index] = True
-
- return parallel_pst_group_mask, ordered_group_names
-
-
-def load_or_create_parallel_pst_group_mask(
- filesystem: AbstractFileSystem,
- pst_ids: Sequence[str | int],
-) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
- """Load the parallel PST grouping from action_set.json or generate a default one.
-
- Parameters
- ----------
- filesystem : AbstractFileSystem
- Filesystem rooted at the preprocessing directory.
- pst_ids : Sequence[str | int]
- Ordered controllable PST ids the output mask should align with.
-
- Returns
- -------
- Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]
- A boolean group mask aligned with ``pst_ids``. Each column belongs to exactly one group.
- """
- parallel_pst_group_mask, _ = load_or_create_parallel_pst_groups(filesystem=filesystem, pst_ids=pst_ids)
- return parallel_pst_group_mask
-
-
-def _build_pst_group_mapping(pst_groups: list[tuple[str, str]], file_path: str) -> dict[str, str]:
- """Build a mapping from PST id to group name and check for duplicates.
-
- Parameters
- ----------
- pst_groups : list[tuple[str, str]]
- List of (pst_id, group_name) tuples parsed from the action set.
- file_path : str
- Path to the ActionSet (action_set.json) file, used for error messages.
-
- Returns
- -------
- dict[str, str]
- Mapping from PST id to group name.
-
- Raises
- ------
- ValueError
- If a duplicate PST id is found.
-
- """
- pst_to_group: dict[str, str] = {}
- for pst_id, group_name in pst_groups:
- if pst_id in pst_to_group:
- raise ValueError(f"Duplicate PST id '{pst_id}' found in {file_path}.")
- pst_to_group[pst_id] = group_name
- return pst_to_group
+ rows = np.zeros(n_psts, dtype=int)
+ row_by_label: dict[int, int] = {}
+ group_ids: list[str] = []
+ next_row = 0
+ for pst_index, label in enumerate(int(label) for label in group_labels):
+ if label < 0:
+ # Ungrouped sentinel: each such PST forms its own singleton group.
+ rows[pst_index] = next_row
+ elif label in row_by_label:
+ rows[pst_index] = row_by_label[label]
+ continue
+ else:
+ row_by_label[label] = next_row
+ rows[pst_index] = next_row
+ group_ids.append(pst_id_list[pst_index])
+ next_row += 1
+
+ parallel_pst_group_mask = np.zeros((next_row, n_psts), dtype=bool)
+ parallel_pst_group_mask[rows, np.arange(n_psts)] = True
+ return parallel_pst_group_mask, group_ids
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 9cb478567..a35d75765 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
@@ -18,7 +18,7 @@
from beartype.typing import Optional, Sequence, Union
from fsspec import AbstractFileSystem
from jaxtyping import Bool, Float, Int
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_groups
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import build_parallel_pst_group_mask
from toop_engine_dc_solver.preprocess.powsybl.powsybl_helpers import (
BranchModel,
get_lines,
@@ -187,8 +187,10 @@ def _get_injections(self) -> pd.DataFrame:
return injections
def _get_mask(
- self, mask_filename: str, default_value: Union[bool, float], default_shape: int
- ) -> Bool[np.ndarray, " n_masked_element"] | Float[np.ndarray, " n_masked_element"]:
+ self, mask_filename: str, default_value: Union[bool, float, int], default_shape: int
+ ) -> (
+ Bool[np.ndarray, " n_masked_element"] | Float[np.ndarray, " n_masked_element"] | Int[np.ndarray, " n_masked_element"]
+ ):
"""Load a given mask or return a default mask.
Parameters
@@ -251,6 +253,8 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
trafos["pst_controllable"] = (
self._get_mask(NETWORK_MASK_NAMES["trafo_pst_controllable"], False, n_trafos) & trafos["has_pst_tap"]
)
+ # Parallel-PST group label per trafo (-1 for non-grouped). Identified during importing.
+ trafos["pst_group"] = self._get_mask(NETWORK_MASK_NAMES["pst_group_masks"], -1, n_trafos)
trafos.sort_values("name", inplace=True)
@@ -484,9 +488,16 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
@functools.lru_cache
def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
- """Get parallel PST grouping metadata aligned with controllable PST arrays."""
- return load_or_create_parallel_pst_groups(
- filesystem=self.data_folder_dirfs,
+ """Get parallel PST grouping metadata aligned with controllable PST arrays.
+
+ The per-trafo group labels are produced during importing (``pst_group_masks``); here they
+ are restricted to the controllable PSTs (in array order) and reshaped into the boolean group
+ mask consumed downstream.
+ """
+ controllable_branches = self._get_branches()[self.get_controllable_phase_shift_mask()]
+ group_labels = controllable_branches["pst_group"].to_numpy(dtype=int)
+ return build_parallel_pst_group_mask(
+ group_labels=group_labels,
pst_ids=self.get_controllable_phase_shift_ids(),
)
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 1314e9817..9cf20bcae 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
@@ -20,7 +20,7 @@
import numpy as np
import structlog
from beartype.typing import Callable, Optional
-from jaxtyping import Bool, Float, Int
+from jaxtyping import Bool, Int
from toop_engine_dc_solver.preprocess.action_set import (
determine_injection_topology,
enumerate_branch_actions,
@@ -1306,14 +1306,9 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
assert parallel_pst_group_mask.shape[1] == pst_linearity.shape[0], (
"Parallel PST group mask must align with controllable PST linearity information."
)
- linear_group_counts = parallel_pst_group_mask.astype(int) @ pst_linearity.astype(int)
- group_sizes = parallel_pst_group_mask.sum(axis=1)
- mixed_parallel_groups = (linear_group_counts > 0) & (linear_group_counts < group_sizes)
- if np.any(mixed_parallel_groups):
- raise ValueError(
- "Parallel PST groups cannot mix linear and non-linear controllable PSTs when grouped optimization data "
- "is prepared. Update action_set.json so each group only contains linear or only non-linear PSTs."
- )
+ # Parallel PSTs are grouped at import time only when they share identical tap-changer
+ # parameters, so a group is necessarily all-linear or all-nonlinear; nonlinear groups drop
+ # out entirely below when their members leave the controllable set.
parallel_pst_group_mask = parallel_pst_group_mask[:, pst_linearity]
kept_group_rows = np.any(parallel_pst_group_mask, axis=1)
parallel_pst_group_mask = parallel_pst_group_mask[kept_group_rows]
@@ -1333,16 +1328,10 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
if not np.all(group_starting_taps == group_starting_taps[0]):
logger.warning(
"Parallel PST group members do not share the same starting tap. "
- "Grouped optimization will use a shared delta after clipping members to the shared tap domain.",
+ "Grouped optimization applies a shared tap delta, then clips each member to its own tap domain.",
group_index=group_idx,
starting_taps=group_starting_taps.tolist(),
)
- phase_shift_low_tap, phase_shift_starting_tap_idx, phase_shift_taps = _clip_parallel_pst_group_ranges(
- parallel_pst_group_mask=parallel_pst_group_mask,
- phase_shift_low_tap=phase_shift_low_tap,
- phase_shift_starting_tap_idx=phase_shift_starting_tap_idx,
- phase_shift_taps=phase_shift_taps,
- )
controllable_pst_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
controllable_phase_shift_mask = np.zeros_like(network_data.controllable_phase_shift_mask, dtype=bool)
@@ -1359,63 +1348,6 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
)
-def _clip_parallel_pst_group_ranges(
- parallel_pst_group_mask: Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"],
- phase_shift_low_tap: Int[np.ndarray, " n_controllable_pst"],
- phase_shift_starting_tap_idx: Int[np.ndarray, " n_controllable_pst"],
- phase_shift_taps: list[Float[np.ndarray, " n_tap_positions"]],
-) -> tuple[
- Int[np.ndarray, " n_controllable_pst"],
- Int[np.ndarray, " n_controllable_pst"],
- list[Float[np.ndarray, " n_tap_positions"]],
-]:
- """Clip PST tap domains to the shared interval of each configured parallel group."""
- clipped_low_tap = phase_shift_low_tap.copy()
- clipped_starting_tap_idx = phase_shift_starting_tap_idx.copy()
- clipped_taps = [np.array(taps, copy=True) for taps in phase_shift_taps]
-
- for group_idx, group_mask in enumerate(parallel_pst_group_mask):
- group_members = np.flatnonzero(group_mask)
- if len(group_members) <= 1:
- continue
-
- group_low_tap = max(int(clipped_low_tap[pst_idx]) for pst_idx in group_members)
- group_high_tap = min(int(clipped_low_tap[pst_idx]) + len(clipped_taps[pst_idx]) for pst_idx in group_members)
- if group_high_tap <= group_low_tap:
- raise ValueError(
- "Parallel PST groups must have at least one shared tap position. "
- f"Group {group_idx} in action_set.json has no common tap domain."
- )
-
- for pst_idx in group_members:
- pst_low_tap = int(clipped_low_tap[pst_idx])
- pst_high_tap = pst_low_tap + len(clipped_taps[pst_idx])
- start_tap_abs = pst_low_tap + int(clipped_starting_tap_idx[pst_idx])
- clipped_start_tap_abs = int(np.clip(start_tap_abs, group_low_tap, group_high_tap - 1))
-
- if clipped_start_tap_abs != start_tap_abs:
- logger.warning(
- "Parallel PST starting tap lies outside the shared tap domain. Clipping to the group range.",
- group_index=group_idx,
- pst_index=int(pst_idx),
- starting_tap=start_tap_abs,
- clipped_starting_tap=clipped_start_tap_abs,
- shared_low_tap=group_low_tap,
- shared_high_tap=group_high_tap,
- )
-
- slice_start = group_low_tap - pst_low_tap
- slice_end = group_high_tap - pst_low_tap
- assert 0 <= slice_start < pst_high_tap - pst_low_tap
- assert slice_start < slice_end <= pst_high_tap - pst_low_tap
-
- clipped_taps[pst_idx] = clipped_taps[pst_idx][slice_start:slice_end]
- clipped_low_tap[pst_idx] = group_low_tap
- clipped_starting_tap_idx[pst_idx] = clipped_start_tap_abs - group_low_tap
-
- return clipped_low_tap, clipped_starting_tap_idx, clipped_taps
-
-
def preprocess( # noqa: PLR0915
interface: BackendInterface,
logging_fn: Optional[Callable[[PreprocessStage, Optional[str]], None]] = None,
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
index d787e622a..3346bd466 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
@@ -5,128 +5,52 @@
# you can obtain one at https://mozilla.org/MPL/2.0/.
# Mozilla Public License, version 2.0
-from datetime import datetime
-
import numpy as np
-from fsspec.implementations.dirfs import DirFileSystem
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import load_or_create_parallel_pst_group_mask
-from toop_engine_interfaces.asset_topology import Topology
-from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
-from toop_engine_interfaces.stored_action_set import ActionSet, PSTRange
+import pytest
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import build_parallel_pst_group_mask
-def _make_action_set(*pst_ranges: PSTRange) -> ActionSet:
- topology = Topology.model_construct(
- topology_id="topology",
- grid_model_file=None,
- name=None,
- stations=[],
- asset_setpoints=None,
- timestamp=datetime.now(),
- metrics=None,
- )
- return ActionSet.model_construct(
- starting_topology=topology,
- simplified_starting_topology=topology,
- connectable_branches=[],
- disconnectable_branches=[],
- pst_ranges=list(pst_ranges),
- hvdc_ranges=[],
- local_actions=[],
+def test_build_parallel_pst_group_mask_distinct_labels_yield_identity() -> None:
+ mask, group_ids = build_parallel_pst_group_mask(
+ group_labels=np.array([0, 1, 2]),
+ pst_ids=["PST1", "PST2", "PST3"],
)
+ assert np.array_equal(mask, np.eye(3, dtype=bool))
+ assert group_ids == ["PST1", "PST2", "PST3"]
-def test_load_parallel_pst_group_mask_defaults_to_identity_when_action_set_missing(tmp_path) -> None:
- filesystem = DirFileSystem(str(tmp_path))
-
- group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2"])
-
- assert np.array_equal(group_mask, np.eye(2, dtype=bool))
-
-def test_load_parallel_pst_group_mask_reads_groups_from_action_set(tmp_path) -> None:
- filesystem = DirFileSystem(str(tmp_path))
- action_set = _make_action_set(
- PSTRange(
- id="PST1",
- name="PST1",
- type="TWO_WINDINGS_TRANSFORMER",
- kind="branch",
- starting_tap=0,
- low_tap=-30,
- high_tap=31,
- pst_group="group_a",
- ),
- PSTRange(
- id="PST2",
- name="PST2",
- type="TWO_WINDINGS_TRANSFORMER",
- kind="branch",
- starting_tap=0,
- low_tap=-30,
- high_tap=31,
- pst_group="group_a",
- ),
- PSTRange(
- id="PST3",
- name="PST3",
- type="TWO_WINDINGS_TRANSFORMER",
- kind="branch",
- starting_tap=0,
- low_tap=-20,
- high_tap=21,
- pst_group="group_b",
- ),
+def test_build_parallel_pst_group_mask_shared_label_groups_members() -> None:
+ mask, group_ids = build_parallel_pst_group_mask(
+ group_labels=np.array([0, 0, 1]),
+ pst_ids=["PST1", "PST2", "PST3"],
)
- with filesystem.open(PREPROCESSING_PATHS["action_set_file_path"], "w", encoding="utf-8") as file:
- file.write(action_set.model_dump_json())
-
- group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2", "PST3"])
- expected = np.array([[True, True, False], [False, False, True]], dtype=bool)
- assert np.array_equal(group_mask, expected)
+ # PST1 and PST2 share group 0 (row 0, named after the first member); PST3 is its own group.
+ assert np.array_equal(mask, np.array([[True, True, False], [False, False, True]], dtype=bool))
+ assert group_ids == ["PST1", "PST3"]
+ # Each PST belongs to exactly one group.
+ assert np.array_equal(mask.sum(axis=0), np.ones(3, dtype=int))
-def test_load_parallel_pst_group_mask_defaults_missing_pst_entries(tmp_path) -> None:
- filesystem = DirFileSystem(str(tmp_path))
- action_set = _make_action_set(
- PSTRange(
- id="PST1",
- name="PST1",
- type="TWO_WINDINGS_TRANSFORMER",
- kind="branch",
- starting_tap=0,
- low_tap=-30,
- high_tap=31,
- pst_group="group_a",
- )
+def test_build_parallel_pst_group_mask_sentinel_labels_form_singletons() -> None:
+ mask, group_ids = build_parallel_pst_group_mask(
+ group_labels=np.array([-1, -1, 0]),
+ pst_ids=["PST1", "PST2", "PST3"],
)
- with filesystem.open(PREPROCESSING_PATHS["action_set_file_path"], "w", encoding="utf-8") as file:
- file.write(action_set.model_dump_json())
- group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2"])
+ # The -1 sentinel never merges PSTs: each gets its own row.
+ assert np.array_equal(mask, np.eye(3, dtype=bool))
+ assert group_ids == ["PST1", "PST2", "PST3"]
- expected = np.array([[True, False], [False, True]], dtype=bool)
- assert np.array_equal(group_mask, expected)
+def test_build_parallel_pst_group_mask_empty() -> None:
+ mask, group_ids = build_parallel_pst_group_mask(group_labels=np.array([], dtype=int), pst_ids=[])
-def test_load_parallel_pst_group_mask_defaults_and_fills_missing_pst_entries(tmp_path) -> None:
- filesystem = DirFileSystem(str(tmp_path))
- action_set = _make_action_set(
- PSTRange(
- id="PST1",
- name="PST1",
- type="TWO_WINDINGS_TRANSFORMER",
- kind="branch",
- starting_tap=0,
- low_tap=-30,
- high_tap=31,
- )
- )
- with filesystem.open(PREPROCESSING_PATHS["action_set_file_path"], "w", encoding="utf-8") as file:
- file.write(action_set.model_dump_json())
+ assert mask.shape == (0, 0)
+ assert group_ids == []
- group_mask = load_or_create_parallel_pst_group_mask(filesystem=filesystem, pst_ids=["PST1", "PST2"])
- expected = np.array([[True, False], [False, True]], dtype=bool)
- assert np.array_equal(group_mask, expected)
+def test_build_parallel_pst_group_mask_rejects_length_mismatch() -> None:
+ with pytest.raises(ValueError, match="entries but"):
+ build_parallel_pst_group_mask(group_labels=np.array([0, 1]), pst_ids=["PST1"])
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
index 5de0fb798..a2901820a 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
@@ -13,6 +13,7 @@
import numpy as np
import pandapower as pp
import pytest
+import structlog.testing
from beartype.typing import Optional, get_args
from fsspec.implementations.dirfs import DirFileSystem
from pandapower.pypower.makePTDF import makePTDF
@@ -174,13 +175,13 @@ def test_add_nodal_injections_to_network_data(data_folder: str, network_data: Ne
assert len(network_data.relevant_node_mask) == len(nodal_injections)
-def test_exclude_nonlinear_psts_from_controllable_clips_parallel_group_ranges(
+def test_exclude_nonlinear_psts_from_controllable_keeps_member_tap_domains(
complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
- """Verify that grouped linear PSTs are clipped to their shared tap domain.
+ """Grouped linear PSTs keep their own (identical) tap domains; differing start taps only warn.
- This uses a fixture with exactly two controllable PSTs. The PST-specific arrays already refer to
- controllable PST order, so the test only overrides PST-local tap metadata and the single group row.
+ The runtime applies a shared tap delta and clips each member to its own domain, so preprocessing
+ must not pre-shrink the domains. This uses a fixture with exactly two controllable PSTs.
"""
grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder
network_data = load_network_data(grid_folder / "network_data.pkl")
@@ -189,49 +190,32 @@ def test_exclude_nonlinear_psts_from_controllable_clips_parallel_group_ranges(
network_data = replace(
network_data,
phase_shift_linearity=np.array([True, True]),
- phase_shift_low_tap=np.array([0, 1]),
+ phase_shift_low_tap=np.array([0, 0]),
+ # Identical domains but different starting taps -> warning only, no clipping.
phase_shift_starting_tap_idx=np.array([3, 1]),
- phase_shift_taps=[np.array([0.0, 1.0, 2.0, 3.0]), np.array([10.0, 11.0])],
+ phase_shift_taps=[np.array([0.0, 1.0, 2.0, 3.0]), np.array([0.0, 1.0, 2.0, 3.0])],
parallel_pst_group_mask=np.array([[True, True]], dtype=bool),
parallel_pst_group_ids=["group_1"],
)
- updated_network_data = exclude_nonlinear_psts_from_controllable(network_data)
+ with structlog.testing.capture_logs() as cap_logs:
+ updated_network_data = exclude_nonlinear_psts_from_controllable(network_data)
- assert np.array_equal(updated_network_data.phase_shift_low_tap, np.array([1, 1]))
- assert np.array_equal(updated_network_data.phase_shift_starting_tap_idx, np.array([1, 1]))
- assert np.array_equal(updated_network_data.phase_shift_taps[0], np.array([1.0, 2.0]))
- assert np.array_equal(updated_network_data.phase_shift_taps[1], np.array([10.0, 11.0]))
+ assert np.array_equal(updated_network_data.phase_shift_low_tap, np.array([0, 0]))
+ assert np.array_equal(updated_network_data.phase_shift_starting_tap_idx, np.array([3, 1]))
+ assert np.array_equal(updated_network_data.phase_shift_taps[0], np.array([0.0, 1.0, 2.0, 3.0]))
+ assert np.array_equal(updated_network_data.phase_shift_taps[1], np.array([0.0, 1.0, 2.0, 3.0]))
+ assert any("do not share the same starting tap" in entry["event"] for entry in cap_logs)
-def test_exclude_nonlinear_psts_from_controllable_rejects_empty_parallel_group_range(
+def test_exclude_nonlinear_psts_from_controllable_drops_nonlinear_group_member(
complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
- """Verify that grouped PSTs fail fast when their tap domains have no shared interval."""
- grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder
- network_data = load_network_data(grid_folder / "network_data.pkl")
- assert network_data.controllable_phase_shift_mask.sum() == 2
+ """A non-linear member is dropped from a group rather than rejected.
- network_data = replace(
- network_data,
- phase_shift_linearity=np.array([True, True]),
- phase_shift_low_tap=np.array([0, 3]),
- phase_shift_starting_tap_idx=np.array([0, 0]),
- phase_shift_taps=[np.array([0.0, 1.0]), np.array([10.0, 11.0])],
- parallel_pst_group_mask=np.array([[True, True]], dtype=bool),
- parallel_pst_group_ids=["group_1"],
- )
-
- with pytest.raises(ValueError, match="no common tap domain"):
- exclude_nonlinear_psts_from_controllable(network_data)
-
-
-def test_exclude_nonlinear_psts_from_controllable_rejects_mixed_parallel_group_linearity(
- complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
-) -> None:
- """Verify that a configured parallel PST group cannot mix linear and non-linear members.
-
- We load two linear PSTs but then configure one of them as non-linear to trigger the validation error."""
+ We load two linear PSTs but configure one as non-linear; it is excluded from the controllable
+ set and the group row shrinks to the remaining linear member.
+ """
grid_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder
network_data = load_network_data(grid_folder / "network_data.pkl")
assert network_data.controllable_phase_shift_mask.sum() == 2
@@ -243,8 +227,11 @@ def test_exclude_nonlinear_psts_from_controllable_rejects_mixed_parallel_group_l
parallel_pst_group_ids=["group_1"],
)
- with pytest.raises(ValueError, match="cannot mix linear and non-linear"):
- exclude_nonlinear_psts_from_controllable(network_data)
+ updated_network_data = exclude_nonlinear_psts_from_controllable(network_data)
+
+ assert updated_network_data.controllable_phase_shift_mask.sum() == 1
+ assert np.array_equal(updated_network_data.parallel_pst_group_mask, np.array([[True]], dtype=bool))
+ assert updated_network_data.parallel_pst_group_ids == ["group_1"]
def test_compute_bridging_branches(data_folder: str, network_data: NetworkData) -> None:
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 2f75feec5..3ebb21742 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -12,6 +12,7 @@
Created: 2024-08-13
"""
+from collections import defaultdict
from dataclasses import asdict, dataclass, replace
from pathlib import Path
@@ -22,7 +23,7 @@
from beartype.typing import Union
from fsspec import AbstractFileSystem
from fsspec.implementations.local import LocalFileSystem
-from jaxtyping import Bool
+from jaxtyping import Bool, Int
from pypowsybl.network.impl.network import Network
from toop_engine_importer.contingency_from_power_factory.contingency_from_file import (
get_contingencies_from_file,
@@ -104,6 +105,14 @@ class NetworkMasks:
trafo_pst_controllable: np.ndarray
"""Trafos which are a PST and can be controlled"""
+ pst_group_masks: np.ndarray
+ """pst_group_masks.npy (an integer group label per trafo for parallel PST grouping).
+
+ Each entry is the parallel-PST group label of the corresponding trafo: ``-1`` for trafos that
+ are not controllable PSTs, a unique label for a lone controllable PST, and a shared label for
+ controllable PSTs identified as parallel (same bus pair, voltage and tap-changer parameters).
+ """
+
tie_line_for_reward: np.ndarray
"""tie_line_for_reward.npy (a boolean mask of tie lines that are relevant for the reward)."""
@@ -180,6 +189,7 @@ def create_default_network_masks(network: Network) -> NetworkMasks:
trafo_n0_n1_max_diff_factor=np.ones(len(trafo_df), dtype=float) * -1,
trafo_dso_border=np.zeros(len(trafo_df), dtype=bool),
trafo_pst_controllable=np.zeros(len(trafo_df), dtype=bool),
+ pst_group_masks=np.full(len(trafo_df), -1, dtype=int),
tie_line_for_reward=np.zeros(len(tie_df), dtype=bool),
tie_line_for_nminus1=np.zeros(len(tie_df), dtype=bool),
tie_line_overload_weight=np.ones(len(tie_df), dtype=float),
@@ -538,9 +548,11 @@ def update_trafo_masks(
disconnectable_mask = disconnectable_mask & ~blacklisted_trafos
pst_controllable_mask = pst_controllable_mask & ~blacklisted_trafos
- # TODO: Use remaining controllable PSTs to build groups of PSTs via identical parameters
- # and same origin and destination bus
- # TODO: Don't forget to load/create empty masks
+ # Identify parallel groups among the remaining controllable PSTs (same bus pair, voltage and
+ # tap-changer parameters) so members can be optimized together downstream.
+ pst_group_labels = build_pst_group_labels(
+ network=network, trafos_df=trafos_df, pst_controllable_mask=pst_controllable_mask
+ )
outage_mask = outage_mask & ~blacklisted_trafos
reward_mask = reward_mask & ~blacklisted_trafos
@@ -553,9 +565,89 @@ def update_trafo_masks(
trafo_overload_weight=trafo_overload_weight,
trafo_disconnectable=disconnectable_mask,
trafo_pst_controllable=pst_controllable_mask,
+ pst_group_masks=pst_group_labels,
)
+def build_pst_group_labels(
+ network: Network,
+ trafos_df: pd.DataFrame,
+ pst_controllable_mask: Bool[np.ndarray, " n_trafos"],
+) -> Int[np.ndarray, " n_trafos"]:
+ """Assign a parallel-PST group label to each transformer.
+
+ Two controllable PSTs are considered parallel (share a group) when all of the following hold:
+
+ * they connect the same unordered bus pair (``{bus1_id, bus2_id}``, orientation may be swapped),
+ * they share the same nominal voltage magnitude, and
+ * they have identical phase-tap-changer parameters: the same tap range (low/high/count) and a
+ per-tap ``alpha``/``rho``/``x``/``r`` step table that matches element-wise within a tolerance.
+
+ Parameters
+ ----------
+ network: Network
+ The powsybl network providing the phase-tap-changer data.
+ trafos_df: pd.DataFrame
+ The 2-winding transformer dataframe, indexed by trafo id and including the ``bus1_id``,
+ ``bus2_id`` and ``voltage_level1_id`` columns.
+ pst_controllable_mask: Bool[np.ndarray, " n_trafos"]
+ Boolean mask (aligned with ``trafos_df`` rows) marking controllable PST transformers.
+
+ Returns
+ -------
+ Int[np.ndarray, " n_trafos"]
+ An integer group label per transformer: ``-1`` for trafos that are not controllable PSTs, a
+ unique label for each lone controllable PST and a shared label for parallel PSTs.
+ """
+ labels = np.full(len(trafos_df), -1, dtype=int)
+
+ tap_changers = network.get_phase_tap_changers()
+ # Candidate PSTs are controllable trafos that actually carry a phase tap changer.
+ candidate_mask = pst_controllable_mask & trafos_df.index.isin(tap_changers.index)
+ candidate_positions = np.flatnonzero(candidate_mask)
+ if candidate_positions.size == 0:
+ return labels
+
+ candidate_ids = trafos_df.index[candidate_positions]
+ nominal_v = get_voltage_from_voltage_level_id(network, trafos_df.loc[candidate_ids, "voltage_level1_id"])
+ steps = network.get_phase_tap_changer_steps(attributes=["alpha", "rho", "x", "r"])
+
+ # Bucket candidates by exactly-comparable attributes; the per-tap step tables are then compared
+ # within each (small) bucket using a numerical tolerance.
+ step_tables: dict[str, np.ndarray] = {}
+ buckets: dict[tuple, list[int]] = defaultdict(list)
+ for local_idx, (position, pst_id) in enumerate(zip(candidate_positions, candidate_ids, strict=True)):
+ low_tap = int(tap_changers.at[pst_id, "low_tap"])
+ high_tap = int(tap_changers.at[pst_id, "high_tap"])
+ bus_pair = frozenset({trafos_df.at[pst_id, "bus1_id"], trafos_df.at[pst_id, "bus2_id"]})
+ step_table = steps.loc[pst_id].sort_index().to_numpy(dtype=float)
+ step_tables[pst_id] = step_table
+ bucket_key = (bus_pair, round(float(nominal_v[local_idx]), 6), low_tap, high_tap, step_table.shape[0])
+ buckets[bucket_key].append(position)
+
+ next_label = 0
+ for positions in buckets.values():
+ # Within a bucket all candidates already share bus pair, voltage and tap range. Group those
+ # whose step tables additionally match within tolerance, seeding new groups for the rest.
+ remaining = list(positions)
+ while remaining:
+ seed_position = remaining.pop(0)
+ seed_table = step_tables[trafos_df.index[seed_position]]
+ group_positions = [seed_position]
+ still_remaining = []
+ for other_position in remaining:
+ if np.allclose(seed_table, step_tables[trafos_df.index[other_position]]):
+ group_positions.append(other_position)
+ else:
+ still_remaining.append(other_position)
+ remaining = still_remaining
+ for position in group_positions:
+ labels[position] = next_label
+ next_label += 1
+
+ return labels
+
+
def update_bus_masks(
network_masks: NetworkMasks,
network: Network,
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index 48087e06d..b065c32af 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -16,6 +16,7 @@
import pytest
from fsspec.implementations.local import LocalFileSystem
from pypowsybl.network import Network
+from toop_engine_grid_helpers.powsybl.example_grids import parallel_pst_example
from toop_engine_importer.pypowsybl_import import network_analysis, powsybl_masks, preprocessing
from toop_engine_importer.pypowsybl_import.data_classes import PreProcessingStatistics
from toop_engine_importer.pypowsybl_import.ucte.powsybl_masks_ucte import get_switchable_buses_ucte
@@ -55,6 +56,9 @@ def test_create_default_network_masks():
assert isinstance(masks.load_for_nminus1, np.ndarray)
assert isinstance(masks.switch_for_nminus1, np.ndarray)
assert isinstance(masks.switch_for_reward, np.ndarray)
+ assert isinstance(masks.pst_group_masks, np.ndarray)
+ # Default group labels are -1 (no parallel-PST grouping identified yet).
+ assert np.all(masks.pst_group_masks == -1)
def test_validate_network_masks(ucte_importer_parameters: UcteImporterParameters):
@@ -847,6 +851,37 @@ def test_save_masks_to_files(ucte_file_with_border, ucte_importer_parameters: Uc
).exists(), f"{NETWORK_MASK_NAMES[file_name]} does not exist"
+def test_build_pst_group_labels_groups_parallel_psts():
+ """Parallel PSTs (same bus pair, voltage and tap-changer parameters) share a group label."""
+ net = parallel_pst_example()
+ trafos = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
+ pst_controllable_mask = trafos.index.isin(net.get_phase_tap_changers().index)
+
+ labels = powsybl_masks.build_pst_group_labels(network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask)
+
+ label_by_id = dict(zip(trafos.index, labels, strict=True))
+ # PST1 and PST2 connect the same bus pair with identical tap-changer parameters -> same group.
+ assert label_by_id["PST1"] == label_by_id["PST2"]
+ # PST3 connects a different bus pair with a different tap range -> its own group.
+ assert label_by_id["PST3"] >= 0
+ assert label_by_id["PST3"] != label_by_id["PST1"]
+
+
+def test_build_pst_group_labels_marks_non_controllable_as_ungrouped():
+ """Trafos that are not controllable PSTs keep the -1 sentinel and are never grouped."""
+ net = parallel_pst_example()
+ trafos = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
+ # Only PST1 is controllable; the parallel PST2 and the distinct PST3 are excluded.
+ pst_controllable_mask = np.asarray(trafos.index == "PST1")
+
+ labels = powsybl_masks.build_pst_group_labels(network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask)
+
+ label_by_id = dict(zip(trafos.index, labels, strict=True))
+ assert label_by_id["PST1"] >= 0
+ assert label_by_id["PST2"] == -1
+ assert label_by_id["PST3"] == -1
+
+
def test_update_reward_masks_to_include_border_branches(
ucte_file_with_border, ucte_importer_parameters: UcteImporterParameters
):
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
index 57091ce20..51cfc89b7 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
@@ -67,6 +67,7 @@
"trafo_n0_n1_max_diff_factor": "trafo_n0_n1_max_diff_factor.npy",
"trafo_blacklisted": "trafo_blacklisted.npy",
"trafo_pst_controllable": "trafo_pst_controllable.npy",
+ "pst_group_masks": "pst_group_masks.npy",
"trafo3w_for_nminus1": "trafo3w_for_nminus1.npy",
"trafo3w_for_reward": "trafo3w_for_reward.npy",
"trafo3w_overload_weight": "trafo3w_overload_weight.npy",
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
index 0a459dc6a..5ae786304 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
@@ -263,7 +263,8 @@ def test_mutate_nodal_injections_ignores_empty_group_mask_when_group_optim_disab
pst_n_taps=jnp.array([5, 5], dtype=int),
pst_start_tap_idx=jnp.array([1, 2], dtype=int),
enable_parallel_pst_group_optim=False,
- parallel_pst_group_mask=jnp.zeros((0, 0), dtype=bool),
+ # An empty group mask (zero group rows) aligned with the two controllable PSTs.
+ parallel_pst_group_mask=jnp.zeros((0, 2), dtype=bool),
)
mutated = mutate_nodal_injections(
From d83c5075ea5a42c7fb3c0ba2a474bc730a2a8fe6 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 12 Jun 2026 19:42:07 +0200
Subject: [PATCH 10/44] fix: missing path in BranchModel for PST group
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../preprocess/powsybl/powsybl_helpers.py | 3 +++
.../dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py | 4 ++++
2 files changed, 7 insertions(+)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
index 39e596154..40e46336a 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
@@ -58,6 +58,9 @@ class BranchModel(DataFrameModel):
pst_controllable: Series[bool] = Field(
nullable=True, default=False, description="Whether the branch can be controlled by a phase tap changer"
)
+ pst_group: Series[int] = Field(
+ nullable=True, default=-1, description="Parallel-PST group label (-1 = not a controllable PST / not grouped)"
+ )
n0_n1_max_diff_factor: Series[float] = Field(
nullable=True, default=-1.0, description="Maximum difference factor between N-0 and N-1 limits"
)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
index f70e5ce34..aea6ae073 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
@@ -54,6 +54,10 @@ def test_add_missing_branch_model_columns() -> None:
assert pd.isna(normalized_branches.loc["branch_id", "p_max_mw_n_1"])
assert bool(normalized_branches.loc["branch_id", "disconnectable"]) is False
assert bool(normalized_branches.loc["branch_id", "pst_controllable"]) is False
+ # pst_group must be a recognized BranchModel column so it survives normalization (incl. the
+ # empty-trafo path) and reaches _get_branches; non-PST branches default to -1.
+ assert "pst_group" in normalized_branches.columns
+ assert normalized_branches.loc["branch_id", "pst_group"] == -1
assert normalized_branches.loc["branch_id", "n0_n1_max_diff_factor"] == -1.0
From a63f46eeea83560677172d601e408dc47f421502 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Sun, 14 Jun 2026 20:48:02 +0200
Subject: [PATCH 11/44] fix: use python int annotation
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_dc_solver/preprocess/network_data.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 1cce54d47..cc00cc37e 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
@@ -736,7 +736,7 @@ def extract_action_set(network_data: NetworkData) -> ActionSet:
)
-def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: Int, branch_idx: Int) -> str:
+def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: int, branch_idx: int) -> str:
"""Return the persisted PST group id for one controllable PST.
If no parallel PST group information is available, or if the PST does not belong to any
From 2d75b50ed2b19e6f0d0a50965af676505eae3371 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Mon, 15 Jun 2026 10:43:55 +0200
Subject: [PATCH 12/44] chore: Type annotation required
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_dc_solver/preprocess/network_data.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 cc00cc37e..1cce54d47 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
@@ -736,7 +736,7 @@ def extract_action_set(network_data: NetworkData) -> ActionSet:
)
-def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: int, branch_idx: int) -> str:
+def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: Int, branch_idx: Int) -> str:
"""Return the persisted PST group id for one controllable PST.
If no parallel PST group information is available, or if the PST does not belong to any
From 7a200d9f3b5116d51f663c14dd357ac0642c7adb Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Mon, 15 Jun 2026 11:45:38 +0200
Subject: [PATCH 13/44] feat: Add non-support for PandaPower backend
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_dc_solver/jax/inputs.py | 9 +++++----
.../preprocess/convert_to_jax.py | 7 ++++++-
.../preprocess/network_data.py | 2 +-
.../pandapower/pandapower_backend.py | 20 +++++++++++++++++++
.../src/toop_engine_interfaces/backend.py | 17 ++++++++++++----
5 files changed, 45 insertions(+), 10 deletions(-)
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 7d1603ca8..d6bfbf33d 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
@@ -546,10 +546,11 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
"grid_model_low_tap",
data=nodal_inj_opt.grid_model_low_tap,
)
- file.create_dataset(
- "parallel_pst_group_mask",
- data=nodal_inj_opt.parallel_pst_group_mask,
- )
+ if solver_config.enable_parallel_pst_group_optim:
+ file.create_dataset(
+ "parallel_pst_group_mask",
+ data=nodal_inj_opt.parallel_pst_group_mask,
+ )
for idx, (branches, nodes) in enumerate(
zip(
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
index d5495604c..01971d40e 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
@@ -238,6 +238,11 @@ def convert_to_jax( # noqa: PLR0913
logging_fn("create_static_information", None)
ptdf = jnp.array(network_data.ptdf)
nodal_injection = jnp.array(network_data.nodal_injection, dtype=float)
+ parallel_pst_group_mask = (
+ jnp.array(network_data.parallel_pst_group_mask, dtype=bool)
+ if network_data.parallel_pst_group_mask is not None
+ else None
+ )
static_information = StaticInformation(
dynamic_information=DynamicInformation(
# Network Data arguments
@@ -286,7 +291,7 @@ def convert_to_jax( # noqa: PLR0913
pst_tap_values=pst_tap_values,
starting_tap_idx=jnp.array(network_data.phase_shift_starting_tap_idx, dtype=int),
grid_model_low_tap=jnp.array(network_data.phase_shift_low_tap, dtype=int),
- parallel_pst_group_mask=jnp.array(network_data.parallel_pst_group_mask, dtype=bool),
+ parallel_pst_group_mask=parallel_pst_group_mask,
)
if network_data.controllable_pst_node_mask.any()
else None,
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 1cce54d47..37ffe86a6 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
@@ -180,7 +180,7 @@ class NetworkData:
"""Mask of branches that would lead to islanding if outaged"""
nodal_injection: Optional[Float[np.ndarray, " n_timestep n_node"]] = None
- """The injected netto power at each node in the grid for all timesteps"""
+ """The injected net power at each node in the grid for all timesteps"""
ptdf_is_extended: bool = False
"""Flag to show if PTDF was already extended"""
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..6bd3f3a94 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
@@ -218,6 +218,26 @@ def get_slack(self) -> int:
return int(slack_bus)
+ def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]]:
+ """Get a PST group mask aligned with the controllable PST arrays.
+
+ Returns
+ -------
+ Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]
+ The mask for parallel PST groups, or None if no explicit grouping metadata is available.
+ """
+ return None
+
+ def get_parallel_pst_group_ids(self) -> Optional[list[str]]:
+ """Get PST group identifiers aligned with rows of get_parallel_pst_group_mask().
+
+ Returns
+ -------
+ Optional[list[str]]
+ The identifiers for parallel PST groups, or None if no explicit grouping metadata is available.
+ """
+ return None
+
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
"""Get the relevant nodes mask
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
index 908b860f0..d4d602223 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/backend.py
@@ -306,16 +306,25 @@ def get_controllable_phase_shift_ids(self) -> list[str]:
if is_controllable
]
+ @abstractmethod
def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]]:
"""Get a PST group mask aligned with the controllable PST arrays.
- Returns None when no explicit grouping metadata is available.
+ Returns
+ -------
+ Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]
+ The mask for parallel PST groups, or None if no explicit grouping metadata is available.
"""
- return None
+ @abstractmethod
def get_parallel_pst_group_ids(self) -> Optional[list[str]]:
- """Get PST group identifiers aligned with rows of get_parallel_pst_group_mask()."""
- return None
+ """Get PST group identifiers aligned with rows of get_parallel_pst_group_mask().
+
+ Returns
+ -------
+ Optional[list[str]]
+ The identifiers for parallel PST groups, or None if no explicit grouping metadata is available.
+ """
@abstractmethod
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
From ca7ce98ab7e8856748cd58e300238d5fb9f5e294 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Mon, 15 Jun 2026 12:19:56 +0200
Subject: [PATCH 14/44] docs: Proper docs for new feature
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
docs/dc_solver/preprocessing.md | 21 ++++++++++++---------
docs/dc_solver/quickstart.md | 5 ++---
docs/importer/network_graph/index.md | 2 +-
docs/importer/pandapower/index.md | 4 +++-
docs/importer/pypowsybl/index.md | 4 +++-
docs/importer/worker/worker.md | 2 +-
docs/index.md | 2 +-
docs/usage.md | 8 +++-----
packages/dc_solver_pkg/README.md | 2 +-
packages/importer_pkg/README.md | 8 ++++++--
packages/interfaces_pkg/README.md | 2 +-
packages/topology_optimizer_pkg/README.md | 4 ++--
12 files changed, 36 insertions(+), 28 deletions(-)
diff --git a/docs/dc_solver/preprocessing.md b/docs/dc_solver/preprocessing.md
index 7de2556ee..9e62abadd 100644
--- a/docs/dc_solver/preprocessing.md
+++ b/docs/dc_solver/preprocessing.md
@@ -3,7 +3,7 @@
The preprocessing flow is split into three parts:
- An importing procedure that prepares a processed grid folder from the raw source data. In this repository this is handled by the Importer package through [`convert_file`][toop_engine_importer.pypowsybl_import.preprocessing.convert_file]. It writes the backend-readable grid snapshot together with masks, loadflow parameters, topology metadata, and an initial contingency definition.
- A [`preprocess`][toop_engine_dc_solver.preprocess.preprocess] routine which extracts DC-loadflow relevant information from a backend and performs various data transformations.
-- A [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_to_jax] routine which reformats the data from the Python format used during preprocessing to the format required by the solver. All processing happens in `preprocess`; this function purely reformats.
+- A [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_to_jax] routine which reformats the data from the Python format used during preprocessing to the format required by the solver. All processing happens in `preprocess`; this function purely reformats.
The [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] routine combines the latter two steps, runs an initial loadflow, and persists the standard solver artifacts back into the same processed grid folder.
@@ -25,18 +25,21 @@ The processed grid folder layout is defined in the [`folder_structure`][toop_eng
| DC solver | `action_set_diffs.hdf5` | Companion diff representation for the persisted action set. |
| DC solver | `nminus1_definition.json` | Refreshed contingency definition after preprocessing filters have been applied. |
-The same processed grid folder is therefore both an input and an output of [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid]. In particular, `action_set.json` is no longer only a postprocessing artifact: if it already exists when preprocessing starts, the backend reads its PST grouping metadata and preserves it through the preprocessing pipeline.
+The same processed grid folder is therefore both an input and an output of [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid].
-## Parallel PST grouping in `action_set.json`
+## Parallel PST grouping
-Controllable PSTs are serialized in `ActionSet.pst_ranges`. Each PST range carries a `pst_group` field that defines which PSTs must move together during optimization.
+Parallel PST group optimization is currently supported only for Powsybl-imported grids. The groups are identified from the imported Powsybl grid data before solver preprocessing. PSTs are considered part of the same supported group only when they connect the same voltage magnitude, share the same bus pair regardless of orientation, and have matching tap and phase-shifter parameters.
+
+Controllable PSTs are serialized in `ActionSet.pst_ranges`. Each PST range carries a `pst_group` field that persists the import-derived group used by downstream tooling.
- PSTs with the same `pst_group` are treated as one optimization group.
-- If `action_set.json` is missing, or a controllable PST is absent from it, preprocessing falls back to one group per PST.
-- During preprocessing, grouped PSTs are clipped to their common tap domain before the action set is written back to disk.
-- Mixed linear and non-linear PSTs are rejected and cannot share the same group (We currently do not support optimization of non-linear/asymmetric PSTs).
+- Group members share the same tap delta during solver execution and optimization, then each member is clipped to its own tap domain.
+- Different initial taps inside one group trigger a warning.
+- Mixed linear and non-linear PSTs are not supported in one group when grouped optimization is enabled. We currently do not support optimization of non-linear/asymmetric PSTs.
+- Parallel PST group optimization is not supported for the PandaPower backend.
-The persisted `action_set.json` always writes the group explicitly, so downstream tools and subsequent preprocessing runs see the same grouping.
+The persisted `action_set.json` writes the group explicitly, so downstream tools can inspect the grouping selected during import.
## Backend interface
@@ -85,7 +88,7 @@ The [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_t
The [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] routine performs the following tasks:
-- Instantiate the backend, depending on whether it is a [`PandaPowerBackend`][toop_engine_dc_solver.preprocess.pandapower.pandapower_backend.PandaPowerBackend] or [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend] grid. The backend reads the normalized grid files, masks, loadflow parameters, and any existing PST grouping metadata from the processed grid folder. (`load_grid_into_loadflow_solver_backend`)
+- Instantiate the backend, depending on whether it is a [`PandaPowerBackend`][toop_engine_dc_solver.preprocess.pandapower.pandapower_backend.PandaPowerBackend] or [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend] grid. The backend reads the normalized grid files, masks, and loadflow parameters from the processed grid folder. For Powsybl grids, import-derived PST grouping metadata is also exposed to preprocessing. (`load_grid_into_loadflow_solver_backend`)
- Call the [`preprocess`][toop_engine_dc_solver.preprocess.preprocess] routine.
- Call the [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax.convert_to_jax] routine.
- [`Validate`][toop_engine_dc_solver.jax.inputs.validate_static_information] the resulting static information.
diff --git a/docs/dc_solver/quickstart.md b/docs/dc_solver/quickstart.md
index ec0f2a4b1..f1e1703db 100644
--- a/docs/dc_solver/quickstart.md
+++ b/docs/dc_solver/quickstart.md
@@ -2,7 +2,7 @@
## Preprocessing
-To get the solver running, we first need to preprocess a grid file. In practice this means working from a processed grid folder that contains a backend grid snapshot (`grid.xiidm` or `grid.json`), masks, and loadflow parameters. If an `action_set.json` is already present in that folder, preprocessing also reuses its `pst_ranges[*].pst_group` information to keep grouped PSTs synchronized. There are three noteworthy steps in the preprocessing:
+To get the solver running, we first need to preprocess a grid file. In practice this means working from a processed grid folder that contains a backend grid snapshot (`grid.xiidm` or `grid.json`), masks, and loadflow parameters. For Powsybl-imported grids, preprocessing also receives import-derived parallel PST group metadata so grouped PSTs can stay synchronized during solver execution and optimization. Parallel PST group optimization is not supported for the PandaPower backend. There are three noteworthy steps in the preprocessing:
- **[`BackendInterface`][toop_engine_interfaces.backend.BackendInterface]**: A backend offers a read-only interface to a power systems modelling software and translates the information from the modelling software into a software-agnostic node-branch representation. Currently, there is a [`PandaPowerBackend`][toop_engine_dc_solver.preprocess.pandapower.pandapower_backend.PandaPowerBackend] and a [`PowsyblBackend`][toop_engine_dc_solver.preprocess.powsybl.powsybl_backend.PowsyblBackend] available.
- **[`NetworkData`][toop_engine_dc_solver.preprocess.network_data.NetworkData]**: This is a backend-agnostic representation of the grid with all the information needed for post-processing and execution of the loadflow solver. You can obtain a filled instance of this through [`preprocess`][toop_engine_dc_solver.preprocess.preprocess] and generate a [`StaticInformation`][toop_engine_dc_solver.jax.types.StaticInformation] dataclass from it.
- **[`StaticInformation`][toop_engine_dc_solver.jax.types.StaticInformation]**: Stores only the relevant data for the loadflow computation directly on GPU VRAM, fully in jax format. You can obtain this from a filled network data instance through [`convert_to_jax`][toop_engine_dc_solver.preprocess.convert_to_jax].
@@ -28,8 +28,7 @@ from toop_engine_dc_solver.preprocess import load_grid
stats, static_information, network_data = load_grid(DirFileSystem("path_to_processed_grid"))
```
-This reads the processed grid folder and writes `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and a refreshed `nminus1_definition.json`. The saved action set contains explicit `pst_group` values for controllable PSTs.
-If you want to change the values of `pst_group`, you can run your workflow, adjust the grouping manually, and the function will retain the grouping information instead of defaulting to unique groups per PST.
+This reads the processed grid folder and writes `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and a refreshed `nminus1_definition.json`. For Powsybl-imported grids with supported parallel PST groups, the saved action set contains explicit import-derived `pst_group` values for controllable PSTs.
The `jax.config.update` statement is recommended because otherwise the static information will be in 32 bit, as jax is [automatically converting everything to 32 bit by default](https://jax.readthedocs.io/en/latest/notebooks/Common_Gotchas_in_JAX.html#double-64bit-precision) and setting this config flag will stop it from doing so. You can still switch to 32 bit during the execution, but by running the preprocessing in 64 bit, you will retain the option to choose at the expense of a bit of disk space.
diff --git a/docs/importer/network_graph/index.md b/docs/importer/network_graph/index.md
index d48838425..9503a569e 100644
--- a/docs/importer/network_graph/index.md
+++ b/docs/importer/network_graph/index.md
@@ -71,4 +71,4 @@ The data classes for the Network Graph:
## Missing Features / Limitations
-1. PSTs are not implemented, currently outside of the station due to the branch [`bay_weight`][toop_engine_importer.network_graph.set_all_weights].
+1. PST asset-topology mapping in the Network Graph is not implemented, currently outside of the station due to the branch [`bay_weight`][toop_engine_importer.network_graph.set_all_weights]. This limitation is scoped to Network Graph mapping and does not describe Powsybl import-time identification of parallel PST groups for optimization.
diff --git a/docs/importer/pandapower/index.md b/docs/importer/pandapower/index.md
index 7610d7130..41e29242e 100644
--- a/docs/importer/pandapower/index.md
+++ b/docs/importer/pandapower/index.md
@@ -1,5 +1,7 @@
# Pandapower import
-## TODO: add some docs
+The PandaPower import path supports the backend workflows documented for PandaPower grids, but it is not a supported path for parallel PST group optimization.
+
+Parallel PST group optimization is currently supported only for Powsybl-imported grids, where groups are identified from the imported grid data and persisted into the processed grid artifacts. A PandaPower grid may still contain ordinary PST information used by other backend functionality, but grouped PST optimization should not be enabled for PandaPower-based preprocessing artifacts.
[`pandapower`][toop_engine_importer.pandapower_import]
diff --git a/docs/importer/pypowsybl/index.md b/docs/importer/pypowsybl/index.md
index 2b1cd0f0c..63cbc0005 100644
--- a/docs/importer/pypowsybl/index.md
+++ b/docs/importer/pypowsybl/index.md
@@ -1,5 +1,7 @@
# PyPowSyBl import
-## TODO: add some docs
+The PyPowSyBl import path is the supported path for CGMES import and for parallel PST group identification.
+
+When grouped PST optimization is enabled downstream, parallel PST groups are derived during import from the Powsybl grid data. PSTs are considered part of the same supported group only when they connect the same voltage magnitude, share the same bus pair regardless of orientation, and have matching tap and phase-shifter parameters. The derived group metadata is persisted into the processed grid artifacts and later written to `action_set.json` as `pst_ranges[*].pst_group`.
[`pypowsybl_import`][toop_engine_importer.pypowsybl_import]
diff --git a/docs/importer/worker/worker.md b/docs/importer/worker/worker.md
index c9e909033..091b0cfbe 100644
--- a/docs/importer/worker/worker.md
+++ b/docs/importer/worker/worker.md
@@ -7,4 +7,4 @@ The importer worker is designed to run as one of the components in the ToOp arch
- Preprocessing through the [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] function to create `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the final filtered `nminus1_definition.json`.
- Running an initial loadflow using the contingency_analysis module.
-If an `action_set.json` is already present when [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] starts, its `pst_ranges[*].pst_group` values are reused so grouped PSTs stay synchronized through preprocessing and optimization.
+For Powsybl-imported grids, the import step identifies supported parallel PST groups from the grid data. [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] persists those groups in `action_set.json` through `pst_ranges[*].pst_group` so grouped PSTs stay synchronized through solver execution and optimization. Parallel PST group optimization is not supported for the PandaPower backend.
diff --git a/docs/index.md b/docs/index.md
index 2672ed0fe..4ee83d68b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,7 +11,7 @@ Welcome to our ToOp (engine) repository at Elia Group.
ToOp is short for Topology Optimization and describes the approach to reduce grid congestion by topological actions. Topological actions are non-costly actions that can be applied to the grid to "steer" the electrcitiy flow.
Our goal is to propose (potentially) new topology strategies to the operators with the goal to lower redispatch costs and carbon emissions.
-This repository builds the engine behind the topology optimization product ToOp at Elia Group. ToOp provides tools to perform topology optimization on operational grid data through an importer, a DC optimization stage, and AC validation. It also includes the GPU-based DC load flow solver. At the current stage it considers transmission line switching, busbar splitting, busbar reassignments, and grouped, linear PST tap optimization.
+This repository builds the engine behind the topology optimization product ToOp at Elia Group. ToOp provides tools to perform topology optimization on operational grid data through an importer, a DC optimization stage, and AC validation. It also includes the GPU-based DC load flow solver. At the current stage it considers transmission line switching, busbar splitting, busbar reassignments, and linear PST tap optimization. For Powsybl-imported grids, we additionally support grouped optimization of PSTs.
diff --git a/docs/usage.md b/docs/usage.md
index 6275da47b..08fb4dd8b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -1,11 +1,11 @@
# How to use this package
-## Overview
+## Overview
There are 6 packages:
1. [Importer](./importer/intro.md): This packages handles grid import and additional data files necessary to perform topology optimization. It relies on the packages `PandaPower` and `PyPowsybl` for grid import, which we refer to as *backends*.
2. [DC Solver](./dc_solver/intro.md): This packages implements an accelerated DC loadflow solver with GPU support.
-3. [Topology Optimizer](./topology_optimizer/intro.md): This package implements a topology optimizer for electrical transmission grids.
+3. [Topology Optimizer](./topology_optimizer/intro.md): This package implements a topology optimizer for electrical transmission grids.
It uses multi-objective optimization to determine reconfigurations of substations that reduce for example line overloads.
4. [Interfaces](./interfaces/intro.md): This package provides a set of abstractions and adapters to enable interoperability between different grid modeling tools and data formats.
5. [Grid Helpers](./grid_helpers/intro.md): Contains several helping functions to streamline the use of both backends for grid importing.
@@ -39,8 +39,6 @@ To use the tool, you first create a processed grid folder and then derive the so
- The [convert_file][toop_engine_importer.pypowsybl_import.preprocessing.convert_file] function takes an import command and writes the normalized backend grid file, masks, loadflow parameters, asset topology metadata, importer auxiliary data, and an initial `nminus1_definition.json`.
- The [load_grid][toop_engine_dc_solver.preprocess.load_grid] function consumes that processed grid folder and writes the solver-facing artifacts, most notably `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and a refreshed `nminus1_definition.json`.
-If an `action_set.json` is already present before `load_grid` runs, its `pst_ranges[*].pst_group` values are reused to keep grouped PSTs synchronized. Missing PST group entries fall back to one group per controllable PST.
-
### Step 2: Perform an optimization
For this, both the DC optimizer and AC validator should be running at the same time to keep runtime constraints. In principle they could be run one after the other, which would greatly simplify deployment, but as the whole point of this project is to have a fast response, we run them at the same time.
@@ -48,7 +46,7 @@ Note: The AC optimizer consumes the results of the DC optimizer. Therefore the A
This is set up through kafka messaging. For the beginning we will run everything on the same machine, but in principle you can deploy this on a cluster.
-First, set up kafka with
+First, set up kafka with
```
cd dev-deployment
docker-compose up
diff --git a/packages/dc_solver_pkg/README.md b/packages/dc_solver_pkg/README.md
index 207d96b4b..3b3b61de9 100644
--- a/packages/dc_solver_pkg/README.md
+++ b/packages/dc_solver_pkg/README.md
@@ -34,7 +34,7 @@ There are various concepts that are required to grasp in order to make sense of
The solver can not directly work with grid data in common grid formats (ucte, cgmes, etc). Instead, it needs to load the relevant information for loadflow computations from a backend. Currently, the supported backends are [pandapower](https://pandapower.readthedocs.io/) and [powsybl](https://powsybl.org). Standard usage points [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] at a processed grid folder that already contains the backend grid snapshot, masks, loadflow parameters, topology metadata, and an initial contingency definition. During preprocessing, the solver computes the PTDF matrix and other relevant information, then persists `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, and a refreshed `nminus1_definition.json`.
-If `action_set.json` already defines `pst_ranges[*].pst_group`, grouped PSTs are preserved through preprocessing, clipped to their common tap domain, and written back explicitly for downstream tools.
+Parallel PST group optimization is supported only for Powsybl-imported grids. For those grids, grouping metadata is derived during import, exposed to preprocessing, and written to `action_set.json` as `pst_ranges[*].pst_group` for downstream tools.
Read more on the [`preprocessing page`](https://eliagroup.github.io/ToOp/dc_solver/preprocessing/).
diff --git a/packages/importer_pkg/README.md b/packages/importer_pkg/README.md
index c9084f1d7..085bcc3fc 100644
--- a/packages/importer_pkg/README.md
+++ b/packages/importer_pkg/README.md
@@ -4,14 +4,16 @@ The Importer package serves as the gateway for loading power system grid models
## Overview
-The Importer package provides capabilities for importing grid models from multiple sources and formats.
+The Importer package provides capabilities for importing grid models from multiple sources and formats.
It supports grid models from UCTE files, and CGMES standards (currently only PyPowSyBl), converting them into standardized formats suitable for power system analysis and optimization.
-At its core, the package leverages two Python libraries as backends:
+At its core, the package leverages two Python libraries as backends:
1. **PandaPower**
2. **PyPowSyBl**
+**Note:** Parallel PST group identification for grouped PST optimization is supported only on the PyPowSyBl import path, where groups are derived from the imported grid data. The PandaPower import path is not a supported path for parallel PST group optimization.
+
## Package Structure
The Importer package is organized into several focused modules, each addressing specific aspects of the grid import process:
@@ -27,6 +29,8 @@ Main entry point: [`convert_file`][toop_engine_importer.pypowsybl_import.preproc
`convert_file` writes the processed grid folder consumed by the DC solver: the normalized backend grid snapshot, masks, loadflow parameters, importer auxiliary data, asset topology metadata, and an initial `nminus1_definition.json`.
+For PyPowSyBl-imported grids, `convert_file` also prepares supported parallel PST group metadata from the grid data so downstream solver and optimizer stages can keep grouped PSTs synchronized.
+
The downstream [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] step augments that same folder with `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the final filtered contingency definition used during optimization.
TODO: add Importer example
diff --git a/packages/interfaces_pkg/README.md b/packages/interfaces_pkg/README.md
index 8c2192458..f8679894a 100644
--- a/packages/interfaces_pkg/README.md
+++ b/packages/interfaces_pkg/README.md
@@ -18,7 +18,7 @@ Implementations can be found in the repository: [`powsybl_backend.py`][toop_engi
[N-minus-1 Definition][toop_engine_interfaces.nminus1_definition] - Defines contingency scenarios for reliability analysis where one component is out of service.
-[Stored Action Set][toop_engine_interfaces.stored_action_set] - Contains pre-computed optimization actions and controllable asset ranges, including PST grouping metadata used to keep grouped PSTs synchronized.
+[Stored Action Set][toop_engine_interfaces.stored_action_set] - Contains pre-computed optimization actions and controllable asset ranges. For supported Powsybl-imported grids, it can include PST grouping metadata used to keep grouped PSTs synchronized.
[Types][toop_engine_interfaces.types] - Provides type definitions and metric types used throughout the optimization engine.
diff --git a/packages/topology_optimizer_pkg/README.md b/packages/topology_optimizer_pkg/README.md
index 813c5c863..861f093f6 100644
--- a/packages/topology_optimizer_pkg/README.md
+++ b/packages/topology_optimizer_pkg/README.md
@@ -29,7 +29,7 @@ The AC optimizer validates promising DC solutions using full AC loadflow calcula
### Topology Representation
- **Actions**: List of substation switching indices from the [`ActionSet`][toop_engine_dc_solver.preprocess.action_set]
- **Disconnections**: Branch outage specifications for N-1 analysis
-- **PST Setpoints**: Phase-shifting transformer positions and groups as described in the [`ActionSet`][toop_engine_dc_solver.preprocess.action_set]
+- **PST Setpoints**: Phase-shifting transformer positions as described in the [`ActionSet`][toop_engine_dc_solver.preprocess.action_set]. Parallel PST group optimization is supported only when Powsybl-imported preprocessing artifacts provide valid `pst_group` metadata.
- **Metrics**: Multi-objective fitness values and constraint violations
### Strategy Collections
@@ -41,7 +41,7 @@ The AC optimizer validates promising DC solutions using full AC loadflow calcula
The optimization process requires preprocessed grid data from the **[`Importer`](https://eliagroup.github.io/ToOp/importer/intro/)** package:
1. **Static Information**: Grid electrical parameters and topology
-2. **Action Set**: Enumerated switching possibilities and controllable asset ranges. For PSTs, `pst_ranges[*].pst_group` keeps grouped transformers synchronized during optimization.
+2. **Action Set**: Enumerated switching possibilities and controllable asset ranges. For supported Powsybl-imported grids, `pst_ranges[*].pst_group` keeps grouped PSTs synchronized during optimization. Parallel PST group optimization is not supported for PandaPower preprocessing artifacts.
3. **N-1 Definition**: Contingency analysis specifications aligned with the preprocessed network data
## Running an Optimization
From 696c877daca627338d104212b3f37efccb444fd0 Mon Sep 17 00:00:00 2001
From: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
Date: Mon, 15 Jun 2026 16:19:56 +0200
Subject: [PATCH 15/44] feat: add grouped pst example
Signed-off-by: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
---
.../powsybl/example_grids.py | 209 +++++++++++++++++-
.../powsybl/test_powsybl_example_grids.py | 22 ++
2 files changed, 230 insertions(+), 1 deletion(-)
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..87219cc10 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
@@ -303,7 +303,7 @@ def _prepare_basic_node_breaker_network_powsybl(
busbar_section_position = pd.DataFrame.from_records(
index="id",
data=[
- {"id": f"BBS{sub_id}_{bus_id}", "section_index": 1, "busbar_index": bus_id}
+ {"id": f"BBS{sub_id}_{bus_id}", "section_index": 1, "busbar_index": int(bus_id)}
for sub_id, num_buses in n_buses.items()
for bus_id in range(1, num_buses + 1)
],
@@ -313,6 +313,8 @@ def _prepare_basic_node_breaker_network_powsybl(
for busbar_id, updated_fields in busbar_section_position_updates.items():
for field, value in updated_fields.items():
busbar_section_position.loc[busbar_id, field] = value
+ busbar_section_position["section_index"] = busbar_section_position["section_index"].astype(int)
+ busbar_section_position["busbar_index"] = busbar_section_position["busbar_index"].astype(int)
net.create_substations(stations)
net.create_voltage_levels(voltage_levels)
@@ -2332,3 +2334,208 @@ def parallel_pst_example() -> Network:
net.create_operational_limits(df=limits)
return net
+
+
+def grouped_pst_grid_example(linear_pst: list[bool] | None) -> pypowsybl.network.Network:
+ """Create an extended node breaker network with parallel PSTs in a two-sided station.
+
+ Base setup: two lines are overloaded.
+
+ Solution 1: PST only, one line overloaded, other reduced
+ net.update_phase_tap_changers(id="PST_1_group_1", tap=-7)
+ net.update_phase_tap_changers(id="PST_2_group_1", tap=-7)
+ net.update_phase_tap_changers(id="PST_3_group_2", tap=-7)
+ net.update_phase_tap_changers(id="PST_4_group_2", tap=-7)
+
+ Solution 2: PST + bus split, no overloads
+ net.open_switch("VL2_BREAKER#0")
+ net.update_phase_tap_changers(id="PST_1_group_1", tap=-7)
+ net.update_phase_tap_changers(id="PST_2_group_1", tap=-7)
+ net.update_phase_tap_changers(id="PST_3_group_2", tap=-6)
+ net.update_phase_tap_changers(id="PST_4_group_2", tap=-6)
+
+ Parameters
+ ----------
+ linear_pst : list[bool] | None
+ A boolean list of length 4 indicating whether each of the four PSTs should be linear (True)
+ or non-linear (False). If None, all PSTs are linear.
+
+ Returns
+ -------
+ pypowsybl.network.Network
+ The created Powsybl network.
+ """
+ if linear_pst is None:
+ linear_pst = [True, True, True, True]
+ if len(linear_pst) != 4:
+ msg = "linear_pst must contain exactly 4 boolean values."
+ raise ValueError(msg)
+ # substation_id : number of buses
+ n_buses = {1: 3, 2: 0, 3: 2, 4: 2, 5: 1}
+ net = _prepare_basic_node_breaker_network_powsybl(n_subs=5, n_vls=5, n_buses=n_buses)
+
+ pypowsybl.network.create_voltage_level_topology(
+ net,
+ id="VL2",
+ aligned_buses_or_busbar_count=2,
+ switch_kinds="BREAKER, DISCONNECTOR, BREAKER",
+ bus_or_busbar_section_prefix_id="BBS2_",
+ )
+ # remove switches to create a two sided station for the PST
+ net.remove_elements(["VL2_DISCONNECTOR_2_4", "VL2_DISCONNECTOR_3_5"])
+ lines = pd.DataFrame.from_records(
+ data=[
+ {"bus_or_busbar_section_id_1": "BBS1_1", "bus_or_busbar_section_id_2": "VL2_1_1"},
+ {"bus_or_busbar_section_id_1": "BBS1_2", "bus_or_busbar_section_id_2": "VL2_1_2"},
+ {"bus_or_busbar_section_id_1": "BBS1_3", "bus_or_busbar_section_id_2": "BBS3_1"},
+ {"bus_or_busbar_section_id_1": "BBS3_1", "bus_or_busbar_section_id_2": "BBS4_1"},
+ {"bus_or_busbar_section_id_1": "VL2_1_3", "bus_or_busbar_section_id_2": "BBS3_1"},
+ {"bus_or_busbar_section_id_1": "VL2_2_3", "bus_or_busbar_section_id_2": "BBS3_2"},
+ {"bus_or_busbar_section_id_1": "VL2_1_4", "bus_or_busbar_section_id_2": "BBS4_1"},
+ {"bus_or_busbar_section_id_1": "VL2_2_4", "bus_or_busbar_section_id_2": "BBS4_2"},
+ {"bus_or_busbar_section_id_1": "BBS3_1", "bus_or_busbar_section_id_2": "BBS5_1"},
+ ]
+ )
+ lines["r"] = 0.1
+ lines["x"] = 10
+ lines["g1"] = 0
+ lines["b1"] = 0
+ lines["g2"] = 0
+ lines["b2"] = 0
+ lines["position_order_1"] = 1
+ lines["position_order_2"] = 1
+ for i, _ in lines.iterrows():
+ lines.loc[i, "id"] = f"L{i + 1}"
+ lines = lines.set_index("id")
+ pypowsybl.network.create_line_bays(net, lines)
+
+ pypowsybl.network.create_coupling_device(
+ net, bus_or_busbar_section_id_1=["BBS1_1", "BBS1_2"], bus_or_busbar_section_id_2=["BBS1_2", "BBS1_3"]
+ )
+ # pst station
+ # vertical busbars
+ pypowsybl.network.create_coupling_device(
+ net, bus_or_busbar_section_id_1=["VL2_1_1"], bus_or_busbar_section_id_2=["VL2_2_1"]
+ )
+ pypowsybl.network.create_coupling_device(
+ net, bus_or_busbar_section_id_1=["VL2_1_3"], bus_or_busbar_section_id_2=["VL2_2_3"]
+ )
+
+ pypowsybl.network.create_coupling_device(
+ net, bus_or_busbar_section_id_1=["BBS3_1"], bus_or_busbar_section_id_2=["BBS3_2"]
+ )
+ pypowsybl.network.create_coupling_device(
+ net, bus_or_busbar_section_id_1=["BBS4_1"], bus_or_busbar_section_id_2=["BBS4_2"]
+ )
+ pypowsybl.network.create_load_bay(net, id="load1", bus_or_busbar_section_id="BBS4_1", p0=100, q0=10, position_order=2)
+ pypowsybl.network.create_load_bay(net, id="load2", bus_or_busbar_section_id="BBS5_1", p0=100, q0=10, position_order=2)
+ pypowsybl.network.create_generator_bay(
+ net,
+ id="generator1",
+ max_p=1000,
+ min_p=0,
+ voltage_regulator_on=True,
+ target_p=200,
+ target_q=30,
+ target_v=225,
+ bus_or_busbar_section_id="BBS1_1",
+ position_order=1,
+ )
+
+ def _create_pst_df(
+ id: str, bus_or_busbar_section_id_1: str, bus_or_busbar_section_id_2: str, linear: bool = False
+ ) -> None:
+ """Helper function to create a phase tap changer with a given id and associated steps."""
+
+ pypowsybl.network.create_2_windings_transformer_bays(
+ net,
+ id=id,
+ b=1e-6,
+ g=5e-7,
+ r=0.1,
+ x=12.0,
+ rated_u1=225.0,
+ rated_u2=225.0,
+ bus_or_busbar_section_id_1=bus_or_busbar_section_id_1,
+ position_order_1=50,
+ direction_1="BOTTOM",
+ bus_or_busbar_section_id_2=bus_or_busbar_section_id_2,
+ position_order_2=50,
+ direction_2="BOTTOM",
+ )
+ ptc_df = pd.DataFrame.from_records(
+ index="id",
+ columns=["id", "target_deadband", "regulation_mode", "low_tap", "tap"],
+ data=[(id, 2, "CURRENT_LIMITER", -30, -20)],
+ )
+ # base/min/max values (keep b,g,r,x constant as before, interpolate rho and alpha)
+ taps = np.arange(-30, 20)
+ b_val, g_val, rho_val = 0, 0, 1
+ alpha_min, alpha_max = -21.0, 28.0
+ x_min, x_max = -20.0, 30.0
+ r_min, r_max = -15.0, 25.0
+
+ alphas = np.linspace(alpha_min, alpha_max, len(taps))
+ x_vals = abs(np.linspace(x_min, x_max, len(taps)))
+ r_vals = abs(np.linspace(r_min, r_max, len(taps)))
+
+ if linear:
+ x_vals = np.zeros_like(x_vals)
+ r_vals = np.zeros_like(r_vals)
+
+ rows = [
+ (id, b_val, g_val, r_val, x_val, rho_val, alpha)
+ for r_val, x_val, alpha in zip(r_vals, x_vals, alphas, strict=True)
+ ]
+
+ steps_df = pd.DataFrame.from_records(data=rows, index="id", columns=["id", "b", "g", "r", "x", "rho", "alpha"])
+
+ net.create_phase_tap_changers(ptc_df, steps_df)
+
+ _create_pst_df(
+ id="PST_1_group_1", bus_or_busbar_section_id_1="VL2_1_1", bus_or_busbar_section_id_2="VL2_1_3", linear=linear_pst[0]
+ )
+ _create_pst_df(
+ id="PST_2_group_1", bus_or_busbar_section_id_1="VL2_2_1", bus_or_busbar_section_id_2="VL2_2_3", linear=linear_pst[1]
+ )
+ _create_pst_df(
+ id="PST_3_group_2", bus_or_busbar_section_id_1="VL2_1_2", bus_or_busbar_section_id_2="VL2_1_4", linear=linear_pst[2]
+ )
+ _create_pst_df(
+ id="PST_4_group_2", bus_or_busbar_section_id_1="VL2_2_2", bus_or_busbar_section_id_2="VL2_2_4", linear=linear_pst[3]
+ )
+
+ limits = pd.DataFrame.from_records(
+ data=[
+ {
+ "element_id": "L3",
+ "value": 50,
+ "side": "ONE",
+ "name": "permanent_limit",
+ "type": "CURRENT",
+ "acceptable_duration": -1,
+ },
+ {
+ "element_id": "L4",
+ "value": 20,
+ "side": "ONE",
+ "name": "permanent_limit",
+ "type": "CURRENT",
+ "acceptable_duration": -1,
+ },
+ ],
+ index="element_id",
+ )
+ net.create_operational_limits(limits)
+ slack_voltage_id = "VL1"
+ slack_bus_id = "VL1_0"
+ dict_slack = {"voltage_level_id": slack_voltage_id, "bus_id": slack_bus_id}
+ pypowsybl.network.Network.create_extensions(net, extension_name="slackTerminal", **dict_slack)
+ # set taps to neutral position
+ net.update_phase_tap_changers(id="PST_1_group_1", tap=-9)
+ net.update_phase_tap_changers(id="PST_2_group_1", tap=-9)
+ net.update_phase_tap_changers(id="PST_3_group_2", tap=-9)
+ net.update_phase_tap_changers(id="PST_4_group_2", tap=-9)
+ pypowsybl.loadflow.run_ac(net)
+
+ return net
diff --git a/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_example_grids.py b/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_example_grids.py
index ca5d5d90b..78c017887 100644
--- a/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_example_grids.py
+++ b/packages/grid_helpers_pkg/tests/powsybl/test_powsybl_example_grids.py
@@ -13,6 +13,7 @@
basic_node_breaker_network_powsybl_v2,
create_complex_grid_battery_hvdc_svc_3w_trafo,
create_complex_substation_layout_grid,
+ grouped_pst_grid_example,
parallel_pst_example,
powsybl_case30_with_psts,
powsybl_case1354,
@@ -105,3 +106,24 @@ def test_parallel_pst_example_converges():
result_ac = run_ac(net)
assert result_ac[0].status_text == "Converged"
assert len(net.get_operational_limits())
+
+
+@pytest.mark.parametrize(
+ "linear_pst",
+ [
+ None,
+ [True, True, True, True],
+ [False, False, False, False],
+ [True, False, True, False],
+ [False, True, False, True],
+ [True, True, False, False],
+ [False, False, True, True],
+ ],
+)
+def test_grouped_pst_grid_example(linear_pst):
+ net = grouped_pst_grid_example(linear_pst=linear_pst)
+ result_dc = run_dc(net)
+ assert result_dc[0].status_text == "Converged"
+ result_ac = run_ac(net)
+ assert result_ac[0].status_text == "Converged"
+ assert len(net.get_operational_limits())
From 7ad247b2cee98b54376c107da5ead496d1fb5860 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Tue, 16 Jun 2026 16:00:04 +0200
Subject: [PATCH 16/44] refactor: Move grouping logic to importer and
streamline identification
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_dc_solver/example_grids.py | 3 +
.../jax/compute_batch.py | 1 -
.../jax/nodal_inj_optim.py | 28 +---
.../preprocess/network_data.py | 6 +-
.../preprocess/parallel_pst_groups.py | 34 +++--
.../preprocess/powsybl/powsybl_backend.py | 24 +--
.../preprocess/powsybl/powsybl_helpers.py | 54 +------
.../preprocess/preprocess.py | 91 ++++-------
.../tests/jax/test_nodal_inj_optim.py | 143 +++++++++++++++---
.../preprocessing/test_parallel_pst_groups.py | 35 +++--
.../preprocessing/test_powsybl_helpers.py | 1 -
.../tests/preprocessing/test_preprocess.py | 24 +--
.../pypowsybl_import/powsybl_masks.py | 100 +++++++-----
.../pypowsybl_import/test_powsybl_masks.py | 12 +-
.../folder_structure.py | 3 +-
.../preprocess/preprocess_heartbeat.py | 2 +-
packages/interfaces_pkg/tests/test_backend.py | 10 +-
17 files changed, 319 insertions(+), 252 deletions(-)
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 f62538a03..916fbb9c8 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
@@ -1047,7 +1047,9 @@ def case30_with_psts_powsybl(folder: Path) -> None:
trafo_mask = np.ones(len(net.get_2_windings_transformers()), dtype=bool)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_controllable"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["pst_group_labels"], trafo_mask)
gen_mask = np.ones(len(net.get_generators()), dtype=bool)
np.save(output_path_masks / NETWORK_MASK_NAMES["generator_for_nminus1"], gen_mask)
@@ -1087,6 +1089,7 @@ def three_node_pst_example_folder_powsybl(folder: Path) -> None:
trafo_mask = np.ones(len(net.get_2_windings_transformers()), dtype=bool)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_controllable"], trafo_mask)
extract_station_info_powsybl(net, folder)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py
index d8631a25e..23dacdce4 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py
@@ -432,7 +432,6 @@ def compute_symmetric_batch(
topo_res=topo_res,
start_options=nodal_inj_start_options,
dynamic_information=dynamic_information,
- solver_config=solver_config,
)
# Compute the N-1 matrix
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
index d39d92180..26b9e658d 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
@@ -17,7 +17,6 @@
NodalInjectionInformation,
NodalInjOptimResults,
NodalInjStartOptions,
- SolverConfig,
TopologyResults,
)
@@ -37,23 +36,6 @@ def make_start_options(
)
-def canonicalize_parallel_pst_taps(
- pst_tap_indices: Int[Array, " batch_size n_timesteps n_controllable_pst"],
- nodal_inj_info: NodalInjectionInformation,
-) -> Int[Array, " batch_size n_timesteps n_controllable_pst"]:
- """Project PST taps onto the configured parallel-group constraint using one shared delta per group."""
- if nodal_inj_info.parallel_pst_group_mask is None or nodal_inj_info.parallel_pst_group_mask.shape[0] == 0:
- return pst_tap_indices
-
- group_mask = nodal_inj_info.parallel_pst_group_mask.astype(int)
- representative_indices = jnp.argmax(group_mask, axis=1)
- representative_start_taps = nodal_inj_info.starting_tap_idx[representative_indices]
- group_deltas = pst_tap_indices[..., representative_indices] - representative_start_taps
- pst_deltas = jnp.einsum("gp,...g->...p", group_mask, group_deltas)
- canonical_taps = nodal_inj_info.starting_tap_idx + pst_deltas
- return jnp.clip(canonical_taps, a_min=0, a_max=nodal_inj_info.pst_n_taps - 1)
-
-
def apply_pst_taps(
n_0: Float[Array, " batch_size n_timesteps n_branches"],
nodal_injections: Float[Array, " batch_size n_timesteps n_buses"],
@@ -131,7 +113,6 @@ def nodal_inj_optimization(
topo_res: TopologyResults,
start_options: NodalInjStartOptions,
dynamic_information: DynamicInformation,
- solver_config: SolverConfig,
) -> tuple[
Float[Array, " batch_size n_timesteps n_branches"],
Float[Array, " batch_size n_timesteps n_outages n_branches_monitored"],
@@ -154,8 +135,6 @@ def nodal_inj_optimization(
Contains previous PST tap results to apply
dynamic_information : DynamicInformation
Contains PST information and grid data
- solver_config : SolverConfig
- Solver configuration
Returns
-------
@@ -171,10 +150,9 @@ def nodal_inj_optimization(
# Get PST tap indices from start options (shape: batch_size x n_timesteps x n_controllable_pst)
pst_tap_indices = start_options.previous_results.pst_tap_idx
- if pst_tap_indices.ndim == 2:
- pst_tap_indices = pst_tap_indices[None, :, :]
- if solver_config.enable_parallel_pst_group_optim:
- pst_tap_indices = canonicalize_parallel_pst_taps(pst_tap_indices=pst_tap_indices, nodal_inj_info=nodal_inj_info)
+ # TODO: This should not be necessary
+ # if pst_tap_indices.ndim == 2:
+ # pst_tap_indices = pst_tap_indices[None, :, :]
n_0_updated = apply_pst_taps(
n_0=n_0,
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 37ffe86a6..72b2b91e3 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
@@ -311,10 +311,12 @@ class NetworkData:
nodes will be included. The ones that refer to a controllable PST will be mentioned in this mask."""
parallel_pst_group_mask: Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]] = None
- """Boolean masks describing groups of parallel controllable PSTs aligned with PST arrays."""
+ """Boolean masks describing groups of parallel controllable PSTs aligned with PST arrays. If there are no controllable
+ PSTs, this will be None."""
parallel_pst_group_ids: Optional[list[str]] = None
- """Optional identifiers aligned one-to-one with rows of parallel_pst_group_mask.
+ """Optional identifiers aligned one-to-one with rows of parallel_pst_group_mask. If there are no controllable
+ PSTs, this will be None.
This is per parallel PST group, not per controllable PST member. If present, its length must match
`parallel_pst_group_mask.shape[0]`.
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
index 34335da13..c8c0712a9 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -7,21 +7,21 @@
"""Helpers for building parallel PST group masks from per-PST group labels.
-The group membership itself is identified during importing (see ``pst_group_masks`` in
+The group membership itself is identified during importing (see ``pst_group_labels`` in
``powsybl_masks.py``); this module only reshapes the per-PST integer labels into the boolean group
mask consumed by the preprocessing and optimization stages.
"""
import numpy as np
-from beartype.typing import Sequence
+from beartype.typing import Optional, Sequence
from jaxtyping import Bool, Int
-def build_parallel_pst_group_mask(
+def build_2d_pst_group_mask_and_labels(
group_labels: Int[np.ndarray, " n_controllable_pst"],
- pst_ids: Sequence[str | int],
-) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
- """Build the parallel PST group mask and group ids from per-PST group labels.
+ pst_id_list: Sequence[str | int],
+) -> tuple[Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]], Optional[list[str]]]:
+ """Build the parallel PST group mask (matrix) and provide group ids aligned with the matrix rows.
PSTs that share a (non-negative) label belong to the same parallel group. The sentinel ``-1``
marks an ungrouped PST, which is given its own singleton group, so the default with no parallel
@@ -30,23 +30,25 @@ def build_parallel_pst_group_mask(
Parameters
----------
group_labels : Int[np.ndarray, " n_controllable_pst"]
- Integer group label for each controllable PST, aligned with ``pst_ids``.
- pst_ids : Sequence[str | int]
+ Integer group label for each controllable PST, aligned with ``pst_id_list``.
+ pst_id_list : Sequence[str | int]
Ordered controllable PST ids the output mask aligns with.
Returns
-------
- tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]
- A boolean group mask with one row per distinct group (in first-seen order), where each
- column has exactly one ``True``, and a list of group identifiers (the first member's PST id
- per row).
+ tuple[Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]], Optional[list[str]]]
+ 1. A boolean group mask with one row per distinct group (in first-seen order), where each
+ column has exactly one ``True`` value indicating the group membership of the corresponding PST.
+ The column order is aligned with the input ``pst_id_list``. Returns None if there are no controllable PSTs.
+ 2. A list of group identifiers (the first member's PST id per row). Returns None if there are no controllable PSTs.
"""
- pst_id_list = [str(pst_id) for pst_id in pst_ids]
+ pst_id_list = [str(pst_id) for pst_id in pst_id_list]
n_psts = len(pst_id_list)
if group_labels.shape[0] != n_psts:
raise ValueError(f"group_labels has {group_labels.shape[0]} entries but {n_psts} controllable PST ids were given.")
if n_psts == 0:
- return np.zeros((0, 0), dtype=bool), []
+ # TODO: Group mask could also be None
+ return None, None
rows = np.zeros(n_psts, dtype=int)
row_by_label: dict[int, int] = {}
@@ -62,9 +64,9 @@ def build_parallel_pst_group_mask(
else:
row_by_label[label] = next_row
rows[pst_index] = next_row
- group_ids.append(pst_id_list[pst_index])
+ group_ids.append(str(pst_id_list[pst_index]))
next_row += 1
- parallel_pst_group_mask = np.zeros((next_row, n_psts), dtype=bool)
+ parallel_pst_group_mask = np.zeros((len(group_ids), n_psts), dtype=bool)
parallel_pst_group_mask[rows, np.arange(n_psts)] = True
return parallel_pst_group_mask, group_ids
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 a35d75765..7d958b947 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
@@ -18,7 +18,7 @@
from beartype.typing import Optional, Sequence, Union
from fsspec import AbstractFileSystem
from jaxtyping import Bool, Float, Int
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import build_parallel_pst_group_mask
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import build_2d_pst_group_mask_and_labels
from toop_engine_dc_solver.preprocess.powsybl.powsybl_helpers import (
BranchModel,
get_lines,
@@ -250,11 +250,10 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
trafos["overload_weight"] = self._get_mask(NETWORK_MASK_NAMES["trafo_overload_weight"], 1.0, n_trafos)
trafos["disconnectable"] = self._get_mask(NETWORK_MASK_NAMES["trafo_disconnectable"], False, n_trafos)
trafos["n0_n1_max_diff_factor"] = self._get_mask(NETWORK_MASK_NAMES["trafo_n0_n1_max_diff_factor"], -1.0, n_trafos)
- trafos["pst_controllable"] = (
- self._get_mask(NETWORK_MASK_NAMES["trafo_pst_controllable"], False, n_trafos) & trafos["has_pst_tap"]
- )
+ trafos["pst_controllable"] = self._get_mask(NETWORK_MASK_NAMES["trafo_pst_controllable"], False, n_trafos)
+ trafos["has_pst_tap"] = self._get_mask(NETWORK_MASK_NAMES["trafo_has_pst_tap"], False, n_trafos)
# Parallel-PST group label per trafo (-1 for non-grouped). Identified during importing.
- trafos["pst_group"] = self._get_mask(NETWORK_MASK_NAMES["pst_group_masks"], -1, n_trafos)
+ trafos["pst_group"] = self._get_mask(NETWORK_MASK_NAMES["pst_group_labels"], -1, n_trafos)
trafos.sort_values("name", inplace=True)
@@ -457,7 +456,7 @@ def get_phase_shift_linearity(self) -> Bool[np.ndarray, " n_controllable_psts"]:
i.e. whether the shift angle is linear to the tap position
"""
- return self._get_branches()[self.get_controllable_phase_shift_mask()]["has_pst_linear_tap"].values
+ return self._get_branches()[self.get_controllable_phase_shift_mask()]["pst_controllable"].values
def get_phase_shift_taps(self) -> list[Float[np.ndarray, " n_controllable_psts"]]:
"""Get a list of taps for each pst"""
@@ -490,15 +489,18 @@ def get_phase_shift_low_taps(self) -> Int[np.ndarray, " n_controllable_psts"]:
def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"], list[str]]:
"""Get parallel PST grouping metadata aligned with controllable PST arrays.
- The per-trafo group labels are produced during importing (``pst_group_masks``); here they
- are restricted to the controllable PSTs (in array order) and reshaped into the boolean group
- mask consumed downstream.
+ The parallel PSTs and their group labels are identified during importing and stored per PST (branch):
+ 1. BranchModel.``pst_controllable``
+ 2. BranchModel.``pst_group``
+ Use the masks to create a 2-d boolean array with rows as parallel PST groups and columns as controllable PSTs, where
+ True indicates that a PST belongs to a group. The order of the columns is aligned with the order of controllable PSTs
+ in get_controllable_phase_shift_mask(), so that the resulting 2-d array can be used as a mask consumed downstream.
"""
controllable_branches = self._get_branches()[self.get_controllable_phase_shift_mask()]
group_labels = controllable_branches["pst_group"].to_numpy(dtype=int)
- return build_parallel_pst_group_mask(
+ return build_2d_pst_group_mask_and_labels(
group_labels=group_labels,
- pst_ids=self.get_controllable_phase_shift_ids(),
+ pst_id_list=self.get_controllable_phase_shift_ids(),
)
def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
index 40e46336a..be398212a 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
@@ -19,7 +19,7 @@
import pandera as pa
import pandera.typing as pat
import structlog
-from beartype.typing import Literal, Optional
+from beartype.typing import Optional
from pandera import DataFrameModel, Field
from pandera.typing import Index, Series
from pypowsybl.network import Network
@@ -40,9 +40,6 @@ class BranchModel(DataFrameModel):
has_pst_tap: Series[bool] = Field(
nullable=True, default=False, description="Whether the transformer has a phase tap changer"
)
- has_pst_linear_tap: Series[bool] = Field(
- nullable=True, default=False, description="Whether the transformer has a linear phase tap changer"
- )
for_reward: Series[bool] = Field(
nullable=True, default=False, description="Whether the branch is used for reward calculation"
)
@@ -56,7 +53,7 @@ class BranchModel(DataFrameModel):
)
disconnectable: Series[bool] = Field(nullable=True, default=False, description="Whether the branch can be disconnected")
pst_controllable: Series[bool] = Field(
- nullable=True, default=False, description="Whether the branch can be controlled by a phase tap changer"
+ nullable=True, default=False, description="Whether the branch can be controlled by a linear phase tap changer"
)
pst_group: Series[int] = Field(
nullable=True, default=-1, description="Parallel-PST group label (-1 = not a controllable PST / not grouped)"
@@ -262,12 +259,9 @@ def get_trafos(net: Network, net_pu: Optional[Network] = None) -> pat.DataFrame[
+ " ## "
+ (trafos["elementName"] if "elementName" in trafos.keys() else trafos["name"])
)
- linear_psts = get_linear_pst(net, mode="dc")
- trafos["has_pst_linear_tap"] = False
- trafos["has_pst_tap"] = False
- trafos.loc[linear_psts.index, "has_pst_linear_tap"] = linear_psts.values
- trafos.loc[linear_psts.index, "has_pst_tap"] = True
- return add_missing_branch_model_columns(trafos[["x", "r", "rho", "alpha", "name", "has_pst_linear_tap", "has_pst_tap"]])
+ return add_missing_branch_model_columns(
+ trafos[["x", "r", "rho", "alpha", "name", "has_pst_tap", "pst_group", "pst_controllable"]]
+ )
@pa.check_types
@@ -396,41 +390,3 @@ def get_lines(net: Network, net_pu: Optional[Network] = None) -> pat.DataFrame[B
+ (lines["elementName_nopu"] if "elementName_nopu" in lines.keys() else lines["name"])
)
return add_missing_branch_model_columns(lines[["x", "r", "name"]])
-
-
-def get_linear_pst(net: Network, mode: Literal["ac", "dc"], tol: float = 1e-9) -> pd.Series:
- """Check if a given branch has a linear phase shift transformer (PST) tap changer.
-
- A linear PST is defined by the evaluation of x, r, g, b values at different tap positions.
-
- Parameters
- ----------
- net : Network
- The powsybl network
- mode : Literal["ac", "dc"]
- The mode for which to check the linearity of the PST.
- In "dc" mode, only the reactance (x) is checked.
- In "ac" mode, the reactance (x), resistance (r), conductance (g) and susceptance (b) are checked.
- tol : float, optional
- The tolerance for determining linearity, by default 1e-9.
- """
- tap_steps = net.get_phase_tap_changer_steps()
- if mode == "dc":
- linear_cols = ["x"]
- elif mode == "ac":
- linear_cols = ["r", "x", "g", "b"]
- else:
- raise ValueError(f"Invalid mode {mode}. Must be 'ac' or 'dc'.")
-
- pst_ids = tap_steps.index.get_level_values("id").unique()
- trafo_linear_pst = pd.Series(True, index=pst_ids)
-
- for pst_id in pst_ids:
- pst_info = tap_steps.loc[pst_id]
- for col in linear_cols:
- pst_info_col = pst_info[col].values
- if not np.allclose(pst_info_col, pst_info_col[0], atol=tol):
- trafo_linear_pst[pst_id] = False
- break
-
- return trafo_linear_pst
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 9cf20bcae..243c113a0 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
@@ -1276,11 +1276,12 @@ def compute_separation_set_for_stations(
)
-def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> NetworkData:
- """Exclude nonlinear phase shifters from the controllable phase shifter mask.
+def verify_equal_starting_tap_in_pst_group(network_data: NetworkData) -> bool:
+ """Warn if not all phase shifters in a parallel PST group share the same starting tap.
- This is necessary because nonlinear phase shifters cannot be handled correctly in the backend
- at this moment.
+ Different starting taps lead to different updates for the same tap step, which can lead to unexpected behavior.
+ We allow different starting taps within a group, but we want to make sure the user is aware of this fact
+ when they are not the same.
Parameters
----------
@@ -1289,63 +1290,37 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
Returns
-------
- NetworkData
- The network data with the nonlinear phase shifters excluded from the controllable mask
+ bool
+ True if the phase shifters in each parallel PST group share the same starting tap, False otherwise
"""
- if network_data.phase_shift_mask is None or network_data.controllable_phase_shift_mask is None:
- return network_data
+ if network_data.controllable_phase_shift_mask is None:
+ return True
- logger.info(
- "Excluding nonlinear phase shifters from the controllable mask, "
- "since they cannot be handled correctly in the backend."
- )
- pst_linearity = network_data.phase_shift_linearity
parallel_pst_group_mask = network_data.parallel_pst_group_mask
+ if parallel_pst_group_mask is None:
+ return True
+
+ phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx
parallel_pst_group_ids = network_data.parallel_pst_group_ids
- if parallel_pst_group_mask is not None:
- assert parallel_pst_group_mask.shape[1] == pst_linearity.shape[0], (
- "Parallel PST group mask must align with controllable PST linearity information."
+ if parallel_pst_group_ids is None: # Defensive if parallel PST group ids are not given
+ logger.warning(
+ "Parallel PST group mask is given but parallel PST group ids are not given. "
+ "Assuming that the groups are ordered according to the order of the phase shifters.",
+ parallel_pst_group_mask=parallel_pst_group_mask,
)
- # Parallel PSTs are grouped at import time only when they share identical tap-changer
- # parameters, so a group is necessarily all-linear or all-nonlinear; nonlinear groups drop
- # out entirely below when their members leave the controllable set.
- parallel_pst_group_mask = parallel_pst_group_mask[:, pst_linearity]
- kept_group_rows = np.any(parallel_pst_group_mask, axis=1)
- parallel_pst_group_mask = parallel_pst_group_mask[kept_group_rows]
- if parallel_pst_group_ids is not None:
- parallel_pst_group_ids = [
- group_id for group_id, keep in zip(parallel_pst_group_ids, kept_group_rows, strict=True) if keep
- ]
-
- phase_shift_low_tap = network_data.phase_shift_low_tap[pst_linearity]
- phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx[pst_linearity]
- phase_shift_taps = [taps for taps, linear in zip(network_data.phase_shift_taps, pst_linearity, strict=True) if linear]
- if parallel_pst_group_mask is not None:
- for group_idx, group_mask in enumerate(parallel_pst_group_mask):
- if np.sum(group_mask) <= 1:
- continue
- group_starting_taps = phase_shift_low_tap[group_mask] + phase_shift_starting_tap_idx[group_mask]
- if not np.all(group_starting_taps == group_starting_taps[0]):
- logger.warning(
- "Parallel PST group members do not share the same starting tap. "
- "Grouped optimization applies a shared tap delta, then clips each member to its own tap domain.",
- group_index=group_idx,
- starting_taps=group_starting_taps.tolist(),
- )
-
- controllable_pst_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
- controllable_phase_shift_mask = np.zeros_like(network_data.controllable_phase_shift_mask, dtype=bool)
- controllable_phase_shift_mask[controllable_pst_indices[pst_linearity]] = True
- return replace(
- network_data,
- controllable_phase_shift_mask=controllable_phase_shift_mask,
- phase_shift_low_tap=phase_shift_low_tap,
- phase_shift_starting_tap_idx=phase_shift_starting_tap_idx,
- phase_shift_taps=phase_shift_taps,
- phase_shift_linearity=np.ones_like(phase_shift_low_tap, dtype=bool),
- parallel_pst_group_mask=parallel_pst_group_mask,
- parallel_pst_group_ids=parallel_pst_group_ids,
- )
+ parallel_pst_group_ids = np.arange(parallel_pst_group_mask.shape[0])
+ for group_idx, group_mask in enumerate(parallel_pst_group_mask):
+ if np.sum(group_mask) <= 1:
+ continue
+ group_starting_taps = phase_shift_starting_tap_idx[group_mask]
+ if not np.all(group_starting_taps == group_starting_taps[0]):
+ logger.warning(
+ "Parallel PST group members do not share the same starting tap. "
+ "Though members share the same tap step table, starting at different taps leads to different updates ",
+ parallel_pst_group_ids=parallel_pst_group_ids[group_idx],
+ starting_taps=group_starting_taps.tolist(),
+ )
+ return True
def preprocess( # noqa: PLR0915
@@ -1380,8 +1355,8 @@ def preprocess( # noqa: PLR0915
logging_fn("extract_network_data_from_interface", None)
network_data = extract_network_data_from_interface(interface)
- logging_fn("exclude_nonlinear_psts_from_controllable", None)
- network_data = exclude_nonlinear_psts_from_controllable(network_data)
+ logging_fn("verify_equal_starting_tap_in_pst_group", None)
+ assert verify_equal_starting_tap_in_pst_group(network_data)
logging_fn("compute_bridging_branches", None)
network_data = compute_bridging_branches(network_data)
diff --git a/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py b/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py
index f2f6d34b1..b23088975 100644
--- a/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py
+++ b/packages/dc_solver_pkg/tests/jax/test_nodal_inj_optim.py
@@ -10,10 +10,9 @@
import jax.numpy as jnp
from fsspec.implementations.dirfs import DirFileSystem
from jax_dataclasses import replace
-from toop_engine_dc_solver.example_grids import case30_with_psts_powsybl
-from toop_engine_dc_solver.jax.nodal_inj_optim import canonicalize_parallel_pst_taps, nodal_inj_optimization
+from toop_engine_dc_solver.example_grids import case30_with_psts_powsybl, parallel_pst_data_folder
+from toop_engine_dc_solver.jax.nodal_inj_optim import nodal_inj_optimization
from toop_engine_dc_solver.jax.types import (
- NodalInjectionInformation,
NodalInjOptimResults,
NodalInjStartOptions,
TopologyResults,
@@ -76,7 +75,6 @@ def test_compare_nodal_inj_to_powsybl(tmp_path: Path) -> None:
precision_percent=jnp.array(1.0),
),
dynamic_information=di,
- solver_config=solver_config,
)
# When we apply starting taps to flows that were already computed with starting taps,
@@ -107,7 +105,6 @@ def test_compare_nodal_inj_to_powsybl(tmp_path: Path) -> None:
precision_percent=jnp.array(1.0),
),
dynamic_information=di,
- solver_config=solver_config,
)
# Verify that when we apply different taps, we get different flows than with starting taps
@@ -115,26 +112,124 @@ def test_compare_nodal_inj_to_powsybl(tmp_path: Path) -> None:
assert n_0_changed.shape == n_0_batched.shape, "Output shape should match input shape"
-def test_canonicalize_parallel_pst_taps_uses_shared_group_delta() -> None:
- nodal_inj_info = NodalInjectionInformation(
- controllable_pst_indices=jnp.array([0, 1, 2]),
- shift_degree_min=jnp.array([-10.0, -10.0, -10.0]),
- shift_degree_max=jnp.array([10.0, 10.0, 10.0]),
- pst_n_taps=jnp.array([10, 10, 10]),
- pst_tap_values=jnp.zeros((3, 10)),
- starting_tap_idx=jnp.array([4, 4, 2]),
- grid_model_low_tap=jnp.array([0, 0, 0]),
- parallel_pst_group_mask=jnp.array(
- [
- [True, True, False],
- [False, False, True],
- ]
+def test_compare_nodal_inj_to_powsybl_parallel_psts(tmp_path: Path) -> None:
+ """Test that nodal_inj_optimization function works correctly with PST taps.
+
+ This test verifies that:
+ 1. The nodal_inj_optimization function runs without errors
+ 2. Applying different PST tap settings produces different N-0 flows
+ 3. The function correctly handles batch dimensions and returns expected shapes
+ """
+ # Create case30 grid with PSTs in pypowsybl format
+ parallel_pst_data_folder(tmp_path)
+
+ # Load the grid
+ filesystem_dir = DirFileSystem(str(tmp_path))
+ _, static_information, _ = load_grid(filesystem_dir, pandapower=False)
+
+ di = static_information.dynamic_information
+ solver_config = replace(static_information.solver_config, batch_size_bsdf=1, enable_parallel_pst_group_optim=True)
+
+ inj_info = di.nodal_injection_information
+ assert inj_info is not None, "Grid should have PSTs"
+ assert inj_info.starting_tap_idx.shape[0] == 3, "Test grid should have 3 controllable PSTs"
+ assert inj_info.parallel_pst_group_mask is not None, "Test grid should have parallel PST group mask"
+ assert inj_info.parallel_pst_group_mask.shape == (2, 3), "PST group mask should have shape (2, 3)"
+
+ # Get dimensions from unsplit_flow (shape: n_timesteps, n_branches)
+ # We need to add a batch dimension for nodal_inj_optimization
+ n_timesteps, n_branches = di.unsplit_flow.shape
+ batch_size = 1
+
+ # Add batch dimension to unsplit_flow, nodal_injections, and ptdf
+ n_0_batched = di.unsplit_flow[None, :, :] # (1, n_timesteps, n_branches)
+ nodal_injections_batched = di.nodal_injections[None, :, :] # (1, n_timesteps, n_buses)
+ ptdf_batched = di.ptdf[None, :, :] # (1, n_branches, n_buses)
+ from_node_batched = di.from_node[None, :] # (1, n_branches)
+ to_node_batched = di.to_node[None, :] # (1, n_branches)
+
+ taps = inj_info.starting_tap_idx
+ n_0_unchanged, n_1_unchanged, results_unchanged = nodal_inj_optimization(
+ n_0=n_0_batched,
+ nodal_injections=nodal_injections_batched,
+ topo_res=TopologyResults(
+ ptdf=ptdf_batched,
+ from_node=from_node_batched,
+ to_node=to_node_batched,
+ lodf=jnp.array([[]]),
+ success=jnp.ones((batch_size,), dtype=bool),
+ outage_modf=[],
+ bsdf=jnp.zeros((1, n_branches)), # dummy BSDF with batch dimension
+ failure_cases_to_zero=None,
+ disconnection_modf=None,
),
+ start_options=NodalInjStartOptions(
+ previous_results=NodalInjOptimResults(
+ pst_tap_idx=taps[None, None, :], # Add batch and timestep dimensions
+ ),
+ precision_percent=jnp.array(1.0),
+ ),
+ dynamic_information=di,
)
- pst_tap_indices = jnp.array([[[6, 1, 5]]])
- canonical = canonicalize_parallel_pst_taps(pst_tap_indices=pst_tap_indices, nodal_inj_info=nodal_inj_info)
+ # When we apply starting taps to flows that were already computed with starting taps,
+ # we should get different results (applying the same shift twice)
+ # So we just verify the function runs successfully
+ assert n_0_unchanged.shape == n_0_batched.shape, "Output shape should match input shape"
- assert canonical.shape == pst_tap_indices.shape
- assert canonical[0, 0, 0] == canonical[0, 0, 1]
- assert canonical[0, 0, 0] == 6
+ # Now take different taps, lets say we increase all taps by 1 (if possible) and see if n-0 results changed.
+ new_taps = jnp.minimum(taps + 1, inj_info.pst_n_taps - 1) # increase taps by 1 but don't exceed max tap
+ n_0_changed, n_1_changed, results_changed = nodal_inj_optimization(
+ n_0=n_0_batched,
+ nodal_injections=nodal_injections_batched,
+ topo_res=TopologyResults(
+ ptdf=ptdf_batched,
+ from_node=from_node_batched,
+ to_node=to_node_batched,
+ lodf=jnp.array([[]]),
+ success=jnp.ones((batch_size,), dtype=bool),
+ outage_modf=[],
+ bsdf=jnp.zeros((1, n_branches)), # dummy BSDF with batch dimension
+ failure_cases_to_zero=None,
+ disconnection_modf=None,
+ ),
+ start_options=NodalInjStartOptions(
+ previous_results=NodalInjOptimResults(
+ pst_tap_idx=new_taps[None, None, :], # Add batch and timestep dimensions
+ ),
+ precision_percent=jnp.array(1.0),
+ ),
+ dynamic_information=di,
+ )
+
+ # Verify that when we apply different taps, we get different flows than with starting taps
+ assert not jnp.allclose(n_0_changed, n_0_unchanged), "N-0 flows should differ between different tap settings"
+ assert n_0_changed.shape == n_0_batched.shape, "Output shape should match input shape"
+
+ pst_start_options = NodalInjOptimResults(
+ pst_tap_idx=new_taps[None, None, :], # Add batch and timestep dimensions
+ )
+ n_0_changed, n_1_changed, results_changed = nodal_inj_optimization(
+ n_0=n_0_batched,
+ nodal_injections=nodal_injections_batched,
+ topo_res=TopologyResults(
+ ptdf=ptdf_batched,
+ from_node=from_node_batched,
+ to_node=to_node_batched,
+ lodf=jnp.array([[]]),
+ success=jnp.ones((batch_size,), dtype=bool),
+ outage_modf=[],
+ bsdf=jnp.zeros((1, n_branches)), # dummy BSDF with batch dimension
+ failure_cases_to_zero=None,
+ disconnection_modf=None,
+ ),
+ start_options=NodalInjStartOptions(
+ previous_results=pst_start_options,
+ precision_percent=jnp.array(1.0),
+ ),
+ dynamic_information=di,
+ )
+
+ # Verify that when we apply different taps, we get different flows than with starting taps
+ assert not jnp.allclose(n_0_changed, n_0_unchanged), "N-0 flows should differ between different tap settings"
+ assert n_0_changed.shape == n_0_batched.shape, "Output shape should match input shape"
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
index 3346bd466..392c4b24f 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
@@ -7,13 +7,13 @@
import numpy as np
import pytest
-from toop_engine_dc_solver.preprocess.parallel_pst_groups import build_parallel_pst_group_mask
+from toop_engine_dc_solver.preprocess.parallel_pst_groups import build_2d_pst_group_mask_and_labels
def test_build_parallel_pst_group_mask_distinct_labels_yield_identity() -> None:
- mask, group_ids = build_parallel_pst_group_mask(
+ mask, group_ids = build_2d_pst_group_mask_and_labels(
group_labels=np.array([0, 1, 2]),
- pst_ids=["PST1", "PST2", "PST3"],
+ pst_id_list=["PST1", "PST2", "PST3"],
)
assert np.array_equal(mask, np.eye(3, dtype=bool))
@@ -21,9 +21,9 @@ def test_build_parallel_pst_group_mask_distinct_labels_yield_identity() -> None:
def test_build_parallel_pst_group_mask_shared_label_groups_members() -> None:
- mask, group_ids = build_parallel_pst_group_mask(
+ mask, group_ids = build_2d_pst_group_mask_and_labels(
group_labels=np.array([0, 0, 1]),
- pst_ids=["PST1", "PST2", "PST3"],
+ pst_id_list=["PST1", "PST2", "PST3"],
)
# PST1 and PST2 share group 0 (row 0, named after the first member); PST3 is its own group.
@@ -34,9 +34,9 @@ def test_build_parallel_pst_group_mask_shared_label_groups_members() -> None:
def test_build_parallel_pst_group_mask_sentinel_labels_form_singletons() -> None:
- mask, group_ids = build_parallel_pst_group_mask(
+ mask, group_ids = build_2d_pst_group_mask_and_labels(
group_labels=np.array([-1, -1, 0]),
- pst_ids=["PST1", "PST2", "PST3"],
+ pst_id_list=["PST1", "PST2", "PST3"],
)
# The -1 sentinel never merges PSTs: each gets its own row.
@@ -45,12 +45,25 @@ def test_build_parallel_pst_group_mask_sentinel_labels_form_singletons() -> None
def test_build_parallel_pst_group_mask_empty() -> None:
- mask, group_ids = build_parallel_pst_group_mask(group_labels=np.array([], dtype=int), pst_ids=[])
+ mask, group_ids = build_2d_pst_group_mask_and_labels(group_labels=np.array([], dtype=int), pst_id_list=[])
- assert mask.shape == (0, 0)
- assert group_ids == []
+ assert mask is None
+ assert group_ids is None
+
+
+def test_build_parallel_pst_group_mask_integer() -> None:
+ mask, group_ids = build_2d_pst_group_mask_and_labels(
+ group_labels=np.array([0, 0, 1]),
+ pst_id_list=[1, 2, 3],
+ )
+
+ # PST1 and PST2 share group 0 (row 0, named after the first member); PST3 is its own group.
+ assert np.array_equal(mask, np.array([[True, True, False], [False, False, True]], dtype=bool))
+ assert group_ids == ["1", "3"]
+ # Each PST belongs to exactly one group.
+ assert np.array_equal(mask.sum(axis=0), np.ones(3, dtype=int))
def test_build_parallel_pst_group_mask_rejects_length_mismatch() -> None:
with pytest.raises(ValueError, match="entries but"):
- build_parallel_pst_group_mask(group_labels=np.array([0, 1]), pst_ids=["PST1"])
+ build_2d_pst_group_mask_and_labels(group_labels=np.array([0, 1]), pst_id_list=["PST1"])
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
index aea6ae073..a9d288f2f 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
@@ -46,7 +46,6 @@ def test_add_missing_branch_model_columns() -> None:
assert pd.isna(normalized_branches.loc["branch_id", "rho"])
assert pd.isna(normalized_branches.loc["branch_id", "alpha"])
assert bool(normalized_branches.loc["branch_id", "has_pst_tap"]) is False
- assert bool(normalized_branches.loc["branch_id", "has_pst_linear_tap"]) is False
assert bool(normalized_branches.loc["branch_id", "for_reward"]) is False
assert bool(normalized_branches.loc["branch_id", "for_nminus1"]) is False
assert normalized_branches.loc["branch_id", "overload_weight"] == 1.0
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
index a2901820a..57e2138b4 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
@@ -55,7 +55,6 @@
compute_separation_set_for_stations,
convert_multi_outages,
exclude_bridges_from_outage_masks,
- exclude_nonlinear_psts_from_controllable,
filter_disconnectable_branches_nminus2,
filter_inactive_injections,
filter_relevant_nodes_branch_count,
@@ -65,6 +64,7 @@
reduce_branch_dimension,
reduce_node_dimension,
simplify_asset_topology,
+ verify_equal_starting_tap_in_pst_group,
)
from toop_engine_dc_solver.preprocess.preprocess_bb_outage import get_busbar_map_adjacent_branches
from toop_engine_dc_solver.preprocess.preprocess_station_realisations import enumerate_station_realisations
@@ -175,7 +175,7 @@ def test_add_nodal_injections_to_network_data(data_folder: str, network_data: Ne
assert len(network_data.relevant_node_mask) == len(nodal_injections)
-def test_exclude_nonlinear_psts_from_controllable_keeps_member_tap_domains(
+def test_verify_equal_starting_tap_in_pst_group_keeps_member_tap_domains(
complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
"""Grouped linear PSTs keep their own (identical) tap domains; differing start taps only warn.
@@ -199,16 +199,16 @@ def test_exclude_nonlinear_psts_from_controllable_keeps_member_tap_domains(
)
with structlog.testing.capture_logs() as cap_logs:
- updated_network_data = exclude_nonlinear_psts_from_controllable(network_data)
+ assert verify_equal_starting_tap_in_pst_group(network_data)
- assert np.array_equal(updated_network_data.phase_shift_low_tap, np.array([0, 0]))
- assert np.array_equal(updated_network_data.phase_shift_starting_tap_idx, np.array([3, 1]))
- assert np.array_equal(updated_network_data.phase_shift_taps[0], np.array([0.0, 1.0, 2.0, 3.0]))
- assert np.array_equal(updated_network_data.phase_shift_taps[1], np.array([0.0, 1.0, 2.0, 3.0]))
+ assert np.array_equal(network_data.phase_shift_low_tap, np.array([0, 0]))
+ assert np.array_equal(network_data.phase_shift_starting_tap_idx, np.array([3, 1]))
+ assert np.array_equal(network_data.phase_shift_taps[0], np.array([0.0, 1.0, 2.0, 3.0]))
+ assert np.array_equal(network_data.phase_shift_taps[1], np.array([0.0, 1.0, 2.0, 3.0]))
assert any("do not share the same starting tap" in entry["event"] for entry in cap_logs)
-def test_exclude_nonlinear_psts_from_controllable_drops_nonlinear_group_member(
+def test_verify_equal_starting_tap_in_pst_group_drops_nonlinear_group_member(
complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
"""A non-linear member is dropped from a group rather than rejected.
@@ -227,11 +227,11 @@ def test_exclude_nonlinear_psts_from_controllable_drops_nonlinear_group_member(
parallel_pst_group_ids=["group_1"],
)
- updated_network_data = exclude_nonlinear_psts_from_controllable(network_data)
+ assert verify_equal_starting_tap_in_pst_group(network_data)
- assert updated_network_data.controllable_phase_shift_mask.sum() == 1
- assert np.array_equal(updated_network_data.parallel_pst_group_mask, np.array([[True]], dtype=bool))
- assert updated_network_data.parallel_pst_group_ids == ["group_1"]
+ assert network_data.controllable_phase_shift_mask.sum() == 1
+ assert np.array_equal(network_data.parallel_pst_group_mask, np.array([[True]], dtype=bool))
+ assert network_data.parallel_pst_group_ids == ["group_1"]
def test_compute_bridging_branches(data_folder: str, network_data: NetworkData) -> None:
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 3ebb21742..90c05a459 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -102,11 +102,14 @@ class NetworkMasks:
"""trafo_dso_border.npy (a boolean mask of transformers that border the DSO control area)
Currently only used during importing and not part of the PowsyblBackend"""
+ trafo_has_pst_tap: np.ndarray
+ """trafo_has_pst_tap.npy (a boolean mask of transformers that have a PST tap)"""
+
trafo_pst_controllable: np.ndarray
- """Trafos which are a PST and can be controlled"""
+ """trafo_pst_controllable.npy (a boolean mask of transformers that are a PST and can be controlled)"""
- pst_group_masks: np.ndarray
- """pst_group_masks.npy (an integer group label per trafo for parallel PST grouping).
+ pst_group_labels: np.ndarray
+ """pst_group_labels.npy (an integer group label per trafo for parallel PST grouping).
Each entry is the parallel-PST group label of the corresponding trafo: ``-1`` for trafos that
are not controllable PSTs, a unique label for a lone controllable PST, and a shared label for
@@ -188,8 +191,9 @@ def create_default_network_masks(network: Network) -> NetworkMasks:
trafo_blacklisted=np.zeros(len(trafo_df), dtype=bool),
trafo_n0_n1_max_diff_factor=np.ones(len(trafo_df), dtype=float) * -1,
trafo_dso_border=np.zeros(len(trafo_df), dtype=bool),
+ trafo_has_pst_tap=np.zeros(len(trafo_df), dtype=bool),
trafo_pst_controllable=np.zeros(len(trafo_df), dtype=bool),
- pst_group_masks=np.full(len(trafo_df), -1, dtype=int),
+ pst_group_labels=np.full(len(trafo_df), -1, dtype=int),
tie_line_for_reward=np.zeros(len(tie_df), dtype=bool),
tie_line_for_nminus1=np.zeros(len(tie_df), dtype=bool),
tie_line_overload_weight=np.ones(len(tie_df), dtype=float),
@@ -523,15 +527,15 @@ def update_trafo_masks(
nminus1_area_mask = get_mask_for_area_codes(
trafos_df, importer_parameters.area_settings.nminus1_area, region_colums[0], region_colums[1]
)
- controllable_mask = get_mask_for_area_codes(
+ control_area_mask = get_mask_for_area_codes(
trafos_df, importer_parameters.area_settings.control_area, region_colums[0], region_colums[1]
)
view_area_mask = get_mask_for_area_codes(
trafos_df, importer_parameters.area_settings.view_area, region_colums[0], region_colums[1]
)
is_disconnectable = _is_disconnectable(network=network, grid_model_id=trafos_df.index.tolist())
- disconnectable_mask = controllable_mask & hv_trafos & is_disconnectable & ~is_3w_lower_leg
- pst_controllable_mask = controllable_mask & hv_trafos
+ disconnectable_mask = control_area_mask & hv_trafos & is_disconnectable & ~is_3w_lower_leg
+ control_area_hv_trafo_mask = control_area_mask & hv_trafos
outage_mask = nminus1_area_mask & hv_trafos & ~is_3w_lower_leg
reward_mask = view_area_mask & trafos_with_limits & hv_trafos
@@ -547,11 +551,11 @@ def update_trafo_masks(
blacklisted_trafos = trafos_df.index.isin(blacklisted_ids)
disconnectable_mask = disconnectable_mask & ~blacklisted_trafos
- pst_controllable_mask = pst_controllable_mask & ~blacklisted_trafos
- # Identify parallel groups among the remaining controllable PSTs (same bus pair, voltage and
- # tap-changer parameters) so members can be optimized together downstream.
- pst_group_labels = build_pst_group_labels(
- network=network, trafos_df=trafos_df, pst_controllable_mask=pst_controllable_mask
+ control_area_hv_trafo_mask = control_area_hv_trafo_mask & ~blacklisted_trafos
+ # Filter for linear PSTs, which are the only ones we can currently optimize over.
+ # Create groups for the parallel PSTs among them, which can be used downstream.
+ trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels = filter_and_group_linear_psts(
+ network, trafos_df, control_area_hv_trafo_mask
)
outage_mask = outage_mask & ~blacklisted_trafos
reward_mask = reward_mask & ~blacklisted_trafos
@@ -564,24 +568,25 @@ def update_trafo_masks(
trafo_dso_border=trafo_dso_border,
trafo_overload_weight=trafo_overload_weight,
trafo_disconnectable=disconnectable_mask,
- trafo_pst_controllable=pst_controllable_mask,
- pst_group_masks=pst_group_labels,
+ trafo_has_pst_tap=trafo_has_pst_tap,
+ trafo_pst_controllable=trafo_pst_controllable,
+ pst_group_labels=pst_group_labels,
)
-def build_pst_group_labels(
+def filter_and_group_linear_psts(
network: Network,
trafos_df: pd.DataFrame,
- pst_controllable_mask: Bool[np.ndarray, " n_trafos"],
-) -> Int[np.ndarray, " n_trafos"]:
- """Assign a parallel-PST group label to each transformer.
+ control_area_hv_trafo_mask: Bool[np.ndarray, " n_trafos"],
+) -> tuple[Bool[np.ndarray, " n_trafos"], Bool[np.ndarray, " n_trafos"], Int[np.ndarray, " n_trafos"]]:
+ """Filter transformers for all tap changers, for controllable (linear) subset, and identify parallel PST groups.
Two controllable PSTs are considered parallel (share a group) when all of the following hold:
* they connect the same unordered bus pair (``{bus1_id, bus2_id}``, orientation may be swapped),
- * they share the same nominal voltage magnitude, and
+ * they share the same nominal voltage magnitude (which is checked implicitly via the bus pair), and
* they have identical phase-tap-changer parameters: the same tap range (low/high/count) and a
- per-tap ``alpha``/``rho``/``x``/``r`` step table that matches element-wise within a tolerance.
+ per-tap ``alpha``/``rho``/``x``/``r``/``g``/``b`` step table that matches element-wise within a tolerance.
Parameters
----------
@@ -590,8 +595,8 @@ def build_pst_group_labels(
trafos_df: pd.DataFrame
The 2-winding transformer dataframe, indexed by trafo id and including the ``bus1_id``,
``bus2_id`` and ``voltage_level1_id`` columns.
- pst_controllable_mask: Bool[np.ndarray, " n_trafos"]
- Boolean mask (aligned with ``trafos_df`` rows) marking controllable PST transformers.
+ control_area_hv_trafo_mask: Bool[np.ndarray, " n_trafos"]
+ Boolean mask (aligned with ``trafos_df`` rows) marking transformers in the control area.
Returns
-------
@@ -599,28 +604,33 @@ def build_pst_group_labels(
An integer group label per transformer: ``-1`` for trafos that are not controllable PSTs, a
unique label for each lone controllable PST and a shared label for parallel PSTs.
"""
- labels = np.full(len(trafos_df), -1, dtype=int)
+ trafo_pst_controllable = np.zeros(len(trafos_df), dtype=bool)
+ pst_group_labels = np.full(len(trafos_df), -1, dtype=int)
tap_changers = network.get_phase_tap_changers()
- # Candidate PSTs are controllable trafos that actually carry a phase tap changer.
- candidate_mask = pst_controllable_mask & trafos_df.index.isin(tap_changers.index)
- candidate_positions = np.flatnonzero(candidate_mask)
- if candidate_positions.size == 0:
- return labels
+ # Candidate PSTs are trafos that actually carry a phase tap changer.
+ trafo_has_pst_tap = control_area_hv_trafo_mask & trafos_df.index.isin(tap_changers.index)
+ trafo_has_pst_tap_index = np.flatnonzero(trafo_has_pst_tap)
+ if trafo_has_pst_tap_index.size == 0:
+ return trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels
- candidate_ids = trafos_df.index[candidate_positions]
- nominal_v = get_voltage_from_voltage_level_id(network, trafos_df.loc[candidate_ids, "voltage_level1_id"])
- steps = network.get_phase_tap_changer_steps(attributes=["alpha", "rho", "x", "r"])
+ trafo_has_pst_tap_ids = trafos_df.index[trafo_has_pst_tap_index]
+ nominal_v = get_voltage_from_voltage_level_id(network, trafos_df.loc[trafo_has_pst_tap_ids, "voltage_level1_id"])
+
+ steps = network.get_phase_tap_changer_steps(attributes=["alpha", "rho", "x", "r", "g", "b"])
# Bucket candidates by exactly-comparable attributes; the per-tap step tables are then compared
# within each (small) bucket using a numerical tolerance.
step_tables: dict[str, np.ndarray] = {}
buckets: dict[tuple, list[int]] = defaultdict(list)
- for local_idx, (position, pst_id) in enumerate(zip(candidate_positions, candidate_ids, strict=True)):
+ for local_idx, (position, pst_id) in enumerate(zip(trafo_has_pst_tap_index, trafo_has_pst_tap_ids, strict=True)):
low_tap = int(tap_changers.at[pst_id, "low_tap"])
high_tap = int(tap_changers.at[pst_id, "high_tap"])
bus_pair = frozenset({trafos_df.at[pst_id, "bus1_id"], trafos_df.at[pst_id, "bus2_id"]})
step_table = steps.loc[pst_id].sort_index().to_numpy(dtype=float)
+ if _is_nonlinear_pst(step_table):
+ continue
+ trafo_pst_controllable[position] = True
step_tables[pst_id] = step_table
bucket_key = (bus_pair, round(float(nominal_v[local_idx]), 6), low_tap, high_tap, step_table.shape[0])
buckets[bucket_key].append(position)
@@ -642,10 +652,10 @@ def build_pst_group_labels(
still_remaining.append(other_position)
remaining = still_remaining
for position in group_positions:
- labels[position] = next_label
+ pst_group_labels[position] = next_label
next_label += 1
- return labels
+ return trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels
def update_bus_masks(
@@ -1282,3 +1292,25 @@ def _is_disconnectable(network: Network, grid_model_id: list[str]) -> np.ndarray
network.remove_variant("disconnectable_check")
network.set_working_variant("InitialState")
return disconnectable
+
+
+def _is_nonlinear_pst(step_table: pd.DataFrame) -> bool:
+ """Check Powsybl's phase tap changer step table of a transformer for nonlinear behavior.
+
+ This check is done by checking if `rho`, `x, `r, `g`, or `b` columns have at least two different values
+ at different tap positions.
+
+ Parameters
+ ----------
+ step_table: pd.DataFrame
+ The step table of a phase tap changer, containing the `alpha`, `rho`, `x`, `r`, `g`, and `b` columns.
+
+ Returns
+ -------
+ bool
+ True if the step table indicates a nonlinear PST, False otherwise.
+ """
+ for column in ["rho", "x", "r", "g", "b"]:
+ if column in step_table.dtype.names and not np.allclose(step_table[column], step_table[column][0]):
+ return True
+ return False
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index b065c32af..6d616769f 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -56,9 +56,9 @@ def test_create_default_network_masks():
assert isinstance(masks.load_for_nminus1, np.ndarray)
assert isinstance(masks.switch_for_nminus1, np.ndarray)
assert isinstance(masks.switch_for_reward, np.ndarray)
- assert isinstance(masks.pst_group_masks, np.ndarray)
+ assert isinstance(masks.pst_group_labels, np.ndarray)
# Default group labels are -1 (no parallel-PST grouping identified yet).
- assert np.all(masks.pst_group_masks == -1)
+ assert np.all(masks.pst_group_labels == -1)
def test_validate_network_masks(ucte_importer_parameters: UcteImporterParameters):
@@ -857,7 +857,9 @@ def test_build_pst_group_labels_groups_parallel_psts():
trafos = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
pst_controllable_mask = trafos.index.isin(net.get_phase_tap_changers().index)
- labels = powsybl_masks.build_pst_group_labels(network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask)
+ labels = powsybl_masks.filter_and_group_linear_psts(
+ network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask
+ )
label_by_id = dict(zip(trafos.index, labels, strict=True))
# PST1 and PST2 connect the same bus pair with identical tap-changer parameters -> same group.
@@ -874,7 +876,9 @@ def test_build_pst_group_labels_marks_non_controllable_as_ungrouped():
# Only PST1 is controllable; the parallel PST2 and the distinct PST3 are excluded.
pst_controllable_mask = np.asarray(trafos.index == "PST1")
- labels = powsybl_masks.build_pst_group_labels(network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask)
+ labels = powsybl_masks.filter_and_group_linear_psts(
+ network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask
+ )
label_by_id = dict(zip(trafos.index, labels, strict=True))
assert label_by_id["PST1"] >= 0
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
index 51cfc89b7..6855876f4 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
@@ -67,7 +67,8 @@
"trafo_n0_n1_max_diff_factor": "trafo_n0_n1_max_diff_factor.npy",
"trafo_blacklisted": "trafo_blacklisted.npy",
"trafo_pst_controllable": "trafo_pst_controllable.npy",
- "pst_group_masks": "pst_group_masks.npy",
+ "trafo_has_pst_tap": "trafo_has_pst_tap.npy",
+ "pst_group_labels": "pst_group_labels.npy",
"trafo3w_for_nminus1": "trafo3w_for_nminus1.npy",
"trafo3w_for_reward": "trafo3w_for_reward.npy",
"trafo3w_overload_weight": "trafo3w_overload_weight.npy",
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py
index 646e7582a..6c6e77bba 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py
@@ -33,7 +33,7 @@
NumpyPreprocessStage: TypeAlias = Literal[
"preprocess_started",
"extract_network_data_from_interface",
- "exclude_nonlinear_psts_from_controllable",
+ "verify_equal_starting_tap_in_pst_group",
"filter_relevant_nodes",
"assert_network_data",
"compute_ptdf_if_not_given",
diff --git a/packages/interfaces_pkg/tests/test_backend.py b/packages/interfaces_pkg/tests/test_backend.py
index 8e3ffcb5f..26f77e58c 100644
--- a/packages/interfaces_pkg/tests/test_backend.py
+++ b/packages/interfaces_pkg/tests/test_backend.py
@@ -6,13 +6,13 @@
# Mozilla Public License, version 2.0
import numpy as np
-from beartype.typing import Sequence, Union
+from beartype.typing import Optional, Sequence, Union
from jaxtyping import Bool, Float, Int
from toop_engine_interfaces.backend import BackendInterface
class TestBackend(BackendInterface):
- """An AI generated implentation so we can test the functions that infer from others"""
+ """An AI generated implementation so we can test the functions that infer from others"""
def get_slack(self) -> int:
return 0
@@ -35,6 +35,12 @@ def get_shift_angles(self) -> Float[np.ndarray, " n_timestep n_branch"]:
def get_phase_shift_mask(self) -> Bool[np.ndarray, " n_branch"]:
return np.array([True, False])
+ def get_parallel_pst_group_ids(self) -> Optional[list[str]]:
+ return None
+
+ def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]]:
+ return None
+
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
return np.array([True, False, True])
From b93d8b85e52df4567247f2dfb25cd1d23cf871d7 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Wed, 17 Jun 2026 13:58:46 +0000
Subject: [PATCH 17/44] refactor: Add node breaker version of case57 and fix
tests
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../sensitive-files-whitelist.txt | 1 +
data/case57_xiidm/grid.xiidm | 1948 +++++++++++++++++
.../toop_engine_dc_solver/example_grids.py | 20 +
.../preprocess/powsybl/powsybl_backend.py | 4 +-
.../preprocess/powsybl/powsybl_helpers.py | 4 +-
.../preprocess/preprocess.py | 6 +-
packages/dc_solver_pkg/tests/conftest.py | 32 +-
.../preprocessing/test_powsybl_backend.py | 72 +-
.../pypowsybl_import/powsybl_masks.py | 10 +-
.../pypowsybl_import/test_powsybl_masks.py | 22 +-
10 files changed, 2047 insertions(+), 72 deletions(-)
create mode 100644 data/case57_xiidm/grid.xiidm
diff --git a/.pre-commit-hooks/sensitive-files-whitelist.txt b/.pre-commit-hooks/sensitive-files-whitelist.txt
index c4b6e77d1..00d6e449f 100644
--- a/.pre-commit-hooks/sensitive-files-whitelist.txt
+++ b/.pre-commit-hooks/sensitive-files-whitelist.txt
@@ -22,6 +22,7 @@ data/test_grid_node_breaker/nminus1_definition.json
data/test_grid_node_breaker/initial_topology/test_ucte_powsybl_example.xiidm
data/test_grid_node_breaker/initial_topology/asset_topology.json
data/complex_grid/grid.xiidm
+data/case57_xiidm/grid.xiidm
# Notebook files
notebooks/test.xiidm
diff --git a/data/case57_xiidm/grid.xiidm b/data/case57_xiidm/grid.xiidm
new file mode 100644
index 000000000..ccca9f0d2
--- /dev/null
+++ b/data/case57_xiidm/grid.xiidm
@@ -0,0 +1,1948 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 916fbb9c8..6ea0066fe 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,6 +51,7 @@
from toop_engine_grid_helpers.powsybl.loadflow_parameters import 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_importer.pypowsybl_import.powsybl_masks import make_masks, save_masks_to_filesystem
from toop_engine_interfaces.asset_topology import (
Busbar,
BusbarCoupler,
@@ -563,6 +564,25 @@ def case57_data_powsybl(folder: Path) -> None:
)
+def case57_data_powsybl_xiidm(folder: Path) -> None:
+ """Load a powsybl xiidm grid and save importing masks to the given folder path."""
+ net = pypowsybl.network.load(folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ pypowsybl.loadflow.run_ac(net, DISTRIBUTED_SLACK)
+ dir_system = DirFileSystem(folder)
+ save_lf_params_to_fs(DISTRIBUTED_SLACK, dir_system, Path(PREPROCESSING_PATHS["loadflow_parameters_file_path"]))
+
+ network_masks = make_masks(
+ network=net,
+ slack_id=net.get_buses().index[0],
+ importer_parameters=CgmesImporterParameters(
+ area_settings=AreaSettings(control_area=[""], view_area=[""], nminus1_area=[""], cutoff_voltage=1),
+ data_folder=folder,
+ grid_model_file=Path(folder) / PREPROCESSING_PATHS["grid_file_path_powsybl"],
+ ),
+ )
+ save_masks_to_filesystem(network_masks, folder, dir_system)
+
+
def case57_non_converging(folder: Path) -> None:
"""A case57 variant that does not converge in AC but does converge in DC"""
net = pandapower_non_converging_case57()
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 7d958b947..d42f3c20e 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
@@ -503,11 +503,11 @@ def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_gr
pst_id_list=self.get_controllable_phase_shift_ids(),
)
- def get_parallel_pst_group_mask(self) -> Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]:
+ def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_pst_groups n_controllable_pst"]]:
"""Get the parallel PST groups aligned with the controllable PST arrays."""
return self._get_parallel_pst_groups()[0]
- def get_parallel_pst_group_ids(self) -> list[str]:
+ def get_parallel_pst_group_ids(self) -> Optional[list[str]]:
"""Get the parallel PST group ids aligned with the group mask rows."""
return self._get_parallel_pst_groups()[1]
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
index be398212a..ca0830393 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
@@ -259,9 +259,7 @@ def get_trafos(net: Network, net_pu: Optional[Network] = None) -> pat.DataFrame[
+ " ## "
+ (trafos["elementName"] if "elementName" in trafos.keys() else trafos["name"])
)
- return add_missing_branch_model_columns(
- trafos[["x", "r", "rho", "alpha", "name", "has_pst_tap", "pst_group", "pst_controllable"]]
- )
+ return add_missing_branch_model_columns(trafos[["x", "r", "rho", "alpha", "name"]])
@pa.check_types
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 243c113a0..3e19f6f96 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
@@ -280,10 +280,10 @@ def combine_phaseshift_and_injection(network_data: NetworkData) -> NetworkData:
phase_shift_indices = np.flatnonzero(phase_shift_mask)
number_of_phase_shifters = phase_shift_indices.shape[0]
- # We want to find out for each controllable PST which injection it is connected to
- controllable_psts = np.flatnonzero(network_data.controllable_phase_shift_mask[phase_shift_mask])
+ # We want to find out for each phase-shifter (that includes controllable ones) which injection it is connected to
+ controllable_psts_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
controllable_pst_node_mask = np.zeros((number_of_phase_shifters + len(network_data.node_ids),), dtype=bool)
- controllable_pst_node_mask[controllable_psts] = True
+ controllable_pst_node_mask[controllable_psts_indices] = True
# Add nodal injections to the phase shifters
phase_shift_names = [network_data.branch_names[i] for i in phase_shift_indices]
diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py
index 31893bdf7..e852c9a20 100644
--- a/packages/dc_solver_pkg/tests/conftest.py
+++ b/packages/dc_solver_pkg/tests/conftest.py
@@ -41,7 +41,7 @@
from toop_engine_dc_solver.example_grids import (
case14_pandapower,
case30_with_psts_pandapower,
- case57_data_powsybl,
+ case57_data_powsybl_xiidm,
complex_grid_battery_hvdc_svc_3w_trafo_data_folder,
node_breaker_folder_powsybl,
oberrhein_data,
@@ -539,39 +539,35 @@ def data_folder_with_more_branches(_data_folder_with_more_branches: Path, tmp_pa
@pytest.fixture(scope="session")
-def _powsybl_case57_folder(tmp_path_factory: pytest.TempPathFactory) -> Path:
- temp_dir = tmp_path_factory.mktemp("powsybl_case57")
- case57_data_powsybl(temp_dir)
+def _powsybl_case57_folder_xiidm(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ temp_dir = tmp_path_factory.mktemp("powsybl_case57_xiidm")
+ shutil.copy(Path(__file__).parents[3] / "data" / "case57_xiidm" / "grid.xiidm", temp_dir)
+ case57_data_powsybl_xiidm(temp_dir)
return temp_dir
-@pytest.fixture(scope="session")
-def _powsybl_data_folder(_powsybl_case57_folder: Path) -> Path:
- return _powsybl_case57_folder
-
-
@pytest.fixture(scope="function")
-def powsybl_case57_folder(_powsybl_case57_folder: Path, tmp_path: Path) -> Path:
- shutil.copytree(_powsybl_case57_folder, tmp_path, dirs_exist_ok=True)
+def powsybl_case57_folder_xiidm(_powsybl_case57_folder_xiidm: Path, tmp_path: Path) -> Path:
+ shutil.copytree(_powsybl_case57_folder_xiidm, tmp_path, dirs_exist_ok=True)
return tmp_path
@pytest.fixture(scope="function")
-def powsybl_data_folder(_powsybl_data_folder: Path, tmp_path: Path) -> Path:
- shutil.copytree(_powsybl_data_folder, tmp_path, dirs_exist_ok=True)
+def powsybl_data_folder(_powsybl_case57_folder_xiidm: Path, tmp_path: Path) -> Path:
+ shutil.copytree(_powsybl_case57_folder_xiidm, tmp_path, dirs_exist_ok=True)
return tmp_path
@pytest.fixture(scope="session")
-def loaded_powsybl_net(_powsybl_data_folder: Path) -> pypowsybl.network.Network:
- grid_file_path = _powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"]
+def loaded_powsybl_net(_powsybl_case57_folder_xiidm: Path) -> pypowsybl.network.Network:
+ grid_file_path = _powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"]
net = pypowsybl.network.load(grid_file_path)
pypowsybl.loadflow.run_ac(net)
return net
@pytest.fixture(scope="session")
-def _preprocessed_powsybl_data_folder(_powsybl_data_folder: Path, tmp_path_factory: pytest.TempPathFactory) -> Path:
+def _preprocessed_powsybl_data_folder(_powsybl_case57_folder_xiidm: Path, tmp_path_factory: pytest.TempPathFactory) -> Path:
tmp_path = tmp_path_factory.mktemp("powsybl_result")
tmp_grid_file_path = tmp_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]
tmp_grid_file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -583,12 +579,12 @@ def _preprocessed_powsybl_data_folder(_powsybl_data_folder: Path, tmp_path_facto
temp_lf_parameters_file_path.parent.mkdir(parents=True, exist_ok=True)
# Copy over the grid file
shutil.copy(
- _powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"],
+ _powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"],
tmp_grid_file_path,
)
# Extract data from the backend, run preprocessing
- fs_dir = DirFileSystem(str(_powsybl_data_folder))
+ fs_dir = DirFileSystem(str(_powsybl_case57_folder_xiidm))
save_lf_params_to_fs(
DISTRIBUTED_SLACK, DirFileSystem(str(tmp_path)), Path(PREPROCESSING_PATHS["loadflow_parameters_file_path"])
)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
index 25d6cdedf..2a8af2565 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
@@ -43,8 +43,8 @@
from toop_engine_interfaces.nminus1_definition import load_nminus1_definition
-def test_get_branches(powsybl_data_folder: Path) -> None:
- filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+def test_get_branches(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
n_branches = len(backend.get_branch_ids())
n_timesteps = 1
@@ -80,8 +80,8 @@ def test_get_branches(powsybl_data_folder: Path) -> None:
assert np.all(np.isfinite(ac_dc_diff))
-def test_get_nodes(powsybl_data_folder: Path) -> None:
- filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+def test_get_nodes(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
busses = backend.net.get_buses()
n_connected_nodes = sum(busses.connected_component == 0)
@@ -96,8 +96,8 @@ def test_get_nodes(powsybl_data_folder: Path) -> None:
assert backend.get_cross_coupler_limits().shape == (n_connected_nodes,)
-def test_get_injections(powsybl_data_folder: Path) -> None:
- filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+def test_get_injections(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
n_injections = len(backend.get_injection_ids())
n_timesteps = 1
@@ -110,8 +110,8 @@ def test_get_injections(powsybl_data_folder: Path) -> None:
assert backend.get_outaged_injection_mask().shape == (n_injections,)
-def test_ptdf_matrix(powsybl_data_folder: Path) -> None:
- filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+def test_ptdf_matrix(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
net = backend.net
loadflow_ref = net.get_branches()["p1"].loc[backend.get_branch_ids()].values
@@ -153,8 +153,8 @@ def test_ptdf_matrix(powsybl_data_folder: Path) -> None:
assert np.allclose(ac_loadflow, -ac_loadflow_ref)
-def test_extract_network_data(powsybl_data_folder: Path) -> None:
- filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+def test_extract_network_data(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
network_data = preprocess(backend)
assert network_data.ptdf.size > 0
@@ -216,13 +216,15 @@ def test_lodf(preprocessed_powsybl_data_folder: Path) -> None:
assert np.allclose(n_1_loadflow, -n_1[outage_idx])
-def test_injection_outages_match(preprocessed_powsybl_data_folder: Path) -> None:
- net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
- lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
+def test_injection_outages_match(preprocessed_powsybl_case57_folder_xiidm: Path) -> None:
+ net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
+ lf_params = load_lf_params(
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
+ )
static_information = load_static_information(
- preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
)
dynamic_information = static_information.dynamic_information
@@ -259,12 +261,14 @@ def test_injection_outages_match(preprocessed_powsybl_data_folder: Path) -> None
)
-def test_loadflows_match(preprocessed_powsybl_data_folder: Path) -> None:
- net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
- lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
+def test_loadflows_match(preprocessed_powsybl_case57_folder_xiidm: Path) -> None:
+ net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
+ lf_params = load_lf_params(
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
+ )
static_information = load_static_information(
- preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
)
(n_0, n_1), success = run_solver_symmetric(
@@ -305,13 +309,15 @@ def test_loadflows_match(preprocessed_powsybl_data_folder: Path) -> None:
def test_loadflows_match_bat_hvdc_shunt_svc(complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder: Path) -> None:
- preprocessed_powsybl_data_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder
- net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ preprocessed_powsybl_case57_folder_xiidm = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder
+ net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
- lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
+ network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
+ lf_params = load_lf_params(
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
+ )
static_information = load_static_information(
- preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
)
(n_0, n_1), success = run_solver_symmetric(
@@ -353,13 +359,15 @@ def test_loadflows_match_bat_hvdc_shunt_svc(complex_grid_battery_hvdc_svc_3w_tra
def test_loadflows_match_ucte(basic_ucte_data_folder: Path) -> None:
- preprocessed_powsybl_data_folder = basic_ucte_data_folder
- net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
+ preprocessed_powsybl_case57_folder_xiidm = basic_ucte_data_folder
+ net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
static_information = load_static_information(
- preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
+ )
+ lf_params = load_lf_params(
+ preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
)
- lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
(n_0, n_1), success = run_solver_symmetric(
topologies=default_topology(static_information.solver_config),
@@ -399,8 +407,8 @@ def test_loadflows_match_ucte(basic_ucte_data_folder: Path) -> None:
assert np.allclose(-n_1, n_1_ref)
-def test_globally_unique_ids(powsybl_data_folder: Path) -> None:
- filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+def test_globally_unique_ids(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
assert len(backend.get_branch_ids()) == len(set(backend.get_branch_ids()))
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 90c05a459..eac6d5069 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -103,10 +103,10 @@ class NetworkMasks:
Currently only used during importing and not part of the PowsyblBackend"""
trafo_has_pst_tap: np.ndarray
- """trafo_has_pst_tap.npy (a boolean mask of transformers that have a PST tap)"""
+ """trafo_has_pst_tap.npy (a boolean mask of transformers within the control area that have a PST tap)"""
trafo_pst_controllable: np.ndarray
- """trafo_pst_controllable.npy (a boolean mask of transformers that are a PST and can be controlled)"""
+ """trafo_pst_controllable.npy (a boolean mask of transformers that are a PST and can be controlled, i.e. are linear)."""
pst_group_labels: np.ndarray
"""pst_group_labels.npy (an integer group label per trafo for parallel PST grouping).
@@ -627,11 +627,11 @@ def filter_and_group_linear_psts(
low_tap = int(tap_changers.at[pst_id, "low_tap"])
high_tap = int(tap_changers.at[pst_id, "high_tap"])
bus_pair = frozenset({trafos_df.at[pst_id, "bus1_id"], trafos_df.at[pst_id, "bus2_id"]})
- step_table = steps.loc[pst_id].sort_index().to_numpy(dtype=float)
+ step_table = steps.loc[pst_id].sort_index()
if _is_nonlinear_pst(step_table):
continue
trafo_pst_controllable[position] = True
- step_tables[pst_id] = step_table
+ step_tables[pst_id] = step_table.to_numpy(dtype=float)
bucket_key = (bus_pair, round(float(nominal_v[local_idx]), 6), low_tap, high_tap, step_table.shape[0])
buckets[bucket_key].append(position)
@@ -1311,6 +1311,6 @@ def _is_nonlinear_pst(step_table: pd.DataFrame) -> bool:
True if the step table indicates a nonlinear PST, False otherwise.
"""
for column in ["rho", "x", "r", "g", "b"]:
- if column in step_table.dtype.names and not np.allclose(step_table[column], step_table[column][0]):
+ if column in step_table.columns and not np.allclose(step_table[column], step_table[column].iloc[0]):
return True
return False
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index 6d616769f..6473db8e2 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -648,7 +648,7 @@ def test_update_trafo_masks(ucte_file_with_border, ucte_importer_parameters: Uct
)
assert np.array_equal(
network_masks.trafo_pst_controllable,
- np.array([False, False, True, False, False, False]),
+ np.array([False, False, False, False, False, False]),
)
ucte_importer_parameters.area_settings.nminus1_area = ["D8"]
@@ -855,13 +855,15 @@ def test_build_pst_group_labels_groups_parallel_psts():
"""Parallel PSTs (same bus pair, voltage and tap-changer parameters) share a group label."""
net = parallel_pst_example()
trafos = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
- pst_controllable_mask = trafos.index.isin(net.get_phase_tap_changers().index)
+ control_area_hv_trafo_mask = trafos.index.isin(net.get_phase_tap_changers().index)
- labels = powsybl_masks.filter_and_group_linear_psts(
- network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask
+ trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels = powsybl_masks.filter_and_group_linear_psts(
+ network=net, trafos_df=trafos, control_area_hv_trafo_mask=control_area_hv_trafo_mask
)
- label_by_id = dict(zip(trafos.index, labels, strict=True))
+ assert trafo_has_pst_tap.sum() == 3
+ assert trafo_pst_controllable.sum() == 3
+ label_by_id = dict(zip(trafos.index, pst_group_labels, strict=True))
# PST1 and PST2 connect the same bus pair with identical tap-changer parameters -> same group.
assert label_by_id["PST1"] == label_by_id["PST2"]
# PST3 connects a different bus pair with a different tap range -> its own group.
@@ -874,13 +876,15 @@ def test_build_pst_group_labels_marks_non_controllable_as_ungrouped():
net = parallel_pst_example()
trafos = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
# Only PST1 is controllable; the parallel PST2 and the distinct PST3 are excluded.
- pst_controllable_mask = np.asarray(trafos.index == "PST1")
+ control_area_hv_trafo_mask = np.asarray(trafos.index == "PST1")
- labels = powsybl_masks.filter_and_group_linear_psts(
- network=net, trafos_df=trafos, pst_controllable_mask=pst_controllable_mask
+ trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels = powsybl_masks.filter_and_group_linear_psts(
+ network=net, trafos_df=trafos, control_area_hv_trafo_mask=control_area_hv_trafo_mask
)
- label_by_id = dict(zip(trafos.index, labels, strict=True))
+ assert trafo_has_pst_tap.sum() == 1
+ assert trafo_pst_controllable.sum() == 1
+ label_by_id = dict(zip(trafos.index, pst_group_labels, strict=True))
assert label_by_id["PST1"] >= 0
assert label_by_id["PST2"] == -1
assert label_by_id["PST3"] == -1
From 5f85997735a36510e7887c25116d57cdb78d937b Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:43:50 +0000
Subject: [PATCH 18/44] refactor: Grid parsing for groups considers all PSTs
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_dc_solver/example_grids.py | 21 +++--
.../preprocess/network_data.py | 2 +-
.../pandapower/pandapower_backend.py | 2 +-
.../preprocess/parallel_pst_groups.py | 6 +-
.../preprocess/powsybl/powsybl_backend.py | 14 ++-
.../preprocess/powsybl/powsybl_helpers.py | 2 +-
.../preprocess/preprocess.py | 93 +++++++++++--------
.../preprocessing/test_parallel_pst_groups.py | 2 +-
.../preprocessing/test_powsybl_backend.py | 48 ++++------
.../preprocessing/test_powsybl_helpers.py | 2 +-
.../tests/preprocessing/test_preprocess.py | 10 +-
.../pypowsybl_import/powsybl_masks.py | 57 +++++++-----
.../pypowsybl_import/test_powsybl_masks.py | 14 +--
.../folder_structure.py | 2 +-
.../preprocess/preprocess_heartbeat.py | 2 +-
15 files changed, 154 insertions(+), 123 deletions(-)
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 6ea0066fe..302d1c39b 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
@@ -567,13 +567,13 @@ def case57_data_powsybl(folder: Path) -> None:
def case57_data_powsybl_xiidm(folder: Path) -> None:
"""Load a powsybl xiidm grid and save importing masks to the given folder path."""
net = pypowsybl.network.load(folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- pypowsybl.loadflow.run_ac(net, DISTRIBUTED_SLACK)
+ lf_result, *_ = pypowsybl.loadflow.run_ac(net, DISTRIBUTED_SLACK)
dir_system = DirFileSystem(folder)
save_lf_params_to_fs(DISTRIBUTED_SLACK, dir_system, Path(PREPROCESSING_PATHS["loadflow_parameters_file_path"]))
network_masks = make_masks(
network=net,
- slack_id=net.get_buses().index[0],
+ slack_id=lf_result.reference_bus_id,
importer_parameters=CgmesImporterParameters(
area_settings=AreaSettings(control_area=[""], view_area=[""], nminus1_area=[""], cutoff_voltage=1),
data_folder=folder,
@@ -785,7 +785,7 @@ def case9241_pandapower(data_folder: Path) -> None:
"trafo_for_nminus1": trafo_for_nminus1,
"trafo_for_reward": np.ones(len(net.trafo), dtype=bool),
"relevant_subs": all_relevant_subs,
- "trafo_pst_controllable": np.ones(len(net.trafo), dtype=bool),
+ "trafo_pst_linear": np.ones(len(net.trafo), dtype=bool),
}
)
@@ -1041,7 +1041,8 @@ def case30_with_psts_pandapower(folder: Path) -> None:
trafo_mask = np.ones(len(net.trafo), dtype=bool)
np.save(masks_path / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(masks_path / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
- np.save(masks_path / NETWORK_MASK_NAMES["trafo_pst_controllable"], trafo_mask)
+ np.save(masks_path / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_mask)
+ np.save(masks_path / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_mask)
random_topology_info(folder)
save_lf_params_to_fs({}, DirFileSystem(folder), Path(PREPROCESSING_PATHS["loadflow_parameters_file_path"]))
@@ -1065,11 +1066,15 @@ def case30_with_psts_powsybl(folder: Path) -> None:
np.save(output_path_masks / NETWORK_MASK_NAMES["line_for_nminus1"], line_mask)
trafo_mask = np.ones(len(net.get_2_windings_transformers()), dtype=bool)
+ trafo_has_pst_tap = np.zeros(len(net.get_2_windings_transformers()), dtype=bool)
+ trafo_has_pst_tap[-2:] = True
+ trafo_mask_groups = np.full(len(net.get_2_windings_transformers()), -1, dtype=int)
+ trafo_mask_groups[-2:] = np.array([0, 1])
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
- np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_mask)
- np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_controllable"], trafo_mask)
- np.save(output_path_masks / NETWORK_MASK_NAMES["pst_group_labels"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_has_pst_tap)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["pst_group_labels"], trafo_mask_groups)
gen_mask = np.ones(len(net.get_generators()), dtype=bool)
np.save(output_path_masks / NETWORK_MASK_NAMES["generator_for_nminus1"], gen_mask)
@@ -1110,7 +1115,7 @@ def three_node_pst_example_folder_powsybl(folder: Path) -> None:
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_mask)
- np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_controllable"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_mask)
extract_station_info_powsybl(net, folder)
save_lf_params_to_fs(
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 72b2b91e3..d2737ad50 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
@@ -314,7 +314,7 @@ class NetworkData:
"""Boolean masks describing groups of parallel controllable PSTs aligned with PST arrays. If there are no controllable
PSTs, this will be None."""
- parallel_pst_group_ids: Optional[list[str]] = None
+ parallel_pst_group_ids: Optional[Sequence[str]] = None
"""Optional identifiers aligned one-to-one with rows of parallel_pst_group_mask. If there are no controllable
PSTs, this will be None.
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 6bd3f3a94..25a826d86 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
@@ -410,7 +410,7 @@ def _get_controllable_phase_shift_trafo_mask(self) -> Bool[np.ndarray, " n_trafo
try:
controllable_pst_mask = load_numpy_filesystem(
filesystem=self.data_folder_dirfs,
- file_path=str(self._get_masks_path() / NETWORK_MASK_NAMES["trafo_pst_controllable"]),
+ file_path=str(self._get_masks_path() / NETWORK_MASK_NAMES["trafo_has_pst_tap"]),
)
except FileNotFoundError:
controllable_pst_mask = np.zeros(len(self.net.trafo), dtype=bool)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
index c8c0712a9..9d482de51 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/parallel_pst_groups.py
@@ -24,8 +24,7 @@ def build_2d_pst_group_mask_and_labels(
"""Build the parallel PST group mask (matrix) and provide group ids aligned with the matrix rows.
PSTs that share a (non-negative) label belong to the same parallel group. The sentinel ``-1``
- marks an ungrouped PST, which is given its own singleton group, so the default with no parallel
- PSTs reduces to one group per PST.
+ marks normal transformers and should not occur in the input.
Parameters
----------
@@ -42,6 +41,9 @@ def build_2d_pst_group_mask_and_labels(
The column order is aligned with the input ``pst_id_list``. Returns None if there are no controllable PSTs.
2. A list of group identifiers (the first member's PST id per row). Returns None if there are no controllable PSTs.
"""
+ if -1 in group_labels:
+ raise ValueError("Import error: Controllable PSTs include normal transformers")
+
pst_id_list = [str(pst_id) for pst_id in pst_id_list]
n_psts = len(pst_id_list)
if group_labels.shape[0] != n_psts:
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 d42f3c20e..286c93ae3 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
@@ -250,7 +250,7 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
trafos["overload_weight"] = self._get_mask(NETWORK_MASK_NAMES["trafo_overload_weight"], 1.0, n_trafos)
trafos["disconnectable"] = self._get_mask(NETWORK_MASK_NAMES["trafo_disconnectable"], False, n_trafos)
trafos["n0_n1_max_diff_factor"] = self._get_mask(NETWORK_MASK_NAMES["trafo_n0_n1_max_diff_factor"], -1.0, n_trafos)
- trafos["pst_controllable"] = self._get_mask(NETWORK_MASK_NAMES["trafo_pst_controllable"], False, n_trafos)
+ trafos["pst_linear"] = self._get_mask(NETWORK_MASK_NAMES["trafo_pst_linear"], False, n_trafos)
trafos["has_pst_tap"] = self._get_mask(NETWORK_MASK_NAMES["trafo_has_pst_tap"], False, n_trafos)
# Parallel-PST group label per trafo (-1 for non-grouped). Identified during importing.
trafos["pst_group"] = self._get_mask(NETWORK_MASK_NAMES["pst_group_labels"], -1, n_trafos)
@@ -447,19 +447,20 @@ def get_phase_shift_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get a mask of branches that can have a phase shift"""
return self._get_branches()["has_pst_tap"].values
+ # TODO: Current feature creates same result as `get_phase_shift_mask`, but kept for now.
def get_controllable_phase_shift_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get a mask of controllable PSTs"""
- return self._get_branches()["pst_controllable"].values
+ return self._get_branches()["has_pst_tap"].values
def get_phase_shift_linearity(self) -> Bool[np.ndarray, " n_controllable_psts"]:
"""Get the linearity of the phase shift for each controllable PST.
i.e. whether the shift angle is linear to the tap position
"""
- return self._get_branches()[self.get_controllable_phase_shift_mask()]["pst_controllable"].values
+ return self._get_branches()[self.get_controllable_phase_shift_mask()]["pst_linear"].values
def get_phase_shift_taps(self) -> list[Float[np.ndarray, " n_controllable_psts"]]:
- """Get a list of taps for each pst"""
+ """Get a list of taps for each controllable PST"""
shift_taps = []
steps = self.net.get_phase_tap_changer_steps(attributes=["alpha"])
@@ -508,7 +509,10 @@ def get_parallel_pst_group_mask(self) -> Optional[Bool[np.ndarray, " n_parallel_
return self._get_parallel_pst_groups()[0]
def get_parallel_pst_group_ids(self) -> Optional[list[str]]:
- """Get the parallel PST group ids aligned with the group mask rows."""
+ """Get the parallel PST group ids aligned with the group mask rows.
+
+ The group ids are derived from the branch names of the first PST (first-seen order) in the group.
+ """
return self._get_parallel_pst_groups()[1]
def get_relevant_node_mask(self) -> Bool[np.ndarray, " n_node"]:
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
index ca0830393..7b2c254b8 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
@@ -52,7 +52,7 @@ class BranchModel(DataFrameModel):
nullable=True, description="Maximum active power in MW for N-1 cases (taken from 'N-1')"
)
disconnectable: Series[bool] = Field(nullable=True, default=False, description="Whether the branch can be disconnected")
- pst_controllable: Series[bool] = Field(
+ pst_linear: Series[bool] = Field(
nullable=True, default=False, description="Whether the branch can be controlled by a linear phase tap changer"
)
pst_group: Series[int] = Field(
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 3e19f6f96..e11c6f7e1 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
@@ -280,10 +280,10 @@ def combine_phaseshift_and_injection(network_data: NetworkData) -> NetworkData:
phase_shift_indices = np.flatnonzero(phase_shift_mask)
number_of_phase_shifters = phase_shift_indices.shape[0]
- # We want to find out for each phase-shifter (that includes controllable ones) which injection it is connected to
- controllable_psts_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
+ # We want to find out for each controllable PST which injection it is connected to
+ controllable_psts = np.flatnonzero(network_data.controllable_phase_shift_mask[phase_shift_mask])
controllable_pst_node_mask = np.zeros((number_of_phase_shifters + len(network_data.node_ids),), dtype=bool)
- controllable_pst_node_mask[controllable_psts_indices] = True
+ controllable_pst_node_mask[controllable_psts] = True
# Add nodal injections to the phase shifters
phase_shift_names = [network_data.branch_names[i] for i in phase_shift_indices]
@@ -1276,12 +1276,11 @@ def compute_separation_set_for_stations(
)
-def verify_equal_starting_tap_in_pst_group(network_data: NetworkData) -> bool:
- """Warn if not all phase shifters in a parallel PST group share the same starting tap.
+def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> NetworkData:
+ """Exclude nonlinear phase shifters from the controllable phase shifters and paralle grouping.
- Different starting taps lead to different updates for the same tap step, which can lead to unexpected behavior.
- We allow different starting taps within a group, but we want to make sure the user is aware of this fact
- when they are not the same.
+ This is necessary because nonlinear phase shifters cannot be optimized correctly in the backend
+ at this moment.
Parameters
----------
@@ -1290,37 +1289,57 @@ def verify_equal_starting_tap_in_pst_group(network_data: NetworkData) -> bool:
Returns
-------
- bool
- True if the phase shifters in each parallel PST group share the same starting tap, False otherwise
+ NetworkData
+ The network data with the nonlinear phase shifters excluded from the controllable mask
"""
- if network_data.controllable_phase_shift_mask is None:
- return True
-
- parallel_pst_group_mask = network_data.parallel_pst_group_mask
- if parallel_pst_group_mask is None:
- return True
+ if network_data.phase_shift_mask is None or network_data.controllable_phase_shift_mask is None:
+ return network_data
- phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx
- parallel_pst_group_ids = network_data.parallel_pst_group_ids
- if parallel_pst_group_ids is None: # Defensive if parallel PST group ids are not given
+ if not np.all(network_data.phase_shift_linearity):
logger.warning(
- "Parallel PST group mask is given but parallel PST group ids are not given. "
- "Assuming that the groups are ordered according to the order of the phase shifters.",
- parallel_pst_group_mask=parallel_pst_group_mask,
+ "Excluding nonlinear phase shifters from the controllable mask, "
+ "since they cannot be handled correctly in the backend."
)
- parallel_pst_group_ids = np.arange(parallel_pst_group_mask.shape[0])
- for group_idx, group_mask in enumerate(parallel_pst_group_mask):
- if np.sum(group_mask) <= 1:
- continue
- group_starting_taps = phase_shift_starting_tap_idx[group_mask]
- if not np.all(group_starting_taps == group_starting_taps[0]):
- logger.warning(
- "Parallel PST group members do not share the same starting tap. "
- "Though members share the same tap step table, starting at different taps leads to different updates ",
- parallel_pst_group_ids=parallel_pst_group_ids[group_idx],
- starting_taps=group_starting_taps.tolist(),
- )
- return True
+
+ pst_linearity = network_data.phase_shift_linearity
+ phase_shift_low_tap = network_data.phase_shift_low_tap[pst_linearity]
+ phase_shift_starting_tap_idx = network_data.phase_shift_starting_tap_idx[pst_linearity]
+ phase_shift_taps = [taps for taps, linear in zip(network_data.phase_shift_taps, pst_linearity, strict=True) if linear]
+
+ controllable_pst_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
+ controllable_phase_shift_mask = np.zeros_like(network_data.controllable_phase_shift_mask, dtype=bool)
+ controllable_phase_shift_mask[controllable_pst_indices[pst_linearity]] = True
+
+ # Filter groups to linear controllable PSTs and check if the members of the groups share the same starting tap,
+ # otherwise log a warning since this can lead to unexpected behavior in the backend.
+ parallel_pst_group_mask = network_data.parallel_pst_group_mask
+ parallel_pst_group_ids = network_data.parallel_pst_group_ids
+ if parallel_pst_group_mask is not None and parallel_pst_group_ids is not None:
+ # Reduce dimension to linear PSTs
+ parallel_pst_group_mask = parallel_pst_group_mask[pst_linearity][:, pst_linearity]
+ parallel_pst_group_ids = [id for id, linear in zip(parallel_pst_group_ids, pst_linearity, strict=True) if linear]
+ for group_idx, group_mask in enumerate(parallel_pst_group_mask):
+ if np.sum(group_mask) <= 1:
+ continue
+ group_starting_taps = phase_shift_starting_tap_idx[group_mask]
+ if not np.all(group_starting_taps == group_starting_taps[0]):
+ logger.warning(
+ "Parallel PST group members do not share the same starting tap. "
+ "Though members share the same tap step table, starting at different taps leads to different updates ",
+ parallel_pst_group_ids=parallel_pst_group_ids[group_idx],
+ starting_taps=group_starting_taps.tolist(),
+ )
+
+ return replace(
+ network_data,
+ controllable_phase_shift_mask=controllable_phase_shift_mask,
+ phase_shift_low_tap=phase_shift_low_tap,
+ phase_shift_starting_tap_idx=phase_shift_starting_tap_idx,
+ phase_shift_taps=phase_shift_taps,
+ phase_shift_linearity=np.ones_like(phase_shift_low_tap, dtype=bool), # All remaining PSTs are linear
+ parallel_pst_group_mask=parallel_pst_group_mask,
+ parallel_pst_group_ids=parallel_pst_group_ids,
+ )
def preprocess( # noqa: PLR0915
@@ -1355,8 +1374,8 @@ def preprocess( # noqa: PLR0915
logging_fn("extract_network_data_from_interface", None)
network_data = extract_network_data_from_interface(interface)
- logging_fn("verify_equal_starting_tap_in_pst_group", None)
- assert verify_equal_starting_tap_in_pst_group(network_data)
+ logging_fn("exclude_nonlinear_psts_from_controllable", None)
+ network_data = exclude_nonlinear_psts_from_controllable(network_data)
logging_fn("compute_bridging_branches", None)
network_data = compute_bridging_branches(network_data)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
index 392c4b24f..facf2c8be 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
@@ -35,7 +35,7 @@ def test_build_parallel_pst_group_mask_shared_label_groups_members() -> None:
def test_build_parallel_pst_group_mask_sentinel_labels_form_singletons() -> None:
mask, group_ids = build_2d_pst_group_mask_and_labels(
- group_labels=np.array([-1, -1, 0]),
+ group_labels=np.array([0, 1, 2]),
pst_id_list=["PST1", "PST2", "PST3"],
)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
index 2a8af2565..6075c6984 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
@@ -216,15 +216,13 @@ def test_lodf(preprocessed_powsybl_data_folder: Path) -> None:
assert np.allclose(n_1_loadflow, -n_1[outage_idx])
-def test_injection_outages_match(preprocessed_powsybl_case57_folder_xiidm: Path) -> None:
- net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
- lf_params = load_lf_params(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
- )
+def test_injection_outages_match(preprocessed_powsybl_data_folder: Path) -> None:
+ net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
+ lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
static_information = load_static_information(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
)
dynamic_information = static_information.dynamic_information
@@ -261,14 +259,12 @@ def test_injection_outages_match(preprocessed_powsybl_case57_folder_xiidm: Path)
)
-def test_loadflows_match(preprocessed_powsybl_case57_folder_xiidm: Path) -> None:
- net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
- lf_params = load_lf_params(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
- )
+def test_loadflows_match(preprocessed_powsybl_data_folder: Path) -> None:
+ net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
+ lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
static_information = load_static_information(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
)
(n_0, n_1), success = run_solver_symmetric(
@@ -309,15 +305,13 @@ def test_loadflows_match(preprocessed_powsybl_case57_folder_xiidm: Path) -> None
def test_loadflows_match_bat_hvdc_shunt_svc(complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder: Path) -> None:
- preprocessed_powsybl_case57_folder_xiidm = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder
- net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ preprocessed_powsybl_data_folder = complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder
+ net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
- lf_params = load_lf_params(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
- )
+ network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
+ lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
static_information = load_static_information(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
+ preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
)
(n_0, n_1), success = run_solver_symmetric(
@@ -359,15 +353,13 @@ def test_loadflows_match_bat_hvdc_shunt_svc(complex_grid_battery_hvdc_svc_3w_tra
def test_loadflows_match_ucte(basic_ucte_data_folder: Path) -> None:
- preprocessed_powsybl_case57_folder_xiidm = basic_ucte_data_folder
- net = pypowsybl.network.load(preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- network_data = load_network_data(preprocessed_powsybl_case57_folder_xiidm / "network_data.pkl")
+ preprocessed_powsybl_data_folder = basic_ucte_data_folder
+ net = pypowsybl.network.load(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ network_data = load_network_data(preprocessed_powsybl_data_folder / "network_data.pkl")
static_information = load_static_information(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["static_information_file_path"]
- )
- lf_params = load_lf_params(
- preprocessed_powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["loadflow_parameters_file_path"]
+ preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["static_information_file_path"]
)
+ lf_params = load_lf_params(preprocessed_powsybl_data_folder / PREPROCESSING_PATHS["loadflow_parameters_file_path"])
(n_0, n_1), success = run_solver_symmetric(
topologies=default_topology(static_information.solver_config),
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
index a9d288f2f..95e5821fc 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
@@ -52,7 +52,7 @@ def test_add_missing_branch_model_columns() -> None:
assert pd.isna(normalized_branches.loc["branch_id", "p_max_mw"])
assert pd.isna(normalized_branches.loc["branch_id", "p_max_mw_n_1"])
assert bool(normalized_branches.loc["branch_id", "disconnectable"]) is False
- assert bool(normalized_branches.loc["branch_id", "pst_controllable"]) is False
+ assert bool(normalized_branches.loc["branch_id", "pst_linear"]) is False
# pst_group must be a recognized BranchModel column so it survives normalization (incl. the
# empty-trafo path) and reaches _get_branches; non-PST branches default to -1.
assert "pst_group" in normalized_branches.columns
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
index 57e2138b4..5e2f8dcb7 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_preprocess.py
@@ -55,6 +55,7 @@
compute_separation_set_for_stations,
convert_multi_outages,
exclude_bridges_from_outage_masks,
+ exclude_nonlinear_psts_from_controllable,
filter_disconnectable_branches_nminus2,
filter_inactive_injections,
filter_relevant_nodes_branch_count,
@@ -64,7 +65,6 @@
reduce_branch_dimension,
reduce_node_dimension,
simplify_asset_topology,
- verify_equal_starting_tap_in_pst_group,
)
from toop_engine_dc_solver.preprocess.preprocess_bb_outage import get_busbar_map_adjacent_branches
from toop_engine_dc_solver.preprocess.preprocess_station_realisations import enumerate_station_realisations
@@ -175,7 +175,7 @@ def test_add_nodal_injections_to_network_data(data_folder: str, network_data: Ne
assert len(network_data.relevant_node_mask) == len(nodal_injections)
-def test_verify_equal_starting_tap_in_pst_group_keeps_member_tap_domains(
+def test_exclude_nonlinear_psts_from_controllable_keeps_member_tap_domains(
complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
"""Grouped linear PSTs keep their own (identical) tap domains; differing start taps only warn.
@@ -199,7 +199,7 @@ def test_verify_equal_starting_tap_in_pst_group_keeps_member_tap_domains(
)
with structlog.testing.capture_logs() as cap_logs:
- assert verify_equal_starting_tap_in_pst_group(network_data)
+ network_data = exclude_nonlinear_psts_from_controllable(network_data)
assert np.array_equal(network_data.phase_shift_low_tap, np.array([0, 0]))
assert np.array_equal(network_data.phase_shift_starting_tap_idx, np.array([3, 1]))
@@ -208,7 +208,7 @@ def test_verify_equal_starting_tap_in_pst_group_keeps_member_tap_domains(
assert any("do not share the same starting tap" in entry["event"] for entry in cap_logs)
-def test_verify_equal_starting_tap_in_pst_group_drops_nonlinear_group_member(
+def test_exclude_nonlinear_psts_from_controllable_drops_nonlinear_group_member(
complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder: Path,
) -> None:
"""A non-linear member is dropped from a group rather than rejected.
@@ -227,7 +227,7 @@ def test_verify_equal_starting_tap_in_pst_group_drops_nonlinear_group_member(
parallel_pst_group_ids=["group_1"],
)
- assert verify_equal_starting_tap_in_pst_group(network_data)
+ network_data = exclude_nonlinear_psts_from_controllable(network_data)
assert network_data.controllable_phase_shift_mask.sum() == 1
assert np.array_equal(network_data.parallel_pst_group_mask, np.array([[True]], dtype=bool))
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index eac6d5069..67f463a16 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -105,15 +105,15 @@ class NetworkMasks:
trafo_has_pst_tap: np.ndarray
"""trafo_has_pst_tap.npy (a boolean mask of transformers within the control area that have a PST tap)"""
- trafo_pst_controllable: np.ndarray
- """trafo_pst_controllable.npy (a boolean mask of transformers that are a PST and can be controlled, i.e. are linear)."""
+ trafo_pst_linear: np.ndarray
+ """trafo_pst_linear.npy (a boolean mask of transformers within the control area that are linear PSTs)."""
pst_group_labels: np.ndarray
"""pst_group_labels.npy (an integer group label per trafo for parallel PST grouping).
Each entry is the parallel-PST group label of the corresponding trafo: ``-1`` for trafos that
- are not controllable PSTs, a unique label for a lone controllable PST, and a shared label for
- controllable PSTs identified as parallel (same bus pair, voltage and tap-changer parameters).
+ are not PSTs, a unique label for a lone PST, and a shared label for PSTs identified as parallel
+ (same bus pair, voltage and tap-changer parameters).
"""
tie_line_for_reward: np.ndarray
@@ -192,7 +192,7 @@ def create_default_network_masks(network: Network) -> NetworkMasks:
trafo_n0_n1_max_diff_factor=np.ones(len(trafo_df), dtype=float) * -1,
trafo_dso_border=np.zeros(len(trafo_df), dtype=bool),
trafo_has_pst_tap=np.zeros(len(trafo_df), dtype=bool),
- trafo_pst_controllable=np.zeros(len(trafo_df), dtype=bool),
+ trafo_pst_linear=np.zeros(len(trafo_df), dtype=bool),
pst_group_labels=np.full(len(trafo_df), -1, dtype=int),
tie_line_for_reward=np.zeros(len(tie_df), dtype=bool),
tie_line_for_nminus1=np.zeros(len(tie_df), dtype=bool),
@@ -554,7 +554,7 @@ def update_trafo_masks(
control_area_hv_trafo_mask = control_area_hv_trafo_mask & ~blacklisted_trafos
# Filter for linear PSTs, which are the only ones we can currently optimize over.
# Create groups for the parallel PSTs among them, which can be used downstream.
- trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels = filter_and_group_linear_psts(
+ trafo_has_pst_tap, trafo_pst_linear, pst_group_labels = filter_and_group_linear_psts(
network, trafos_df, control_area_hv_trafo_mask
)
outage_mask = outage_mask & ~blacklisted_trafos
@@ -569,7 +569,7 @@ def update_trafo_masks(
trafo_overload_weight=trafo_overload_weight,
trafo_disconnectable=disconnectable_mask,
trafo_has_pst_tap=trafo_has_pst_tap,
- trafo_pst_controllable=trafo_pst_controllable,
+ trafo_pst_linear=trafo_pst_linear,
pst_group_labels=pst_group_labels,
)
@@ -579,9 +579,9 @@ def filter_and_group_linear_psts(
trafos_df: pd.DataFrame,
control_area_hv_trafo_mask: Bool[np.ndarray, " n_trafos"],
) -> tuple[Bool[np.ndarray, " n_trafos"], Bool[np.ndarray, " n_trafos"], Int[np.ndarray, " n_trafos"]]:
- """Filter transformers for all tap changers, for controllable (linear) subset, and identify parallel PST groups.
+ """Filter transformers for all tap changers, for linear subset, and identify parallel PST groups.
- Two controllable PSTs are considered parallel (share a group) when all of the following hold:
+ Two PSTs are considered parallel (share a group) when all of the following hold:
* they connect the same unordered bus pair (``{bus1_id, bus2_id}``, orientation may be swapped),
* they share the same nominal voltage magnitude (which is checked implicitly via the bus pair), and
@@ -600,11 +600,15 @@ def filter_and_group_linear_psts(
Returns
-------
- Int[np.ndarray, " n_trafos"]
- An integer group label per transformer: ``-1`` for trafos that are not controllable PSTs, a
- unique label for each lone controllable PST and a shared label for parallel PSTs.
+ trafo_has_pst_tap: Bool[np.ndarray, " n_trafos"]
+ Boolean mask marking transformers that have a phase tap changer and are in the control area.
+ trafo_pst_linear: Bool[np.ndarray, " n_trafos"]
+ Boolean mask marking transformers that have a linear phase tap changer and are in the control area.
+ pst_group_labels: Int[np.ndarray, " n_trafos"]
+ Integer array of parallel PST group labels, aligned with ``trafos_df`` rows. Each group of parallel PSTs shares a
+ unique positive integer label. Non-PSTs are labeled with ``-1``.
"""
- trafo_pst_controllable = np.zeros(len(trafos_df), dtype=bool)
+ trafo_pst_linear = np.zeros(len(trafos_df), dtype=bool)
pst_group_labels = np.full(len(trafos_df), -1, dtype=int)
tap_changers = network.get_phase_tap_changers()
@@ -612,7 +616,7 @@ def filter_and_group_linear_psts(
trafo_has_pst_tap = control_area_hv_trafo_mask & trafos_df.index.isin(tap_changers.index)
trafo_has_pst_tap_index = np.flatnonzero(trafo_has_pst_tap)
if trafo_has_pst_tap_index.size == 0:
- return trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels
+ return trafo_has_pst_tap, trafo_pst_linear, pst_group_labels
trafo_has_pst_tap_ids = trafos_df.index[trafo_has_pst_tap_index]
nominal_v = get_voltage_from_voltage_level_id(network, trafos_df.loc[trafo_has_pst_tap_ids, "voltage_level1_id"])
@@ -628,11 +632,16 @@ def filter_and_group_linear_psts(
high_tap = int(tap_changers.at[pst_id, "high_tap"])
bus_pair = frozenset({trafos_df.at[pst_id, "bus1_id"], trafos_df.at[pst_id, "bus2_id"]})
step_table = steps.loc[pst_id].sort_index()
- if _is_nonlinear_pst(step_table):
- continue
- trafo_pst_controllable[position] = True
+ trafo_pst_linear[position] = _is_linear_pst(step_table)
step_tables[pst_id] = step_table.to_numpy(dtype=float)
- bucket_key = (bus_pair, round(float(nominal_v[local_idx]), 6), low_tap, high_tap, step_table.shape[0])
+ bucket_key = (
+ bus_pair,
+ round(float(nominal_v[local_idx]), 6),
+ low_tap,
+ high_tap,
+ step_table.shape[0],
+ trafo_pst_linear[position],
+ )
buckets[bucket_key].append(position)
next_label = 0
@@ -655,7 +664,7 @@ def filter_and_group_linear_psts(
pst_group_labels[position] = next_label
next_label += 1
- return trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels
+ return trafo_has_pst_tap, trafo_pst_linear, pst_group_labels
def update_bus_masks(
@@ -1294,8 +1303,8 @@ def _is_disconnectable(network: Network, grid_model_id: list[str]) -> np.ndarray
return disconnectable
-def _is_nonlinear_pst(step_table: pd.DataFrame) -> bool:
- """Check Powsybl's phase tap changer step table of a transformer for nonlinear behavior.
+def _is_linear_pst(step_table: pd.DataFrame) -> bool:
+ """Check Powsybl's phase tap changer step table of a transformer for linear behavior.
This check is done by checking if `rho`, `x, `r, `g`, or `b` columns have at least two different values
at different tap positions.
@@ -1308,9 +1317,9 @@ def _is_nonlinear_pst(step_table: pd.DataFrame) -> bool:
Returns
-------
bool
- True if the step table indicates a nonlinear PST, False otherwise.
+ True if the step table indicates a linear PST, False otherwise.
"""
for column in ["rho", "x", "r", "g", "b"]:
if column in step_table.columns and not np.allclose(step_table[column], step_table[column].iloc[0]):
- return True
- return False
+ return False
+ return True
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index 6473db8e2..583e98ba7 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -307,7 +307,7 @@ def test_update_masks_apply_ignore_list(ucte_file_with_border, ucte_importer_par
assert not trafo_masks_ignored.trafo_for_nminus1[ignored_trafo_idx]
assert not trafo_masks_ignored.trafo_for_reward[ignored_trafo_idx]
assert not trafo_masks_ignored.trafo_disconnectable[ignored_trafo_idx]
- assert not trafo_masks_ignored.trafo_pst_controllable[ignored_trafo_idx]
+ assert not trafo_masks_ignored.trafo_pst_linear[ignored_trafo_idx]
assert not tie_and_dangling_masks_ignored.tie_line_for_nminus1[ignored_tie_idx]
assert not tie_and_dangling_masks_ignored.tie_line_for_reward[ignored_tie_idx]
@@ -462,7 +462,7 @@ def test_update_masks_apply_ignore_list_cgmes(
assert not trafo_masks_ignored.trafo_for_nminus1[ignored_trafo_idx]
assert not trafo_masks_ignored.trafo_for_reward[ignored_trafo_idx]
assert not trafo_masks_ignored.trafo_disconnectable[ignored_trafo_idx]
- assert not trafo_masks_ignored.trafo_pst_controllable[ignored_trafo_idx]
+ assert not trafo_masks_ignored.trafo_pst_linear[ignored_trafo_idx]
if ignored_tie_id is not None:
ignored_tie_idx = tie_df.index.get_loc(ignored_tie_id)
@@ -647,7 +647,7 @@ def test_update_trafo_masks(ucte_file_with_border, ucte_importer_parameters: Uct
np.array([False, False, False, False, False, False]),
)
assert np.array_equal(
- network_masks.trafo_pst_controllable,
+ network_masks.trafo_pst_linear,
np.array([False, False, False, False, False, False]),
)
@@ -857,12 +857,12 @@ def test_build_pst_group_labels_groups_parallel_psts():
trafos = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
control_area_hv_trafo_mask = trafos.index.isin(net.get_phase_tap_changers().index)
- trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels = powsybl_masks.filter_and_group_linear_psts(
+ trafo_has_pst_tap, trafo_pst_linear, pst_group_labels = powsybl_masks.filter_and_group_linear_psts(
network=net, trafos_df=trafos, control_area_hv_trafo_mask=control_area_hv_trafo_mask
)
assert trafo_has_pst_tap.sum() == 3
- assert trafo_pst_controllable.sum() == 3
+ assert trafo_pst_linear.sum() == 3
label_by_id = dict(zip(trafos.index, pst_group_labels, strict=True))
# PST1 and PST2 connect the same bus pair with identical tap-changer parameters -> same group.
assert label_by_id["PST1"] == label_by_id["PST2"]
@@ -878,12 +878,12 @@ def test_build_pst_group_labels_marks_non_controllable_as_ungrouped():
# Only PST1 is controllable; the parallel PST2 and the distinct PST3 are excluded.
control_area_hv_trafo_mask = np.asarray(trafos.index == "PST1")
- trafo_has_pst_tap, trafo_pst_controllable, pst_group_labels = powsybl_masks.filter_and_group_linear_psts(
+ trafo_has_pst_tap, trafo_pst_linear, pst_group_labels = powsybl_masks.filter_and_group_linear_psts(
network=net, trafos_df=trafos, control_area_hv_trafo_mask=control_area_hv_trafo_mask
)
assert trafo_has_pst_tap.sum() == 1
- assert trafo_pst_controllable.sum() == 1
+ assert trafo_pst_linear.sum() == 1
label_by_id = dict(zip(trafos.index, pst_group_labels, strict=True))
assert label_by_id["PST1"] >= 0
assert label_by_id["PST2"] == -1
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
index 6855876f4..22bfeb12e 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/folder_structure.py
@@ -66,7 +66,7 @@
"trafo_dso_border": "trafo_dso_border.npy",
"trafo_n0_n1_max_diff_factor": "trafo_n0_n1_max_diff_factor.npy",
"trafo_blacklisted": "trafo_blacklisted.npy",
- "trafo_pst_controllable": "trafo_pst_controllable.npy",
+ "trafo_pst_linear": "trafo_pst_linear.npy",
"trafo_has_pst_tap": "trafo_has_pst_tap.npy",
"pst_group_labels": "pst_group_labels.npy",
"trafo3w_for_nminus1": "trafo3w_for_nminus1.npy",
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py
index 6c6e77bba..646e7582a 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_heartbeat.py
@@ -33,7 +33,7 @@
NumpyPreprocessStage: TypeAlias = Literal[
"preprocess_started",
"extract_network_data_from_interface",
- "verify_equal_starting_tap_in_pst_group",
+ "exclude_nonlinear_psts_from_controllable",
"filter_relevant_nodes",
"assert_network_data",
"compute_ptdf_if_not_given",
From a24337d2781c854e75fd8c047775ede3df73334b Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 13:58:35 +0000
Subject: [PATCH 19/44] fix: Align parallel groups with filter
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../preprocess/powsybl/powsybl_backend.py | 7 ++---
.../preprocess/preprocess.py | 17 +++++++----
packages/dc_solver_pkg/tests/conftest.py | 28 ++++++++++++++-----
3 files changed, 36 insertions(+), 16 deletions(-)
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 286c93ae3..3da188c44 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
@@ -227,7 +227,7 @@ def _get_lines(self) -> pat.DataFrame[BranchModel]:
lines["for_nminus1"] = self._get_mask(NETWORK_MASK_NAMES["line_for_nminus1"], False, n_lines)
lines["overload_weight"] = self._get_mask(NETWORK_MASK_NAMES["line_overload_weight"], 1.0, n_lines)
lines["disconnectable"] = self._get_mask(NETWORK_MASK_NAMES["line_disconnectable"], False, n_lines)
- lines.sort_values("name", inplace=True)
+ lines.sort_index(inplace=True)
return lines
@@ -243,6 +243,7 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
return trafos
n_trafos = len(trafos)
+ trafos.sort_index(inplace=True)
# Add N-1 and observation masks
trafos["for_reward"] = self._get_mask(NETWORK_MASK_NAMES["trafo_for_reward"], False, n_trafos)
@@ -255,8 +256,6 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
# Parallel-PST group label per trafo (-1 for non-grouped). Identified during importing.
trafos["pst_group"] = self._get_mask(NETWORK_MASK_NAMES["pst_group_labels"], -1, n_trafos)
- trafos.sort_values("name", inplace=True)
-
return trafos
@functools.lru_cache
@@ -272,7 +271,7 @@ def _get_tie_lines(self) -> pat.DataFrame[BranchModel]:
tie_lines["overload_weight"] = np.ones(n_tie_lines)
tie_lines["disconnectable"] = np.zeros(n_tie_lines, dtype=bool)
- tie_lines.sort_values("name", inplace=True)
+ tie_lines.sort_index(inplace=True)
return tie_lines
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 e11c6f7e1..1745b1ff9 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
@@ -1314,20 +1314,27 @@ def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> Netwo
# otherwise log a warning since this can lead to unexpected behavior in the backend.
parallel_pst_group_mask = network_data.parallel_pst_group_mask
parallel_pst_group_ids = network_data.parallel_pst_group_ids
- if parallel_pst_group_mask is not None and parallel_pst_group_ids is not None:
+ if parallel_pst_group_mask is not None:
# Reduce dimension to linear PSTs
- parallel_pst_group_mask = parallel_pst_group_mask[pst_linearity][:, pst_linearity]
- parallel_pst_group_ids = [id for id, linear in zip(parallel_pst_group_ids, pst_linearity, strict=True) if linear]
+ parallel_pst_group_mask = parallel_pst_group_mask[:, pst_linearity]
+ kept_group_rows = np.any(parallel_pst_group_mask, axis=1)
+ parallel_pst_group_mask = parallel_pst_group_mask[kept_group_rows]
+ if parallel_pst_group_ids is not None:
+ parallel_pst_group_ids = [
+ group_id for group_id, keep in zip(parallel_pst_group_ids, kept_group_rows, strict=True) if keep
+ ]
for group_idx, group_mask in enumerate(parallel_pst_group_mask):
if np.sum(group_mask) <= 1:
continue
group_starting_taps = phase_shift_starting_tap_idx[group_mask]
if not np.all(group_starting_taps == group_starting_taps[0]):
+ log_fields = {"starting_taps": group_starting_taps.tolist()}
+ if parallel_pst_group_ids is not None:
+ log_fields["parallel_pst_group_ids"] = parallel_pst_group_ids[group_idx]
logger.warning(
"Parallel PST group members do not share the same starting tap. "
"Though members share the same tap step table, starting at different taps leads to different updates ",
- parallel_pst_group_ids=parallel_pst_group_ids[group_idx],
- starting_taps=group_starting_taps.tolist(),
+ **log_fields,
)
return replace(
diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py
index e852c9a20..964c14fca 100644
--- a/packages/dc_solver_pkg/tests/conftest.py
+++ b/packages/dc_solver_pkg/tests/conftest.py
@@ -41,6 +41,7 @@
from toop_engine_dc_solver.example_grids import (
case14_pandapower,
case30_with_psts_pandapower,
+ case57_data_powsybl,
case57_data_powsybl_xiidm,
complex_grid_battery_hvdc_svc_3w_trafo_data_folder,
node_breaker_folder_powsybl,
@@ -546,6 +547,19 @@ def _powsybl_case57_folder_xiidm(tmp_path_factory: pytest.TempPathFactory) -> Pa
return temp_dir
+@pytest.fixture(scope="session")
+def _powsybl_case57_folder(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ temp_dir = tmp_path_factory.mktemp("powsybl_case57")
+ case57_data_powsybl(temp_dir)
+ return temp_dir
+
+
+@pytest.fixture(scope="function")
+def powsybl_case57_folder(_powsybl_case57_folder: Path, tmp_path: Path) -> Path:
+ shutil.copytree(_powsybl_case57_folder, tmp_path, dirs_exist_ok=True)
+ return tmp_path
+
+
@pytest.fixture(scope="function")
def powsybl_case57_folder_xiidm(_powsybl_case57_folder_xiidm: Path, tmp_path: Path) -> Path:
shutil.copytree(_powsybl_case57_folder_xiidm, tmp_path, dirs_exist_ok=True)
@@ -553,21 +567,21 @@ def powsybl_case57_folder_xiidm(_powsybl_case57_folder_xiidm: Path, tmp_path: Pa
@pytest.fixture(scope="function")
-def powsybl_data_folder(_powsybl_case57_folder_xiidm: Path, tmp_path: Path) -> Path:
- shutil.copytree(_powsybl_case57_folder_xiidm, tmp_path, dirs_exist_ok=True)
+def powsybl_data_folder(_powsybl_case57_folder: Path, tmp_path: Path) -> Path:
+ shutil.copytree(_powsybl_case57_folder, tmp_path, dirs_exist_ok=True)
return tmp_path
@pytest.fixture(scope="session")
-def loaded_powsybl_net(_powsybl_case57_folder_xiidm: Path) -> pypowsybl.network.Network:
- grid_file_path = _powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"]
+def loaded_powsybl_net(_powsybl_case57_folder: Path) -> pypowsybl.network.Network:
+ grid_file_path = _powsybl_case57_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"]
net = pypowsybl.network.load(grid_file_path)
pypowsybl.loadflow.run_ac(net)
return net
@pytest.fixture(scope="session")
-def _preprocessed_powsybl_data_folder(_powsybl_case57_folder_xiidm: Path, tmp_path_factory: pytest.TempPathFactory) -> Path:
+def _preprocessed_powsybl_data_folder(_powsybl_case57_folder: Path, tmp_path_factory: pytest.TempPathFactory) -> Path:
tmp_path = tmp_path_factory.mktemp("powsybl_result")
tmp_grid_file_path = tmp_path / PREPROCESSING_PATHS["grid_file_path_powsybl"]
tmp_grid_file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -579,12 +593,12 @@ def _preprocessed_powsybl_data_folder(_powsybl_case57_folder_xiidm: Path, tmp_pa
temp_lf_parameters_file_path.parent.mkdir(parents=True, exist_ok=True)
# Copy over the grid file
shutil.copy(
- _powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["grid_file_path_powsybl"],
+ _powsybl_case57_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"],
tmp_grid_file_path,
)
# Extract data from the backend, run preprocessing
- fs_dir = DirFileSystem(str(_powsybl_case57_folder_xiidm))
+ fs_dir = DirFileSystem(str(_powsybl_case57_folder))
save_lf_params_to_fs(
DISTRIBUTED_SLACK, DirFileSystem(str(tmp_path)), Path(PREPROCESSING_PATHS["loadflow_parameters_file_path"])
)
From a52e6c965e34b3fc7dac607ad6b2e6611c170e98 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 15:30:29 +0000
Subject: [PATCH 20/44] fix: order trafos correctly
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_dc_solver/example_grids.py | 13 ++++++------
.../preprocess/powsybl/powsybl_backend.py | 4 ++--
.../powsybl/powsybl_helpers.py | 16 ++++++++++++++
.../loadflow_based_current_limits.py | 3 ++-
.../pypowsybl_import/powsybl_masks.py | 13 +++++++-----
.../pypowsybl_import/preprocessing.py | 21 ++++++++++++-------
6 files changed, 48 insertions(+), 22 deletions(-)
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 302d1c39b..39d585062 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
@@ -49,7 +49,7 @@
three_node_pst_example,
)
from toop_engine_grid_helpers.powsybl.loadflow_parameters import DISTRIBUTED_SLACK
-from toop_engine_grid_helpers.powsybl.powsybl_helpers import save_lf_params_to_fs
+from toop_engine_grid_helpers.powsybl.powsybl_helpers import save_lf_params_to_fs, sort_powsybl_element_frame_by_id
from toop_engine_importer.pypowsybl_import import preprocessing
from toop_engine_importer.pypowsybl_import.powsybl_masks import make_masks, save_masks_to_filesystem
from toop_engine_interfaces.asset_topology import (
@@ -1065,11 +1065,12 @@ def case30_with_psts_powsybl(folder: Path) -> None:
np.save(output_path_masks / NETWORK_MASK_NAMES["line_for_reward"], line_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["line_for_nminus1"], line_mask)
- trafo_mask = np.ones(len(net.get_2_windings_transformers()), dtype=bool)
- trafo_has_pst_tap = np.zeros(len(net.get_2_windings_transformers()), dtype=bool)
- trafo_has_pst_tap[-2:] = True
- trafo_mask_groups = np.full(len(net.get_2_windings_transformers()), -1, dtype=int)
- trafo_mask_groups[-2:] = np.array([0, 1])
+ trafos = sort_powsybl_element_frame_by_id(net.get_2_windings_transformers())
+ pst_ids = net.get_phase_tap_changers().index
+ trafo_mask = np.ones(len(trafos), dtype=bool)
+ trafo_has_pst_tap = trafos.index.isin(pst_ids)
+ trafo_mask_groups = np.full(len(trafos), -1, dtype=int)
+ trafo_mask_groups[trafo_has_pst_tap] = np.arange(np.sum(trafo_has_pst_tap))
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_has_pst_tap)
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 3da188c44..479e4b6b6 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 @@
get_trafos,
)
from toop_engine_grid_helpers.powsybl.loadflow_parameters import DISTRIBUTED_SLACK
-from toop_engine_grid_helpers.powsybl.powsybl_helpers import load_powsybl_from_fs
+from toop_engine_grid_helpers.powsybl.powsybl_helpers import load_powsybl_from_fs, sort_powsybl_element_frame_by_id
from toop_engine_interfaces.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
@@ -241,9 +241,9 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
trafos = get_trafos(self.net, self.net_pu)
if trafos.empty:
return trafos
+ trafos = sort_powsybl_element_frame_by_id(trafos)
n_trafos = len(trafos)
- trafos.sort_index(inplace=True)
# Add N-1 and observation masks
trafos["for_reward"] = self._get_mask(NETWORK_MASK_NAMES["trafo_for_reward"], False, n_trafos)
diff --git a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
index 8d404dfb1..1b735a2aa 100644
--- a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
+++ b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
@@ -117,6 +117,22 @@ def get_branches_with_i(branches: pd.DataFrame, net: Network) -> pd.DataFrame:
return branches
+def sort_powsybl_element_frame_by_id(element_frame: pd.DataFrame) -> pd.DataFrame:
+ """Sort a PowSyBl element dataframe by its grid model id index.
+
+ Parameters
+ ----------
+ element_frame : pd.DataFrame
+ A PowSyBl element dataframe with the index being the grid model id.
+
+ Returns
+ -------
+ pd.DataFrame
+ The sorted PowSyBl element dataframe.
+ """
+ return element_frame.sort_index()
+
+
def get_injections_with_i(injections: pd.DataFrame, net: Network) -> pd.DataFrame:
"""Get the get_injections results with the i values filled
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/loadflow_based_current_limits.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/loadflow_based_current_limits.py
index 7df55fc05..eb952552c 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/loadflow_based_current_limits.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/loadflow_based_current_limits.py
@@ -16,6 +16,7 @@
import pandas as pd
from beartype.typing import Literal, Union
from pypowsybl.network.impl.network import Network
+from toop_engine_grid_helpers.powsybl.powsybl_helpers import sort_powsybl_element_frame_by_id
from toop_engine_importer.pypowsybl_import.powsybl_masks import NetworkMasks
from toop_engine_interfaces.loadflow_results import BranchSide
from toop_engine_interfaces.messages.preprocess.preprocess_commands import (
@@ -358,7 +359,7 @@ def get_all_dso_trafo_limits(
with the new limits for the trafos that are bordering the DSO area.
The new limits are called "loadflow_based_n0" and "loadflow_based_n0"
"""
- trafo_df = branches_df[branches_df.type == "TWO_WINDINGS_TRANSFORMER"]
+ trafo_df = sort_powsybl_element_frame_by_id(branches_df[branches_df.type == "TWO_WINDINGS_TRANSFORMER"])
limits = []
cases: tuple[Case, ...] = ("n0", "n1")
for case in cases:
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 67f463a16..17ec87146 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -25,6 +25,7 @@
from fsspec.implementations.local import LocalFileSystem
from jaxtyping import Bool, Int
from pypowsybl.network.impl.network import Network
+from toop_engine_grid_helpers.powsybl.powsybl_helpers import sort_powsybl_element_frame_by_id
from toop_engine_importer.contingency_from_power_factory.contingency_from_file import (
get_contingencies_from_file,
match_contingencies,
@@ -168,7 +169,7 @@ def create_default_network_masks(network: Network) -> NetworkMasks:
# Only loading the index is much faster, if we only care for the size
bus_df = network.get_buses(attributes=[])
lines_df = network.get_lines(attributes=[])
- trafo_df = network.get_2_windings_transformers(attributes=[])
+ trafo_df = sort_powsybl_element_frame_by_id(network.get_2_windings_transformers(attributes=[]))
tie_df = network.get_tie_lines(attributes=[])
dangling_df = network.get_boundary_lines(attributes=[])
generator_df = network.get_generators(attributes=[])
@@ -497,8 +498,8 @@ def update_trafo_masks(
network_masks: NetworkMasks
The updated network mask object including the trafo masks.
"""
- trafos_df = network.get_2_windings_transformers(
- attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"]
+ trafos_df = sort_powsybl_element_frame_by_id(
+ network.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"])
)
# Identify relevant parameters for the trafo masks
@@ -1181,7 +1182,9 @@ def update_masks_from_power_factory_contingency_list_file(
network_masks = replace(
network_masks,
line_for_nminus1=network.get_lines().index.isin(grid_model_ids),
- trafo_for_nminus1=network.get_2_windings_transformers().index.isin(grid_model_ids),
+ trafo_for_nminus1=sort_powsybl_element_frame_by_id(network.get_2_windings_transformers()).index.isin(
+ grid_model_ids
+ ),
generator_for_nminus1=generator_nminus1_mask,
load_for_nminus1=load_nminus1_mask,
switch_for_nminus1=network.get_switches().index.isin(grid_model_ids),
@@ -1240,7 +1243,7 @@ def update_masks_from_contingency_list_file(
line_for_nminus1 = lines.index.isin(contingency_ids)
line_for_reward = lines.index.isin(monitored_ids)
- trafos = network.get_2_windings_transformers(attributes=[])
+ trafos = sort_powsybl_element_frame_by_id(network.get_2_windings_transformers(attributes=[]))
# Replace the appendage of the 3w->2w conversion to get the original trafo ids
trafo_orig_ids = trafos.index.str.replace("-Leg[123]$", "", regex=True)
trafo_for_nminus1 = trafo_orig_ids.isin(contingency_ids)
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 c7b103ec9..70ec4a1d6 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
@@ -34,7 +34,12 @@
POWSYBL_LOADFLOW_PARAM_PF,
)
from toop_engine_grid_helpers.powsybl.powsybl_asset_topo import get_topology
-from toop_engine_grid_helpers.powsybl.powsybl_helpers import load_powsybl_from_fs, save_lf_params_to_fs, save_powsybl_to_fs
+from toop_engine_grid_helpers.powsybl.powsybl_helpers import (
+ load_powsybl_from_fs,
+ save_lf_params_to_fs,
+ save_powsybl_to_fs,
+ sort_powsybl_element_frame_by_id,
+)
from toop_engine_importer.network_graph import powsybl_station_to_graph
from toop_engine_importer.pypowsybl_import import network_analysis
from toop_engine_importer.pypowsybl_import.data_classes import PreProcessingStatistics
@@ -132,7 +137,7 @@ def create_nminus1_definition_from_masks(network: Network, network_masks: Networ
for idx, row in lines[network_masks.line_for_nminus1].iterrows()
]
- trafos = network.get_2_windings_transformers(attributes=["name"])
+ trafos = sort_powsybl_element_frame_by_id(network.get_2_windings_transformers(attributes=["name"]))
is_trafo2w = ~trafos.index.str.contains(CONVERTED_TRAFO3W_ENDING)
monitored_trafos = [
MonitoredElement(id=idx, name=row["name"], type="TWO_WINDINGS_TRANSFORMER", kind="branch")
@@ -600,9 +605,9 @@ def fill_statistics_for_network_masks(
statistics.id_lists["line_for_nminus1"] = network.get_lines(attributes=[])[
network_masks.line_for_nminus1
].index.to_list()
- statistics.id_lists["trafo_for_nminus1"] = network.get_2_windings_transformers(attributes=[])[
- network_masks.trafo_for_nminus1
- ].index.to_list()
+ statistics.id_lists["trafo_for_nminus1"] = sort_powsybl_element_frame_by_id(
+ network.get_2_windings_transformers(attributes=[])
+ )[network_masks.trafo_for_nminus1].index.to_list()
statistics.id_lists["tie_line_for_nminus1"] = network.get_tie_lines(attributes=[])[
network_masks.tie_line_for_nminus1
].index.to_list()
@@ -621,9 +626,9 @@ def fill_statistics_for_network_masks(
statistics.id_lists["line_disconnectable"] = network.get_lines(attributes=[])[
network_masks.line_disconnectable
].index.to_list()
- statistics.id_lists["trafo_disconnectable"] = network.get_2_windings_transformers(attributes=[])[
- network_masks.trafo_disconnectable
- ].index.to_list()
+ statistics.id_lists["trafo_disconnectable"] = sort_powsybl_element_frame_by_id(
+ network.get_2_windings_transformers(attributes=[])
+ )[network_masks.trafo_disconnectable].index.to_list()
statistics.import_result.n_relevant_subs = int(network_masks.relevant_subs.sum())
statistics.import_result.n_line_for_nminus1 = int(network_masks.line_for_nminus1.sum())
From 5fe507a6844d68d4efee6f4390ddbbdde798b936 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:30:24 +0000
Subject: [PATCH 21/44] fix: Fix case 30 grid
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_dc_solver/example_grids.py | 8 +++++--
.../topology_optimizer_pkg/tests/conftest.py | 21 ++++++++++++++++++-
.../dc/repertoire/test_discrete_map_elites.py | 18 ----------------
3 files changed, 26 insertions(+), 21 deletions(-)
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 39d585062..fe4a5a429 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
@@ -1113,10 +1113,14 @@ def three_node_pst_example_folder_powsybl(folder: Path) -> None:
np.save(output_path_masks / NETWORK_MASK_NAMES["line_for_reward"], line_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["line_for_nminus1"], line_mask)
trafo_mask = np.ones(len(net.get_2_windings_transformers()), dtype=bool)
+ trafo_has_pst_tap = np.array([True, True], dtype=bool)
+ trafo_pst_linear = np.array([True, True], dtype=bool)
+ trafo_mask_groups = np.array([0, 1], dtype=int)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
- np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_mask)
- np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_has_pst_tap)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_pst_linear)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["pst_group_labels"], trafo_mask_groups)
extract_station_info_powsybl(net, folder)
save_lf_params_to_fs(
diff --git a/packages/topology_optimizer_pkg/tests/conftest.py b/packages/topology_optimizer_pkg/tests/conftest.py
index f91cab3f5..80d582d77 100644
--- a/packages/topology_optimizer_pkg/tests/conftest.py
+++ b/packages/topology_optimizer_pkg/tests/conftest.py
@@ -16,6 +16,7 @@
import jax.numpy as jnp
import numpy as np
import pandera
+import pypowsybl
import pytest
from beartype.typing import Generator, Literal, Union
from confluent_kafka import Consumer, Producer
@@ -24,6 +25,7 @@
from fsspec.implementations.dirfs import DirFileSystem
from jaxtyping import Int
from omegaconf import DictConfig
+from pypowsybl.network import Network
from sqlmodel import Session, select
from toop_engine_contingency_analysis.ac_loadflow_service.kafka_client import LongRunningKafkaConsumer
from toop_engine_dc_solver.example_grids import (
@@ -33,12 +35,15 @@
case57_non_converging,
complex_grid_battery_hvdc_svc_3w_trafo_data_folder,
oberrhein_data,
+ three_node_pst_example_folder_powsybl,
)
-from toop_engine_dc_solver.jax.types import ActionSet
+from toop_engine_dc_solver.jax.types import ActionSet, StaticInformation
from toop_engine_dc_solver.preprocess import load_grid
+from toop_engine_dc_solver.preprocess.network_data import NetworkData
from toop_engine_grid_helpers.powsybl.loadflow_parameters import DISTRIBUTED_SLACK
from toop_engine_grid_helpers.powsybl.powsybl_helpers import save_lf_params_to_fs
from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
+from toop_engine_interfaces.messages.preprocess.preprocess_results import StaticInformationStats
from toop_engine_interfaces.nminus1_definition import Nminus1Definition, load_nminus1_definition
from toop_engine_topology_optimizer.ac.storage import ACOptimTopology, create_session
from toop_engine_topology_optimizer.interfaces.messages.commons import Framework, GridFile, OptimizerType
@@ -494,6 +499,20 @@ def contingency_ids_case_57(n_minus1_definitions_case_57: list[Nminus1Definition
return cont_ids
+@pytest.fixture
+def create_3_node_pst_example_grid(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> tuple[StaticInformationStats, StaticInformation, NetworkData, Network]:
+ """Create a temporary folder with the three node PST example grid and load it into memory."""
+ tmp_path = tmp_path_factory.mktemp("three_node_pst_example_grid")
+
+ three_node_pst_example_folder_powsybl(tmp_path)
+ filesystem_dir = DirFileSystem(str(tmp_path))
+ stats, static_information, network_data = load_grid(filesystem_dir, pandapower=False)
+ net = pypowsybl.network.load(tmp_path / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ return stats, static_information, network_data, net
+
+
@pytest.fixture
def unsplit_ac_dc_repertoire(session: Session, contingency_ids_case_57: list[str]) -> tuple[list[ACOptimTopology], Session]:
"""Populate the database with three strategies of 10 random timesteps each"""
diff --git a/packages/topology_optimizer_pkg/tests/dc/repertoire/test_discrete_map_elites.py b/packages/topology_optimizer_pkg/tests/dc/repertoire/test_discrete_map_elites.py
index 16cac24a2..e4174d334 100644
--- a/packages/topology_optimizer_pkg/tests/dc/repertoire/test_discrete_map_elites.py
+++ b/packages/topology_optimizer_pkg/tests/dc/repertoire/test_discrete_map_elites.py
@@ -11,19 +11,15 @@
import jax.numpy as jnp
import pypowsybl
import pytest
-from fsspec.implementations.dirfs import DirFileSystem
from jax_dataclasses import replace
from pypowsybl.network import Network
from qdax.utils.metrics import default_ga_metrics
-from toop_engine_dc_solver.example_grids import three_node_pst_example_folder_powsybl
from toop_engine_dc_solver.jax.aggregate_results import get_overload_energy_n_1_matrix
from toop_engine_dc_solver.jax.compute_batch import compute_symmetric_batch
from toop_engine_dc_solver.jax.inputs import load_static_information, validate_static_information
from toop_engine_dc_solver.jax.topology_computations import default_topology
from toop_engine_dc_solver.jax.types import NodalInjOptimResults, NodalInjStartOptions, StaticInformation
-from toop_engine_dc_solver.preprocess.convert_to_jax import load_grid
from toop_engine_dc_solver.preprocess.network_data import NetworkData
-from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
from toop_engine_interfaces.messages.preprocess.preprocess_results import StaticInformationStats
from toop_engine_topology_optimizer.dc.ga_helpers import TrackingMixingEmitter
from toop_engine_topology_optimizer.dc.genetic_functions.crossover import (
@@ -129,20 +125,6 @@ def test_discrete_mapelites(static_information_file: str, cell_depth: int) -> No
assert repertoire.fitnesses.shape == (20 * cell_depth,)
-# TODO: Move fixture to conftest
-@pytest.fixture
-def create_3_node_pst_example_grid(
- tmp_path_factory,
-) -> tuple[StaticInformationStats, StaticInformation, NetworkData, Network]:
- tmp_path = tmp_path_factory.mktemp("three_node_pst_example_grid")
-
- three_node_pst_example_folder_powsybl(tmp_path)
- filesystem_dir = DirFileSystem(str(tmp_path))
- stats, static_information, network_data = load_grid(filesystem_dir, pandapower=False)
- net = pypowsybl.network.load(tmp_path / PREPROCESSING_PATHS["grid_file_path_powsybl"])
- return stats, static_information, network_data, net
-
-
# TODO: Fix tap to reduce overload
def test_manual_pst_optimization(
create_3_node_pst_example_grid: tuple[StaticInformationStats, StaticInformation, NetworkData, Network],
From ed6c6c34ff4ca4ae5e557309958e8ea2599b9c2e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:37:35 +0000
Subject: [PATCH 22/44] Initial plan
From 091c01a5971f349fb7ad0fbf19552297b8df6f30 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:48:38 +0000
Subject: [PATCH 23/44] fix: preserve PST masks for loadflow preprocessing
---
.../preprocess/network_data.py | 4 +--
.../pandapower/pandapower_backend.py | 2 +-
.../preprocess/powsybl/powsybl_backend.py | 25 +++++++++++---
.../preprocess/powsybl/powsybl_helpers.py | 33 +++++++++++++++++--
4 files changed, 54 insertions(+), 10 deletions(-)
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 a26924ca9..93c3421c3 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
@@ -712,7 +712,7 @@ def extract_action_set(network_data: NetworkData) -> ActionSet:
starting_tap=start + low, # Convert from index to absolute grid model tap position
low_tap=low,
high_tap=low + len(taps),
- pst_group=_get_parallel_pst_group_id(network_data=network_data, pst_idx=pst_idx, branch_idx=index),
+ pst_group=_get_parallel_pst_group_id(network_data=network_data, pst_idx=pst_idx, branch_idx=int(index)),
)
for pst_idx, (index, start, low, taps) in enumerate(
zip(
@@ -738,7 +738,7 @@ def extract_action_set(network_data: NetworkData) -> ActionSet:
)
-def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: Int, branch_idx: Int) -> str:
+def _get_parallel_pst_group_id(network_data: NetworkData, pst_idx: int, branch_idx: int) -> str:
"""Return the persisted PST group id for one controllable PST.
If no parallel PST group information is available, or if the PST does not belong to any
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 25a826d86..af78d2799 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
@@ -410,7 +410,7 @@ def _get_controllable_phase_shift_trafo_mask(self) -> Bool[np.ndarray, " n_trafo
try:
controllable_pst_mask = load_numpy_filesystem(
filesystem=self.data_folder_dirfs,
- file_path=str(self._get_masks_path() / NETWORK_MASK_NAMES["trafo_has_pst_tap"]),
+ file_path=str(self._get_masks_path() / NETWORK_MASK_NAMES["trafo_pst_linear"]),
)
except FileNotFoundError:
controllable_pst_mask = np.zeros(len(self.net.trafo), dtype=bool)
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 479e4b6b6..2559f9127 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
@@ -214,6 +214,18 @@ def _get_mask(
except FileNotFoundError:
return np.full(default_shape, default_value)
+
+ def _get_mask_or_default(
+ self, mask_filename: str, default_mask: Bool[np.ndarray, " n_masked_element"] | Int[np.ndarray, " n_masked_element"]
+ ) -> Bool[np.ndarray, " n_masked_element"] | Int[np.ndarray, " n_masked_element"]:
+ """Load a mask file or return a provided default mask."""
+ try:
+ return load_numpy_filesystem(
+ filesystem=self.data_folder_dirfs, file_path=str(self._get_masks_path() / mask_filename)
+ )
+ except FileNotFoundError:
+ return default_mask
+
@functools.lru_cache
def _get_lines(self) -> pat.DataFrame[BranchModel]:
"""Add N-1 and observation masks to the lines"""
@@ -251,10 +263,14 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
trafos["overload_weight"] = self._get_mask(NETWORK_MASK_NAMES["trafo_overload_weight"], 1.0, n_trafos)
trafos["disconnectable"] = self._get_mask(NETWORK_MASK_NAMES["trafo_disconnectable"], False, n_trafos)
trafos["n0_n1_max_diff_factor"] = self._get_mask(NETWORK_MASK_NAMES["trafo_n0_n1_max_diff_factor"], -1.0, n_trafos)
- trafos["pst_linear"] = self._get_mask(NETWORK_MASK_NAMES["trafo_pst_linear"], False, n_trafos)
- trafos["has_pst_tap"] = self._get_mask(NETWORK_MASK_NAMES["trafo_has_pst_tap"], False, n_trafos)
+ default_has_pst_tap = trafos["has_pst_tap"].to_numpy(dtype=bool)
+ default_pst_linear = trafos["pst_linear"].to_numpy(dtype=bool)
+ default_pst_group = np.full(n_trafos, -1, dtype=int)
+ default_pst_group[default_pst_linear] = np.arange(np.sum(default_pst_linear))
+ trafos["has_pst_tap"] = self._get_mask_or_default(NETWORK_MASK_NAMES["trafo_has_pst_tap"], default_has_pst_tap)
+ trafos["pst_linear"] = self._get_mask_or_default(NETWORK_MASK_NAMES["trafo_pst_linear"], default_pst_linear)
# Parallel-PST group label per trafo (-1 for non-grouped). Identified during importing.
- trafos["pst_group"] = self._get_mask(NETWORK_MASK_NAMES["pst_group_labels"], -1, n_trafos)
+ trafos["pst_group"] = self._get_mask_or_default(NETWORK_MASK_NAMES["pst_group_labels"], default_pst_group)
return trafos
@@ -446,10 +462,9 @@ def get_phase_shift_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get a mask of branches that can have a phase shift"""
return self._get_branches()["has_pst_tap"].values
- # TODO: Current feature creates same result as `get_phase_shift_mask`, but kept for now.
def get_controllable_phase_shift_mask(self) -> Bool[np.ndarray, " n_branch"]:
"""Get a mask of controllable PSTs"""
- return self._get_branches()["has_pst_tap"].values
+ return self._get_branches()["pst_linear"].values
def get_phase_shift_linearity(self) -> Bool[np.ndarray, " n_controllable_psts"]:
"""Get the linearity of the phase shift for each controllable PST.
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
index 7b2c254b8..b70181945 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/powsybl/powsybl_helpers.py
@@ -19,7 +19,7 @@
import pandera as pa
import pandera.typing as pat
import structlog
-from beartype.typing import Optional
+from beartype.typing import Literal, Optional
from pandera import DataFrameModel, Field
from pandera.typing import Index, Series
from pypowsybl.network import Network
@@ -259,7 +259,12 @@ def get_trafos(net: Network, net_pu: Optional[Network] = None) -> pat.DataFrame[
+ " ## "
+ (trafos["elementName"] if "elementName" in trafos.keys() else trafos["name"])
)
- return add_missing_branch_model_columns(trafos[["x", "r", "rho", "alpha", "name"]])
+ linear_psts = get_linear_pst(net, mode="dc")
+ trafos["pst_linear"] = False
+ trafos["has_pst_tap"] = False
+ trafos.loc[linear_psts.index, "pst_linear"] = linear_psts.values
+ trafos.loc[linear_psts.index, "has_pst_tap"] = True
+ return add_missing_branch_model_columns(trafos[["x", "r", "rho", "alpha", "name", "pst_linear", "has_pst_tap"]])
@pa.check_types
@@ -388,3 +393,27 @@ def get_lines(net: Network, net_pu: Optional[Network] = None) -> pat.DataFrame[B
+ (lines["elementName_nopu"] if "elementName_nopu" in lines.keys() else lines["name"])
)
return add_missing_branch_model_columns(lines[["x", "r", "name"]])
+
+
+def get_linear_pst(net: Network, mode: Literal["ac", "dc"], tol: float = 1e-9) -> pd.Series:
+ """Check if a given branch has a linear phase shift transformer (PST) tap changer."""
+ tap_steps = net.get_phase_tap_changer_steps()
+ if mode == "dc":
+ linear_cols = ["x"]
+ elif mode == "ac":
+ linear_cols = ["r", "x", "g", "b"]
+ else:
+ raise ValueError(f"Invalid mode {mode}. Must be 'ac' or 'dc'.")
+
+ pst_ids = tap_steps.index.get_level_values("id").unique()
+ trafo_linear_pst = pd.Series(True, index=pst_ids)
+
+ for pst_id in pst_ids:
+ pst_info = tap_steps.loc[pst_id]
+ for col in linear_cols:
+ pst_info_col = pst_info[col].values
+ if not np.allclose(pst_info_col, pst_info_col[0], atol=tol):
+ trafo_linear_pst[pst_id] = False
+ break
+
+ return trafo_linear_pst
From f5a32b1a27c2b729e3ce9005e9ef9146a0f23595 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 19:41:37 +0000
Subject: [PATCH 24/44] fix: another case 30
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../dc_solver_pkg/src/toop_engine_dc_solver/example_grids.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
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 fe4a5a429..9e48911cc 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
@@ -1069,12 +1069,14 @@ def case30_with_psts_powsybl(folder: Path) -> None:
pst_ids = net.get_phase_tap_changers().index
trafo_mask = np.ones(len(trafos), dtype=bool)
trafo_has_pst_tap = trafos.index.isin(pst_ids)
+ trafo_pst_linear = np.zeros(len(trafos), dtype=bool)
+ trafo_pst_linear[-2:] = True
trafo_mask_groups = np.full(len(trafos), -1, dtype=int)
trafo_mask_groups[trafo_has_pst_tap] = np.arange(np.sum(trafo_has_pst_tap))
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_reward"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_for_nminus1"], trafo_mask)
np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_has_pst_tap"], trafo_has_pst_tap)
- np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_mask)
+ np.save(output_path_masks / NETWORK_MASK_NAMES["trafo_pst_linear"], trafo_pst_linear)
np.save(output_path_masks / NETWORK_MASK_NAMES["pst_group_labels"], trafo_mask_groups)
gen_mask = np.ones(len(net.get_generators()), dtype=bool)
From 344290301924995e666efbf6065fc85c0b67b1dc Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 19:56:37 +0000
Subject: [PATCH 25/44] fix: missing import
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
packages/topology_optimizer_pkg/tests/conftest.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/topology_optimizer_pkg/tests/conftest.py b/packages/topology_optimizer_pkg/tests/conftest.py
index 4bdd1f354..73f19bb87 100644
--- a/packages/topology_optimizer_pkg/tests/conftest.py
+++ b/packages/topology_optimizer_pkg/tests/conftest.py
@@ -39,6 +39,7 @@
)
from toop_engine_dc_solver.jax.types import ActionSet, StaticInformation
from toop_engine_dc_solver.preprocess import load_grid
+from toop_engine_dc_solver.preprocess.network_data import NetworkData
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_interfaces.folder_structure import PREPROCESSING_PATHS
From 2edbd0dac889bbe1c24ec8faeba966fd520a6107 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 18 Jun 2026 20:30:30 +0000
Subject: [PATCH 26/44] fix: address loadflow preprocessing test failures
---
.../src/toop_engine_dc_solver/example_grids.py | 3 ++-
.../src/toop_engine_dc_solver/preprocess/network_data.py | 2 +-
.../preprocess/powsybl/powsybl_backend.py | 2 +-
.../toop_engine_grid_helpers/powsybl/powsybl_asset_topo.py | 1 +
.../dc/genetic_functions/genotype.py | 7 ++++---
5 files changed, 9 insertions(+), 6 deletions(-)
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 52a819e20..f513a69d5 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
@@ -580,7 +580,8 @@ def case57_data_powsybl_xiidm(folder: Path) -> None:
grid_model_file=Path(folder) / PREPROCESSING_PATHS["grid_file_path_powsybl"],
),
)
- save_masks_to_filesystem(network_masks, folder, dir_system)
+ save_masks_to_filesystem(network_masks, Path("."), dir_system)
+ extract_station_info_powsybl(net, folder)
def case57_non_converging(folder: Path) -> None:
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 93c3421c3..5b1580a7d 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
@@ -533,7 +533,7 @@ def validate_network_data(network_data: NetworkData) -> None:
assert network_data.ptdf.shape == (n_branch, n_nodes)
assert network_data.psdf.shape[0] == n_branch
- assert network_data.slack > 0 and network_data.slack < n_nodes
+ assert network_data.slack >= 0 and network_data.slack < n_nodes
assert network_data.relevant_node_mask.shape == (n_nodes,)
assert network_data.max_mw_flows.shape == (n_timestep, n_branch)
assert network_data.max_mw_flows_n_1.shape == (n_timestep, n_branch)
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 0d093f511..ab38075dc 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
@@ -479,7 +479,7 @@ def get_phase_shift_taps(self) -> list[Float[np.ndarray, " n_controllable_psts"]
for pst_id in self._get_branches()[self.get_controllable_phase_shift_mask()].index:
taps_df = steps.loc[pst_id].sort_index()
- taps = -np.squeeze(taps_df.values)
+ taps = -taps_df["alpha"].to_numpy()
shift_taps.append(taps)
return shift_taps
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..3eee56a3f 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
@@ -543,6 +543,7 @@ def get_stations_bus_breaker(net: Network) -> list[Station]:
injections = all_injections[
(all_injections["bus_breaker_bus_id"].isin(local_buses.index) & all_injections["connected"])
]
+ injections = injections[injections["type"] != "BUSBAR_SECTION"]
busbar_mapper = {grid_model_id: index for index, grid_model_id in enumerate(local_buses.index)}
busbars = [
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
index 7a9474ba2..e983e242d 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
@@ -7,6 +7,8 @@
"""Dataclass for the genotype used in the genetic algorithm."""
+import math
+
import equinox as eqx
import jax
import jax.numpy as jnp
@@ -61,9 +63,8 @@ def deduplicate_genotypes(
# Flatten the nodal injection optimization results into the comparison
# Shape: (batch_size, n_timesteps, n_controllable_pst)
# -> (batch_size, n_timesteps * n_controllable_pst)
- pst_taps_flat = genotypes.nodal_injections_optimized.pst_tap_idx.reshape(
- genotypes.nodal_injections_optimized.pst_tap_idx.shape[0], -1
- )
+ pst_tap_idx = genotypes.nodal_injections_optimized.pst_tap_idx
+ pst_taps_flat = pst_tap_idx.reshape(pst_tap_idx.shape[0], math.prod(pst_tap_idx.shape[1:]))
genotype_parts.append(pst_taps_flat)
genotype_flat = jnp.concatenate(genotype_parts, axis=1)
From db534e683a826c389f1c60964b165144bf193dc0 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 18 Jun 2026 21:14:04 +0000
Subject: [PATCH 27/44] fix: simplify inner logic and verify instead
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../dc/genetic_functions/initialization.py | 7 +++++++
.../dc/genetic_functions/mutation/mutate_nodal_inj.py | 4 +---
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
index 1311838f1..66ec124c7 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
@@ -258,6 +258,13 @@ def verify_static_information(
assert first_static_information.dynamic_information.nodal_injection_information is not None, (
"Parallel PST group optimization requires nodal injection information with controllable PSTs."
)
+ assert (
+ first_static_information.dynamic_information.nodal_injection_information.parallel_pst_group_mask is not None
+ ), (
+ "Parallel PST group optimization is enabled, but the first static information lacks a parallel_pst_group_mask. "
+ "This requires a parallel_pst_group_mask in the nodal injection information. "
+ "Disable parallel PST group optimization or provide correct static information. "
+ )
def update_static_information(
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
index 98b944c7e..a6db1d496 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/mutation/mutate_nodal_inj.py
@@ -69,7 +69,7 @@ def mutate_psts(
# Sample number of PSTs to adjust from a n_controllable_pst-dimensional uniform distribution
key, key_mutate, key_reset = jax.random.split(random_key, 3)
- if enable_parallel_pst_group_optim and parallel_pst_group_mask is not None:
+ if enable_parallel_pst_group_optim:
n_parallel_groups = parallel_pst_group_mask.shape[0]
group_indices_to_mutate = jax.random.bernoulli(key=key, p=pst_mutation_probability, shape=(n_parallel_groups,))
mutation_samples = jax.random.normal(key_mutate, shape=(n_parallel_groups,)) * pst_mutation_sigma
@@ -131,8 +131,6 @@ def mutate_nodal_injections(
n_timesteps = nodal_inj_info.pst_tap_idx.shape[1]
random_key = jax.random.split(random_key, (batch_size, n_timesteps))
parallel_pst_group_mask = nodal_mutation_config.parallel_pst_group_mask
- if not nodal_mutation_config.enable_parallel_pst_group_optim or parallel_pst_group_mask is None:
- parallel_pst_group_mask = None
# vmap to mutate the PST taps for each timestep + batch independently
new_pst_taps = jax.vmap(
From 82bf704661561848c9cb03d88d4b1acd09e7c71b Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Mon, 22 Jun 2026 09:12:29 +0200
Subject: [PATCH 28/44] fix: Revert math import
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../dc/genetic_functions/genotype.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
index e983e242d..7a9474ba2 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
@@ -7,8 +7,6 @@
"""Dataclass for the genotype used in the genetic algorithm."""
-import math
-
import equinox as eqx
import jax
import jax.numpy as jnp
@@ -63,8 +61,9 @@ def deduplicate_genotypes(
# Flatten the nodal injection optimization results into the comparison
# Shape: (batch_size, n_timesteps, n_controllable_pst)
# -> (batch_size, n_timesteps * n_controllable_pst)
- pst_tap_idx = genotypes.nodal_injections_optimized.pst_tap_idx
- pst_taps_flat = pst_tap_idx.reshape(pst_tap_idx.shape[0], math.prod(pst_tap_idx.shape[1:]))
+ pst_taps_flat = genotypes.nodal_injections_optimized.pst_tap_idx.reshape(
+ genotypes.nodal_injections_optimized.pst_tap_idx.shape[0], -1
+ )
genotype_parts.append(pst_taps_flat)
genotype_flat = jnp.concatenate(genotype_parts, axis=1)
From 86990efd30933e302b0d41ef44c482301ad18fcb Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Mon, 22 Jun 2026 10:15:00 +0200
Subject: [PATCH 29/44] fix: Skip PST genotype deduplication for empty
injections
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../dc/genetic_functions/genotype.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
index 7a9474ba2..9d4cb14b8 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/genotype.py
@@ -57,13 +57,12 @@ def deduplicate_genotypes(
genotypes.disconnections,
]
# Include nodal_injections_optimized (PST taps) in deduplication when present
- if genotypes.nodal_injections_optimized is not None:
+ if genotypes.nodal_injections_optimized is not None and genotypes.nodal_injections_optimized.pst_tap_idx.shape[0] > 0:
# Flatten the nodal injection optimization results into the comparison
# Shape: (batch_size, n_timesteps, n_controllable_pst)
# -> (batch_size, n_timesteps * n_controllable_pst)
- pst_taps_flat = genotypes.nodal_injections_optimized.pst_tap_idx.reshape(
- genotypes.nodal_injections_optimized.pst_tap_idx.shape[0], -1
- )
+ pst_tap_idx = genotypes.nodal_injections_optimized.pst_tap_idx
+ pst_taps_flat = genotypes.nodal_injections_optimized.pst_tap_idx.reshape(pst_tap_idx.shape[0], -1)
genotype_parts.append(pst_taps_flat)
genotype_flat = jnp.concatenate(genotype_parts, axis=1)
From 75e7c471289baaded88e74aa08f62707fd29d0d5 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:40:43 +0200
Subject: [PATCH 30/44] fix: Solver config parameter blocked proper import of
group mask
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
docs/importer/worker/worker.md | 4 +-
.../src/toop_engine_dc_solver/jax/inputs.py | 20 ++++--
.../preprocess/preprocess_commands.py | 1 +
.../test_mutate_nodal_inj.py | 62 ++++++++++++++++++-
4 files changed, 77 insertions(+), 10 deletions(-)
diff --git a/docs/importer/worker/worker.md b/docs/importer/worker/worker.md
index 091b0cfbe..b5bea5546 100644
--- a/docs/importer/worker/worker.md
+++ b/docs/importer/worker/worker.md
@@ -3,8 +3,8 @@
The importer worker is designed to run as one of the components in the ToOp architecture. The role is to preprocess grid files from the raw format (CGMES, UCTE) into the processed grid folder used by the optimizers. The worker takes preprocessing [`Command`][toop_engine_interfaces.messages.preprocess.preprocess_commands.Command] objects and, upon reception, starts the importing process. This entails:
- Creating the backend grid snapshot (`grid.xiidm` for powsybl or `grid.json` for pandapower).
-- Writing masks, loadflow parameters, importer auxiliary data, asset topology metadata, and an initial `nminus1_definition.json`.
+- Writing masks (e.g. disconnectable branches, transformers, and PSTs in parallel), loadflow parameters, importer auxiliary data, asset topology metadata, and an initial `nminus1_definition.json`.
- Preprocessing through the [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] function to create `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the final filtered `nminus1_definition.json`.
- Running an initial loadflow using the contingency_analysis module.
-For Powsybl-imported grids, the import step identifies supported parallel PST groups from the grid data. [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] persists those groups in `action_set.json` through `pst_ranges[*].pst_group` so grouped PSTs stay synchronized through solver execution and optimization. Parallel PST group optimization is not supported for the PandaPower backend.
+For Powsybl-imported grids, the import step identifies supported parallel PST groups from the grid data. [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] persists those groups in `action_set.json` through `pst_ranges[*].pst_group`. Parallel PST group optimization is not supported for the PandaPower backend.
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 d6bfbf33d..86b1f59ae 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
@@ -329,6 +329,17 @@ def validate_static_information(
)
assert jnp.all(di.nodal_injection_information.starting_tap_idx >= 0)
assert jnp.all(di.nodal_injection_information.starting_tap_idx < di.nodal_injection_information.pst_n_taps)
+ assert di.nodal_injection_information.parallel_pst_group_mask is not None, (
+ "Parallel PST group mask should always be imported but are None. Check the preprocessing step."
+ )
+ assert (
+ di.nodal_injection_information.parallel_pst_group_mask.shape[1]
+ == (di.nodal_injection_information.controllable_pst_indices.shape[0])
+ )
+ # Sum of each column must be 1, as each PST can only belong to one parallel group
+ assert jnp.all(jnp.sum(di.nodal_injection_information.parallel_pst_group_mask, axis=0) == 1), (
+ "PST group mask implies that a PST belongs to more than one parallel group. Error during mask creation step."
+ )
def save_static_information_fs(filename: str, static_information: StaticInformation, filesystem: AbstractFileSystem) -> None:
@@ -546,11 +557,10 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
"grid_model_low_tap",
data=nodal_inj_opt.grid_model_low_tap,
)
- if solver_config.enable_parallel_pst_group_optim:
- file.create_dataset(
- "parallel_pst_group_mask",
- data=nodal_inj_opt.parallel_pst_group_mask,
- )
+ file.create_dataset(
+ "parallel_pst_group_mask",
+ data=nodal_inj_opt.parallel_pst_group_mask,
+ )
for idx, (branches, nodes) in enumerate(
zip(
diff --git a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_commands.py b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_commands.py
index 4ec6f44d9..d49ba98ab 100644
--- a/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_commands.py
+++ b/packages/interfaces_pkg/src/toop_engine_interfaces/messages/preprocess/preprocess_commands.py
@@ -323,6 +323,7 @@ class PreprocessParameters(BaseModel):
"""If a large configuration table comes out of a substation, the table size can be reduced
by removing configurations that are close to each other. This parameter sets the definition
of close in terms of hamming distance, by default 0 (no reduction)."""
+
separation_set_clip_at_size: int = 100
"""By what size a table is considered large. If the table is larger than this size, the
clip_hamming_distance will be used to reduce the table size, by default 100. If a table is
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
index 5ae786304..e70ad7987 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_mutate_nodal_inj.py
@@ -191,8 +191,7 @@ def test_resetting_psts() -> None:
# Assert boundaries
assert jnp.all(0 <= mutated_pst_taps)
assert jnp.all(mutated_pst_taps < pst_n_taps)
- # Assert some PSTs are adjusted but not all
- difference = jnp.abs(pst_taps - mutated_pst_taps)
+ # Assert all PSTs are reset to starting taps
assert jnp.all(mutated_pst_taps == pst_starting_taps)
# Test resetting some PSTs
@@ -250,10 +249,43 @@ def test_grouped_mutate_psts_keeps_parallel_members_equal() -> None:
parallel_pst_group_mask=parallel_pst_group_mask,
)
+ difference = jnp.abs(pst_taps - mutated_pst_taps)
+ assert jnp.any(difference > 0)
assert mutated_pst_taps[0] == mutated_pst_taps[1]
assert mutated_pst_taps[2] == mutated_pst_taps[3]
+def test_grouped_mutate_psts_keeps_parallel_members_equal_different_order() -> None:
+ random_key = jax.random.PRNGKey(32324)
+ pst_taps = jnp.array([3, 7, 3, 7])
+ pst_starting_taps = jnp.array([5, 7, 5, 7])
+ pst_n_taps = jnp.array([20, 20, 20, 20])
+ parallel_pst_group_mask = jnp.array(
+ [
+ [True, False, True, False],
+ [False, True, False, True],
+ ],
+ dtype=bool,
+ )
+
+ mutated_pst_taps = mutate_psts(
+ random_key=random_key,
+ pst_taps=pst_taps,
+ pst_n_taps=pst_n_taps,
+ pst_starting_taps=pst_starting_taps,
+ pst_mutation_sigma=3.0,
+ pst_mutation_probability=1.0,
+ pst_reset_probability=0.0,
+ enable_parallel_pst_group_optim=True,
+ parallel_pst_group_mask=parallel_pst_group_mask,
+ )
+
+ difference = jnp.abs(pst_taps - mutated_pst_taps)
+ assert jnp.any(difference > 0)
+ assert mutated_pst_taps[0] == mutated_pst_taps[2]
+ assert mutated_pst_taps[1] == mutated_pst_taps[3]
+
+
def test_mutate_nodal_injections_ignores_empty_group_mask_when_group_optim_disabled() -> None:
nodal_inj_info = NodalInjOptimResults(pst_tap_idx=jnp.array([[[1, 2]]], dtype=int))
mutation_config = NodalInjectionMutationConfig(
@@ -264,7 +296,7 @@ def test_mutate_nodal_injections_ignores_empty_group_mask_when_group_optim_disab
pst_start_tap_idx=jnp.array([1, 2], dtype=int),
enable_parallel_pst_group_optim=False,
# An empty group mask (zero group rows) aligned with the two controllable PSTs.
- parallel_pst_group_mask=jnp.zeros((0, 2), dtype=bool),
+ parallel_pst_group_mask=None,
)
mutated = mutate_nodal_injections(
@@ -274,3 +306,27 @@ def test_mutate_nodal_injections_ignores_empty_group_mask_when_group_optim_disab
)
assert mutated == nodal_inj_info
+
+
+def test_mutate_nodal_injections_grouped_psts() -> None:
+ nodal_inj_info = NodalInjOptimResults(pst_tap_idx=jnp.array([[[1, 2, 2]]], dtype=int))
+ mutation_config = NodalInjectionMutationConfig(
+ pst_mutation_sigma=2.0,
+ pst_mutation_probability=1.0,
+ pst_reset_probability=0.0,
+ pst_n_taps=jnp.array([10, 10, 10], dtype=int),
+ pst_start_tap_idx=jnp.array([1, 2, 2], dtype=int),
+ enable_parallel_pst_group_optim=True,
+ # A group mask defining two groups: the first PST is in the first group, the second and third PSTs are in the second group.
+ parallel_pst_group_mask=jnp.array([[1, 0, 0], [0, 1, 1]], dtype=bool),
+ )
+
+ mutated = mutate_nodal_injections(
+ random_key=jax.random.PRNGKey(32422341),
+ nodal_inj_info=nodal_inj_info,
+ nodal_mutation_config=mutation_config,
+ )
+
+ difference = jnp.abs(mutated.pst_tap_idx - nodal_inj_info.pst_tap_idx)
+ assert jnp.any(difference > 0)
+ assert mutated.pst_tap_idx[0, 0, 1] == mutated.pst_tap_idx[0, 0, 2]
From 3c729d32d28538e958235aa2b0fd6cf1fccfebb4 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 25 Jun 2026 11:49:20 +0200
Subject: [PATCH 31/44] docs: Adjust docs after final refactor
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
docs/dc_solver/preprocessing.md | 1 +
docs/importer/worker/worker.md | 2 +-
.../dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py | 6 +++++-
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/docs/dc_solver/preprocessing.md b/docs/dc_solver/preprocessing.md
index 9e62abadd..a71d4ef2a 100644
--- a/docs/dc_solver/preprocessing.md
+++ b/docs/dc_solver/preprocessing.md
@@ -34,6 +34,7 @@ Parallel PST group optimization is currently supported only for Powsybl-imported
Controllable PSTs are serialized in `ActionSet.pst_ranges`. Each PST range carries a `pst_group` field that persists the import-derived group used by downstream tooling.
- PSTs with the same `pst_group` are treated as one optimization group.
+- Group metadata is imported and carried in static information when available, but grouped behavior is applied only when `enable_parallel_pst_group_optim=True`.
- Group members share the same tap delta during solver execution and optimization, then each member is clipped to its own tap domain.
- Different initial taps inside one group trigger a warning.
- Mixed linear and non-linear PSTs are not supported in one group when grouped optimization is enabled. We currently do not support optimization of non-linear/asymmetric PSTs.
diff --git a/docs/importer/worker/worker.md b/docs/importer/worker/worker.md
index b5bea5546..bf69b55c7 100644
--- a/docs/importer/worker/worker.md
+++ b/docs/importer/worker/worker.md
@@ -7,4 +7,4 @@ The importer worker is designed to run as one of the components in the ToOp arch
- Preprocessing through the [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] function to create `static_information.hdf5`, `action_set.json`, `action_set_diffs.hdf5`, `static_information_stats.json`, and the final filtered `nminus1_definition.json`.
- Running an initial loadflow using the contingency_analysis module.
-For Powsybl-imported grids, the import step identifies supported parallel PST groups from the grid data. [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] persists those groups in `action_set.json` through `pst_ranges[*].pst_group`. Parallel PST group optimization is not supported for the PandaPower backend.
+For Powsybl-imported grids, the import step identifies supported parallel PST groups from the grid data. [`load_grid`][toop_engine_dc_solver.preprocess.convert_to_jax.load_grid] persists those groups in `action_set.json` through `pst_ranges[*].pst_group`. The group metadata is imported into static information, while runtime grouped behavior is enabled only when `enable_parallel_pst_group_optim=True`. Parallel PST group optimization is not supported for the PandaPower backend.
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
index cf831e332..8a5146a82 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
@@ -445,7 +445,11 @@ class SolverConfig:
this to True."""
enable_parallel_pst_group_optim: bool = False
- """Whether controllable PSTs should be optimized and applied in configured parallel groups."""
+ """Whether controllable PSTs should be optimized and applied in configured parallel groups.
+
+ Group metadata is still imported into the static information when available, but this flag controls whether the
+ optimizer uses that grouping behavior at runtime.
+ """
def __hash__(self) -> int:
"""Get id as the hash for the static information.
From 65a2ec9cd65f5f6f551130a5de6fc706974ffa44 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:35:00 +0000
Subject: [PATCH 32/44] Initial plan
From 2b35640582a75230a7483ef90246f62b72b34585 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:44:12 +0000
Subject: [PATCH 33/44] fix: add PST parallel group mask to static information
---
.../src/toop_engine_dc_solver/jax/inputs.py | 17 +++++++++++++
.../src/toop_engine_dc_solver/jax/types.py | 3 +++
.../preprocess/convert_to_jax.py | 24 ++++++++++++++++++-
.../preprocessing/test_convert_to_jax.py | 11 ++++++++-
4 files changed, 53 insertions(+), 2 deletions(-)
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..b00c3ac10 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
@@ -329,6 +329,13 @@ def validate_static_information(
)
assert jnp.all(di.nodal_injection_information.starting_tap_idx >= 0)
assert jnp.all(di.nodal_injection_information.starting_tap_idx < di.nodal_injection_information.pst_n_taps)
+ assert di.nodal_injection_information.parallel_pst_group_mask is not None, (
+ "Parallel PST group mask should always be imported but are None. Check the preprocessing step."
+ )
+ assert (
+ di.nodal_injection_information.parallel_pst_group_mask.shape[1]
+ == di.nodal_injection_information.controllable_pst_indices.shape[0]
+ )
def save_static_information_fs(filename: str, static_information: StaticInformation, filesystem: AbstractFileSystem) -> None:
@@ -545,6 +552,10 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
"grid_model_low_tap",
data=nodal_inj_opt.grid_model_low_tap,
)
+ file.create_dataset(
+ "parallel_pst_group_mask",
+ data=nodal_inj_opt.parallel_pst_group_mask,
+ )
for idx, (branches, nodes) in enumerate(
zip(
@@ -843,6 +854,11 @@ def load_nodal_injection_optimization(
The loaded NodalInjectionOptimization or None if not present
"""
if nodal_injection_optimization_present:
+ parallel_pst_group_mask = (
+ jnp.array(file["parallel_pst_group_mask"][:])
+ if "parallel_pst_group_mask" in file
+ else jnp.zeros((0, file["controllable_pst_indices"].shape[0]), dtype=bool)
+ )
return NodalInjectionInformation(
controllable_pst_indices=jnp.array(file["controllable_pst_indices"][:]),
shift_degree_min=jnp.array(file["shift_degree_min"][:]),
@@ -851,6 +867,7 @@ def load_nodal_injection_optimization(
pst_tap_values=jnp.array(file["pst_tap_values"][:]),
starting_tap_idx=jnp.array(file["starting_tap_idx"][:]),
grid_model_low_tap=jnp.array(file["grid_model_low_tap"][:]),
+ parallel_pst_group_mask=parallel_pst_group_mask,
)
return None
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
index 0f164f5ec..facfd4de7 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/types.py
@@ -84,6 +84,9 @@ class NodalInjectionInformation(eqx.Module):
taps are to be reconstructed from indices into pst_tap_values then tap + grid_model_low_tap gives the actual tap position
in the original grid model."""
+ parallel_pst_group_mask: Bool[Array, " n_parallel_pst_groups n_controllable_pst"]
+ """Boolean mask mapping groups of parallel PSTs to controllable PST indices."""
+
class RelBBOutageData(eqx.Module):
"""Holds the relevant busbar outage data."""
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
index ee36d49ae..5fb3a55f7 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
@@ -20,7 +20,7 @@
import structlog
from beartype.typing import Callable, Literal, Optional
from fsspec import AbstractFileSystem
-from jaxtyping import Bool, Float, Int
+from jaxtyping import Array, Bool, Float, Int
from pypowsybl.loadflow import Parameters as LoadflowParameters
from toop_engine_dc_solver.jax.aggregate_results import (
aggregate_to_metric,
@@ -219,6 +219,7 @@ def convert_to_jax( # noqa: PLR0913
for taps in network_data.phase_shift_taps
]
)
+ parallel_pst_group_mask = _get_parallel_pst_group_mask(network_data)
logging_fn("pad_out_branch_actions", None)
assert network_data.branch_action_set is not None, "Please compute branch action set first!"
@@ -286,6 +287,7 @@ def convert_to_jax( # noqa: PLR0913
pst_tap_values=pst_tap_values,
starting_tap_idx=jnp.array(network_data.phase_shift_starting_tap_idx, dtype=int),
grid_model_low_tap=jnp.array(network_data.phase_shift_low_tap, dtype=int),
+ parallel_pst_group_mask=parallel_pst_group_mask,
)
if network_data.controllable_pst_node_mask.any()
else None,
@@ -331,6 +333,26 @@ def convert_to_jax( # noqa: PLR0913
return static_information
+def _get_parallel_pst_group_mask(network_data: NetworkData) -> Bool[Array, " n_parallel_pst_groups n_controllable_pst"]:
+ """Build a mask that groups controllable PSTs sharing the same branch endpoints."""
+ controllable_pst_branch_indices = np.flatnonzero(network_data.controllable_phase_shift_mask)
+ n_controllable_pst = len(controllable_pst_branch_indices)
+ if n_controllable_pst == 0:
+ return jnp.zeros((0, 0), dtype=bool)
+
+ pst_endpoint_groups: dict[tuple[int, int], list[int]] = {}
+ for pst_idx, branch_idx in enumerate(controllable_pst_branch_indices):
+ branch_endpoints = tuple(sorted((int(network_data.from_nodes[branch_idx]), int(network_data.to_nodes[branch_idx]))))
+ pst_endpoint_groups.setdefault(branch_endpoints, []).append(pst_idx)
+
+ parallel_groups = [pst_indices for pst_indices in pst_endpoint_groups.values() if len(pst_indices) > 1]
+ group_mask = np.zeros((len(parallel_groups), n_controllable_pst), dtype=bool)
+ for group_idx, pst_indices in enumerate(parallel_groups):
+ group_mask[group_idx, pst_indices] = True
+
+ return jnp.array(group_mask, dtype=bool)
+
+
def get_bb_outage_baseline_analysis(di: DynamicInformation, more_splits_penalty: float) -> BBOutageBaselineAnalysis:
"""Get the baseline loadflows after busbar outages of unsplit grid.
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py b/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
index 72e344785..0bdf41b77 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
@@ -165,7 +165,16 @@ def test_load_grid_case30(tmp_path_factory: pytest.TempPathFactory) -> None:
filesystem_dir = DirFileSystem(str(folder))
_, static_information, _ = load_grid(data_folder_dirfs=filesystem_dir, pandapower=True)
validate_static_information(static_information)
- assert static_information.dynamic_information.nodal_injection_information.shift_degree_max.shape == (3,)
+ nodal_injection_information = static_information.dynamic_information.nodal_injection_information
+ assert nodal_injection_information.shift_degree_max.shape == (3,)
+ assert nodal_injection_information.parallel_pst_group_mask is not None
+ assert nodal_injection_information.parallel_pst_group_mask.shape == (0, 3)
+
+ loaded_static_information = load_static_information(folder / PREPROCESSING_PATHS["static_information_file_path"])
+ validate_static_information(loaded_static_information)
+ loaded_nodal_injection_information = loaded_static_information.dynamic_information.nodal_injection_information
+ assert loaded_nodal_injection_information.parallel_pst_group_mask is not None
+ assert loaded_nodal_injection_information.parallel_pst_group_mask.shape == (0, 3)
action_set = load_action_set(
folder / PREPROCESSING_PATHS["action_set_file_path"],
From 5b867bcae872e3d93149dd9342392d24680b8048 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 25 Jun 2026 14:50:50 +0200
Subject: [PATCH 34/44] fix: pandapower case without PSTs
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_dc_solver/jax/inputs.py | 24 ++++++++++---------
.../preprocessing/test_convert_to_jax.py | 5 ----
2 files changed, 13 insertions(+), 16 deletions(-)
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 284bd9421..2323303d7 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
@@ -558,10 +558,11 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
"grid_model_low_tap",
data=nodal_inj_opt.grid_model_low_tap,
)
- file.create_dataset(
- "parallel_pst_group_mask",
- data=nodal_inj_opt.parallel_pst_group_mask,
- )
+ if nodal_inj_opt.parallel_pst_group_mask is not None:
+ file.create_dataset(
+ "parallel_pst_group_mask",
+ data=nodal_inj_opt.parallel_pst_group_mask,
+ )
for idx, (branches, nodes) in enumerate(
zip(
@@ -860,14 +861,15 @@ def load_nodal_injection_optimization(
NodalInjectionOptimization | None
The loaded NodalInjectionOptimization or None if not present
"""
- if "parallel_pst_group_mask" in file:
- parallel_pst_group_mask = jnp.array(file["parallel_pst_group_mask"][:])
- else:
- parallel_pst_group_mask = jnp.eye(file["pst_n_taps"].shape[0], dtype=bool)
- logger.warning(
- "No parallel PST group mask found in the file. Using identity matrix as default, which means no parallel groups."
- )
if nodal_injection_optimization_present:
+ if "parallel_pst_group_mask" in file:
+ parallel_pst_group_mask = jnp.array(file["parallel_pst_group_mask"][:])
+ else:
+ parallel_pst_group_mask = jnp.eye(file["pst_n_taps"].shape[0], dtype=bool)
+ logger.warning(
+ "No parallel PST group mask found in the file. "
+ "Using identity matrix as default, which means no parallel groups."
+ )
return NodalInjectionInformation(
controllable_pst_indices=jnp.array(file["controllable_pst_indices"][:]),
shift_degree_min=jnp.array(file["shift_degree_min"][:]),
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py b/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
index 0bdf41b77..a7ec1f0a4 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
@@ -167,14 +167,9 @@ def test_load_grid_case30(tmp_path_factory: pytest.TempPathFactory) -> None:
validate_static_information(static_information)
nodal_injection_information = static_information.dynamic_information.nodal_injection_information
assert nodal_injection_information.shift_degree_max.shape == (3,)
- assert nodal_injection_information.parallel_pst_group_mask is not None
- assert nodal_injection_information.parallel_pst_group_mask.shape == (0, 3)
loaded_static_information = load_static_information(folder / PREPROCESSING_PATHS["static_information_file_path"])
validate_static_information(loaded_static_information)
- loaded_nodal_injection_information = loaded_static_information.dynamic_information.nodal_injection_information
- assert loaded_nodal_injection_information.parallel_pst_group_mask is not None
- assert loaded_nodal_injection_information.parallel_pst_group_mask.shape == (0, 3)
action_set = load_action_set(
folder / PREPROCESSING_PATHS["action_set_file_path"],
From 79a362af2f42d459360c6159ad3bb92e748362b4 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 25 Jun 2026 16:38:13 +0200
Subject: [PATCH 35/44] fix: copilot feedback
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_dc_solver/jax/nodal_inj_optim.py | 5 ++---
.../preprocess/powsybl/powsybl_backend.py | 14 ++++++++------
.../preprocessing/test_parallel_pst_groups.py | 11 -----------
.../pypowsybl_import/powsybl_masks.py | 2 +-
4 files changed, 11 insertions(+), 21 deletions(-)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
index 26b9e658d..a95d825ef 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/nodal_inj_optim.py
@@ -150,9 +150,8 @@ def nodal_inj_optimization(
# Get PST tap indices from start options (shape: batch_size x n_timesteps x n_controllable_pst)
pst_tap_indices = start_options.previous_results.pst_tap_idx
- # TODO: This should not be necessary
- # if pst_tap_indices.ndim == 2:
- # pst_tap_indices = pst_tap_indices[None, :, :]
+ if pst_tap_indices.ndim == 2:
+ pst_tap_indices = pst_tap_indices[None, :, :]
n_0_updated = apply_pst_taps(
n_0=n_0,
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 ab38075dc..321b964b7 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
@@ -263,13 +263,15 @@ def _get_trafos(self) -> pat.DataFrame[BranchModel]:
trafos["disconnectable"] = self._get_mask(NETWORK_MASK_NAMES["trafo_disconnectable"], False, n_trafos)
trafos["n0_n1_max_diff_factor"] = self._get_mask(NETWORK_MASK_NAMES["trafo_n0_n1_max_diff_factor"], -1.0, n_trafos)
default_has_pst_tap = trafos["has_pst_tap"].to_numpy(dtype=bool)
- default_pst_linear = trafos["pst_linear"].to_numpy(dtype=bool)
- default_pst_group = np.full(n_trafos, -1, dtype=int)
- default_pst_group[default_pst_linear] = np.arange(np.sum(default_pst_linear))
trafos["has_pst_tap"] = self._get_mask_or_default(NETWORK_MASK_NAMES["trafo_has_pst_tap"], default_has_pst_tap)
+ default_pst_linear = trafos["pst_linear"].to_numpy(dtype=bool)
trafos["pst_linear"] = self._get_mask_or_default(NETWORK_MASK_NAMES["trafo_pst_linear"], default_pst_linear)
- # Parallel-PST group label per trafo (-1 for non-grouped). Identified during importing.
- trafos["pst_group"] = self._get_mask_or_default(NETWORK_MASK_NAMES["pst_group_labels"], default_pst_group)
+ # Parallel-PST group label per trafo. Identified during importing.
+ # Default mask sets -1 for all non-PSTs and assigns unique groups per PST.
+ default_pst_groups = np.full(n_trafos, -1, dtype=int)
+ groups_for_psts = np.arange(np.sum(trafos["has_pst_tap"]), dtype=int)
+ default_pst_groups[trafos["has_pst_tap"]] = groups_for_psts
+ trafos["pst_group"] = self._get_mask_or_default(NETWORK_MASK_NAMES["pst_group_labels"], default_pst_groups)
return trafos
@@ -504,7 +506,7 @@ def _get_parallel_pst_groups(self) -> tuple[Bool[np.ndarray, " n_parallel_pst_gr
"""Get parallel PST grouping metadata aligned with controllable PST arrays.
The parallel PSTs and their group labels are identified during importing and stored per PST (branch):
- 1. BranchModel.``pst_controllable``
+ 1. BranchModel.``pst_linear``
2. BranchModel.``pst_group``
Use the masks to create a 2-d boolean array with rows as parallel PST groups and columns as controllable PSTs, where
True indicates that a PST belongs to a group. The order of the columns is aligned with the order of controllable PSTs
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
index facf2c8be..163fd49bb 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_parallel_pst_groups.py
@@ -33,17 +33,6 @@ def test_build_parallel_pst_group_mask_shared_label_groups_members() -> None:
assert np.array_equal(mask.sum(axis=0), np.ones(3, dtype=int))
-def test_build_parallel_pst_group_mask_sentinel_labels_form_singletons() -> None:
- mask, group_ids = build_2d_pst_group_mask_and_labels(
- group_labels=np.array([0, 1, 2]),
- pst_id_list=["PST1", "PST2", "PST3"],
- )
-
- # The -1 sentinel never merges PSTs: each gets its own row.
- assert np.array_equal(mask, np.eye(3, dtype=bool))
- assert group_ids == ["PST1", "PST2", "PST3"]
-
-
def test_build_parallel_pst_group_mask_empty() -> None:
mask, group_ids = build_2d_pst_group_mask_and_labels(group_labels=np.array([], dtype=int), pst_id_list=[])
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 17ec87146..7a3c26da2 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -1309,7 +1309,7 @@ def _is_disconnectable(network: Network, grid_model_id: list[str]) -> np.ndarray
def _is_linear_pst(step_table: pd.DataFrame) -> bool:
"""Check Powsybl's phase tap changer step table of a transformer for linear behavior.
- This check is done by checking if `rho`, `x, `r, `g`, or `b` columns have at least two different values
+ This check is done by checking if `rho`, `x, `r`, `g`, or `b` columns have at least two different values
at different tap positions.
Parameters
From 40cad0c86ff20a8339faf4e6ef135dbf6fdc8c8b Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Thu, 25 Jun 2026 17:16:28 +0200
Subject: [PATCH 36/44] fix: feedback
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../preprocess/preprocess.py | 2 +-
.../pypowsybl_import/powsybl_masks.py | 95 ++++++++++++++++++-
2 files changed, 91 insertions(+), 6 deletions(-)
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 1745b1ff9..a1e6df3c3 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
@@ -1277,7 +1277,7 @@ def compute_separation_set_for_stations(
def exclude_nonlinear_psts_from_controllable(network_data: NetworkData) -> NetworkData:
- """Exclude nonlinear phase shifters from the controllable phase shifters and paralle grouping.
+ """Exclude nonlinear phase shifters from the controllable phase shifters and parallel grouping.
This is necessary because nonlinear phase shifters cannot be optimized correctly in the backend
at this moment.
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 7a3c26da2..75e697eb5 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -607,17 +607,72 @@ def filter_and_group_linear_psts(
Boolean mask marking transformers that have a linear phase tap changer and are in the control area.
pst_group_labels: Int[np.ndarray, " n_trafos"]
Integer array of parallel PST group labels, aligned with ``trafos_df`` rows. Each group of parallel PSTs shares a
- unique positive integer label. Non-PSTs are labeled with ``-1``.
+ unique non-negative integer label. Non-PSTs are labeled with ``-1``.
"""
- trafo_pst_linear = np.zeros(len(trafos_df), dtype=bool)
+ trafo_has_pst_tap, trafo_pst_linear, step_tables, buckets = _identify_psts_and_create_buckets(
+ network=network,
+ trafos_df=trafos_df,
+ control_area_hv_trafo_mask=control_area_hv_trafo_mask,
+ )
pst_group_labels = np.full(len(trafos_df), -1, dtype=int)
+ if not buckets:
+ return trafo_has_pst_tap, trafo_pst_linear, pst_group_labels
+
+ pst_group_labels = _build_pst_group_labels_from_buckets(
+ trafos_df=trafos_df,
+ step_tables=step_tables,
+ buckets=buckets,
+ )
+
+ return trafo_has_pst_tap, trafo_pst_linear, pst_group_labels
+
+
+def _identify_psts_and_create_buckets(
+ network: Network,
+ trafos_df: pd.DataFrame,
+ control_area_hv_trafo_mask: Bool[np.ndarray, " n_trafos"],
+) -> tuple[
+ Bool[np.ndarray, " n_trafos"],
+ Bool[np.ndarray, " n_trafos"],
+ dict[str, np.ndarray],
+ dict[tuple[object, ...], list[int]],
+]:
+ """Identify PSTs, linear PSTs and grouping buckets.
+
+ Candidate PSTs are transformers in the control area that have a phase tap changer. The returned
+ buckets contain exactly comparable grouping attributes: unordered bus pair, nominal voltage, tap range,
+ number of tap steps and whether the tap changer is linear. The per-tap step tables are returned
+ separately so that grouping can compare them numerically within each bucket.
+
+ Parameters
+ ----------
+ network: Network
+ The powsybl network providing the phase-tap-changer and voltage-level data.
+ trafos_df: pd.DataFrame
+ The 2-winding transformer dataframe, indexed by trafo id and including the ``bus1_id``,
+ ``bus2_id`` and ``voltage_level1_id`` columns.
+ control_area_hv_trafo_mask: Bool[np.ndarray, " n_trafos"]
+ Boolean mask (aligned with ``trafos_df`` rows) marking transformers in the control area.
+
+ Returns
+ -------
+ trafo_has_pst_tap: Bool[np.ndarray, " n_trafos"]
+ Boolean mask marking transformers that have a phase tap changer and are in the control area.
+ trafo_pst_linear: Bool[np.ndarray, " n_trafos"]
+ Boolean mask marking transformers that have a linear phase tap changer and are in the control area.
+ step_tables: dict[str, np.ndarray]
+ Mapping from PST id to its phase-tap-changer step table as a numeric array.
+ buckets: dict[tuple[object, ...], list[int]]
+ Mapping from exactly comparable PST attributes to transformer positions in ``trafos_df``.
+ """
+ trafo_pst_linear = np.zeros(len(trafos_df), dtype=bool)
tap_changers = network.get_phase_tap_changers()
# Candidate PSTs are trafos that actually carry a phase tap changer.
trafo_has_pst_tap = control_area_hv_trafo_mask & trafos_df.index.isin(tap_changers.index)
trafo_has_pst_tap_index = np.flatnonzero(trafo_has_pst_tap)
if trafo_has_pst_tap_index.size == 0:
- return trafo_has_pst_tap, trafo_pst_linear, pst_group_labels
+ return trafo_has_pst_tap, trafo_pst_linear, {}, defaultdict(list)
trafo_has_pst_tap_ids = trafos_df.index[trafo_has_pst_tap_index]
nominal_v = get_voltage_from_voltage_level_id(network, trafos_df.loc[trafo_has_pst_tap_ids, "voltage_level1_id"])
@@ -627,7 +682,7 @@ def filter_and_group_linear_psts(
# Bucket candidates by exactly-comparable attributes; the per-tap step tables are then compared
# within each (small) bucket using a numerical tolerance.
step_tables: dict[str, np.ndarray] = {}
- buckets: dict[tuple, list[int]] = defaultdict(list)
+ buckets: dict[tuple[object, ...], list[int]] = defaultdict(list)
for local_idx, (position, pst_id) in enumerate(zip(trafo_has_pst_tap_index, trafo_has_pst_tap_ids, strict=True)):
low_tap = int(tap_changers.at[pst_id, "low_tap"])
high_tap = int(tap_changers.at[pst_id, "high_tap"])
@@ -645,6 +700,36 @@ def filter_and_group_linear_psts(
)
buckets[bucket_key].append(position)
+ return trafo_has_pst_tap, trafo_pst_linear, step_tables, buckets
+
+
+def _build_pst_group_labels_from_buckets(
+ trafos_df: pd.DataFrame,
+ step_tables: dict[str, np.ndarray],
+ buckets: dict[tuple[object, ...], list[int]],
+) -> Int[np.ndarray, " n_trafos"]:
+ """Build parallel PST group labels from precomputed buckets.
+
+ PSTs in the same bucket already share exactly comparable attributes. This function splits each
+ bucket into one or more groups by comparing the corresponding per-tap step tables with numerical
+ tolerance, then assigns consecutive labels starting at ``0``. Non-PST positions keep label ``-1``.
+
+ Parameters
+ ----------
+ trafos_df: pd.DataFrame
+ The 2-winding transformer dataframe, indexed by trafo id.
+ step_tables: dict[str, np.ndarray]
+ Mapping from PST id to its phase-tap-changer step table as a numeric array.
+ buckets: dict[tuple[object, ...], list[int]]
+ Mapping from exactly comparable PST attributes to transformer positions in ``trafos_df``.
+
+ Returns
+ -------
+ pst_group_labels: Int[np.ndarray, " n_trafos"]
+ Integer array of parallel PST group labels, aligned with ``trafos_df`` rows. Each group of parallel PSTs shares a
+ unique non-negative integer label. Non-PSTs are labeled with ``-1``.
+ """
+ pst_group_labels = np.full(len(trafos_df), -1, dtype=int)
next_label = 0
for positions in buckets.values():
# Within a bucket all candidates already share bus pair, voltage and tap range. Group those
@@ -665,7 +750,7 @@ def filter_and_group_linear_psts(
pst_group_labels[position] = next_label
next_label += 1
- return trafo_has_pst_tap, trafo_pst_linear, pst_group_labels
+ return pst_group_labels
def update_bus_masks(
From 5cbda0c581a4fefc39601615821b34c776c88991 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 26 Jun 2026 10:58:24 +0200
Subject: [PATCH 37/44] chore: stable sort
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
index 289002bf7..886bdf0d2 100644
--- a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
+++ b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
@@ -130,7 +130,7 @@ def sort_powsybl_element_frame_by_id(element_frame: pd.DataFrame) -> pd.DataFram
pd.DataFrame
The sorted PowSyBl element dataframe.
"""
- return element_frame.sort_index()
+ return element_frame.sort_index(kind="stable")
def get_injections_with_i(injections: pd.DataFrame, net: Network) -> pd.DataFrame:
From b372f5399322e9f7d92f89da5dc9f8e624398ba2 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:09:34 +0200
Subject: [PATCH 38/44] Revert "chore: stable sort"
This reverts commit 5cbda0c581a4fefc39601615821b34c776c88991.
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
index 886bdf0d2..289002bf7 100644
--- a/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
+++ b/packages/grid_helpers_pkg/src/toop_engine_grid_helpers/powsybl/powsybl_helpers.py
@@ -130,7 +130,7 @@ def sort_powsybl_element_frame_by_id(element_frame: pd.DataFrame) -> pd.DataFram
pd.DataFrame
The sorted PowSyBl element dataframe.
"""
- return element_frame.sort_index(kind="stable")
+ return element_frame.sort_index()
def get_injections_with_i(injections: pd.DataFrame, net: Network) -> pd.DataFrame:
From c9d07c11e569dafe077542ebf9d0a808239781a1 Mon Sep 17 00:00:00 2001
From: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
Date: Fri, 26 Jun 2026 09:54:14 +0000
Subject: [PATCH 39/44] chore: fix beartype
Signed-off-by: Sascha Petznick <229719644+spetznick-elia@users.noreply.github.com>
---
.../toop_engine_importer/pypowsybl_import/powsybl_masks.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index 75e697eb5..c85aa8717 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -692,13 +692,13 @@ def _identify_psts_and_create_buckets(
step_tables[pst_id] = step_table.to_numpy(dtype=float)
bucket_key = (
bus_pair,
- round(float(nominal_v[local_idx]), 6),
+ round(float(nominal_v[local_idx])),
low_tap,
high_tap,
step_table.shape[0],
trafo_pst_linear[position],
)
- buckets[bucket_key].append(position)
+ buckets[bucket_key].append(int(position))
return trafo_has_pst_tap, trafo_pst_linear, step_tables, buckets
From aa99509aaa7a2ba395792368a51404684bef2f9d Mon Sep 17 00:00:00 2001
From: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
Date: Fri, 26 Jun 2026 14:08:37 +0200
Subject: [PATCH 40/44] test: add grouped pst test
Signed-off-by: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
---
.../toop_engine_dc_solver/example_grids.py | 52 +++++++++
.../src/toop_engine_dc_solver/jax/inputs.py | 2 +-
packages/dc_solver_pkg/tests/conftest.py | 51 +++++++++
.../test_postprocess_powsybl.py | 1 +
.../preprocessing/test_powsybl_backend.py | 86 ++++++++++++++
.../preprocessing/test_powsybl_helpers.py | 24 ++++
.../dc_solver_pkg/tests/test_example_grids.py | 50 ++++++++-
.../pypowsybl_import/test_powsybl_masks.py | 58 +++++++++-
.../dc/genetic_functions/scoring_functions.py | 24 +++-
.../dc/worker/optimizer.py | 2 +
.../topology_optimizer_pkg/tests/conftest.py | 33 ++++++
.../tests/test_ac_dc_integration.py | 106 ++++++++++++++++++
12 files changed, 483 insertions(+), 6 deletions(-)
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 f62538a03..00fdfcd82 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
@@ -41,6 +41,7 @@
create_busbar_b_in_ieee,
create_complex_grid_battery_hvdc_svc_3w_trafo,
extract_station_info_powsybl,
+ grouped_pst_grid_example,
parallel_pst_example,
powsybl_case30_with_psts,
powsybl_case1354,
@@ -1190,3 +1191,54 @@ def parallel_pst_data_folder(folder: Path) -> NetworkData:
)
return network_data
+
+
+def grouped_pst_grid_example_data_folder(folder: Path, split_group_station: bool = False) -> NetworkData:
+ """Create a preprocessed folder for grouped_pst_example().
+
+ Runs the importer and preprocessing.
+
+ Parameter:
+ folder: Path
+ The root folder where the data is saved to.
+ split_group_station: bool
+ Whether to split the group station into two separate stations.
+ Returns:
+ NetworkData
+ The network data after preprocessing, which can be used for testing the consistency of the preprocessing step
+ """
+ net = grouped_pst_grid_example(linear_pst=None)
+ if split_group_station:
+ net.open_switch("VL2_BREAKER#0")
+ pypowsybl.loadflow.run_dc(net, DISTRIBUTED_SLACK)
+
+ grid_file_path = folder / PREPROCESSING_PATHS["grid_file_path_powsybl"]
+ grid_file_path.parent.mkdir(parents=True, exist_ok=True)
+ net.save(grid_file_path)
+
+ importer_parameters = CgmesImporterParameters(
+ grid_model_file=folder / PREPROCESSING_PATHS["grid_file_path_powsybl"],
+ data_folder=folder,
+ area_settings=AreaSettings(
+ cutoff_voltage=1,
+ control_area=[""],
+ view_area=[""],
+ nminus1_area=[""],
+ dso_trafo_factors=LimitAdjustmentParameters(),
+ dso_trafo_weight=1.0,
+ border_line_factors=LimitAdjustmentParameters(),
+ border_line_weight=1.0,
+ ),
+ )
+
+ _ = preprocessing.convert_file(importer_parameters=importer_parameters)
+
+ preprocessing_parameters = PreprocessParameters(action_set_clip=2**4, preprocess_bb_outages=False)
+ _info, _static_information, network_data = load_grid(
+ data_folder_dirfs=DirFileSystem(folder),
+ pandapower=False,
+ status_update_fn=None,
+ parameters=preprocessing_parameters,
+ )
+
+ return network_data
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 d6bfbf33d..ac08521bc 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
@@ -546,7 +546,7 @@ def _save_static_information(binaryio: io.IOBase, static_information: StaticInfo
"grid_model_low_tap",
data=nodal_inj_opt.grid_model_low_tap,
)
- if solver_config.enable_parallel_pst_group_optim:
+ if nodal_inj_opt.parallel_pst_group_mask is not None:
file.create_dataset(
"parallel_pst_group_mask",
data=nodal_inj_opt.parallel_pst_group_mask,
diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py
index 31893bdf7..e619c631f 100644
--- a/packages/dc_solver_pkg/tests/conftest.py
+++ b/packages/dc_solver_pkg/tests/conftest.py
@@ -47,6 +47,12 @@
oberrhein_data,
three_node_pst_example_folder_powsybl,
)
+from toop_engine_dc_solver.example_grids import (
+ grouped_pst_grid_example_data_folder as create_grouped_pst_grid_example_data_folder,
+)
+from toop_engine_dc_solver.example_grids import (
+ parallel_pst_data_folder as create_parallel_pst_data_folder,
+)
from toop_engine_dc_solver.jax.injections import (
convert_action_index_to_numpy,
random_injection,
@@ -1315,6 +1321,51 @@ def complex_grid_battery_hvdc_svc_3w_trafo_linear_0_1_data_folder(
return tmp_path
+@pytest.fixture(scope="session")
+def _parallel_pst_data_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ tmp_path = tmp_path_factory.mktemp("parallel_pst")
+ network_data = create_parallel_pst_data_folder(tmp_path)
+ save_network_data(tmp_path / "network_data.pkl", network_data)
+ return tmp_path
+
+
+@pytest.fixture(scope="function")
+def parallel_pst_data_folder(_parallel_pst_data_path: Path, tmp_path: Path) -> Path:
+ shutil.copytree(_parallel_pst_data_path, tmp_path, dirs_exist_ok=True)
+ return tmp_path
+
+
+@pytest.fixture(scope="session")
+def _grouped_pst_grid_example_data_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ tmp_path = tmp_path_factory.mktemp("grouped_pst")
+ network_data = create_grouped_pst_grid_example_data_folder(tmp_path)
+ save_network_data(tmp_path / "network_data.pkl", network_data)
+ return tmp_path
+
+
+@pytest.fixture(scope="function")
+def grouped_pst_grid_example_data_folder(_grouped_pst_grid_example_data_path: Path, tmp_path: Path) -> Path:
+ shutil.copytree(_grouped_pst_grid_example_data_path, tmp_path, dirs_exist_ok=True)
+ return tmp_path
+
+
+@pytest.fixture(scope="session")
+def _grouped_pst_grid_example_split_data_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ tmp_path = tmp_path_factory.mktemp("grouped_pst_split")
+ network_data = create_grouped_pst_grid_example_data_folder(tmp_path, split_group_station=True)
+ save_network_data(tmp_path / "network_data.pkl", network_data)
+ return tmp_path
+
+
+@pytest.fixture(scope="function")
+def grouped_pst_grid_example_split_data_folder(
+ _grouped_pst_grid_example_split_data_path: Path,
+ tmp_path: Path,
+) -> Path:
+ shutil.copytree(_grouped_pst_grid_example_split_data_path, tmp_path, dirs_exist_ok=True)
+ return tmp_path
+
+
@pytest.fixture(scope="function")
def three_node_pst_example_data_folder(tmp_path_factory: pytest.TempPathFactory) -> Path:
tmp_path = tmp_path_factory.mktemp("three_node_pst_example")
diff --git a/packages/dc_solver_pkg/tests/postprocessing/test_postprocess_powsybl.py b/packages/dc_solver_pkg/tests/postprocessing/test_postprocess_powsybl.py
index 1450383ac..98d90989c 100644
--- a/packages/dc_solver_pkg/tests/postprocessing/test_postprocess_powsybl.py
+++ b/packages/dc_solver_pkg/tests/postprocessing/test_postprocess_powsybl.py
@@ -241,6 +241,7 @@ def test_apply_disconnections_matches_loadflows(
"complex_grid_battery_hvdc_svc_3w_trafo_linear_1_0_data_folder",
"complex_grid_battery_hvdc_svc_3w_trafo_linear_1_1_data_folder",
"complex_grid_battery_hvdc_svc_3w_trafo_linear_0_1_data_folder",
+ "grouped_pst_grid_example_data_folder",
],
)
def test_change_pst_matches_loadflows(
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
index 25d6cdedf..b91ad4f29 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
@@ -35,13 +35,41 @@
)
from toop_engine_dc_solver.preprocess.powsybl.powsybl_backend import PowsyblBackend
from toop_engine_dc_solver.preprocess.preprocess import preprocess
+from toop_engine_grid_helpers.powsybl.example_grids import grouped_pst_grid_example
from toop_engine_grid_helpers.powsybl.loadflow_parameters import DISTRIBUTED_SLACK
from toop_engine_interfaces.folder_structure import (
+ NETWORK_MASK_NAMES,
PREPROCESSING_PATHS,
)
from toop_engine_interfaces.loadflow_result_helpers_polars import extract_solver_matrices_polars
from toop_engine_interfaces.nminus1_definition import load_nminus1_definition
+GROUPED_PST_IDS = ["PST_1_group_1", "PST_2_group_1", "PST_3_group_2", "PST_4_group_2"]
+
+
+def _write_grouped_pst_grid(
+ tmp_dir: Path,
+ linear_pst: list[bool],
+ pst_group_labels: dict[str, int],
+ split_station: bool,
+) -> PowsyblBackend:
+ net = grouped_pst_grid_example(linear_pst=linear_pst)
+ if split_station:
+ net.open_switch("VL2_BREAKER#0")
+
+ grid_path = tmp_dir / PREPROCESSING_PATHS["grid_file_path_powsybl"]
+ grid_path.parent.mkdir(parents=True, exist_ok=True)
+ net.save(grid_path)
+
+ mask_path = tmp_dir / PREPROCESSING_PATHS["masks_path"]
+ mask_path.mkdir(parents=True, exist_ok=True)
+ trafo_ids = net.get_2_windings_transformers(attributes=[]).index.to_list()
+ np.save(mask_path / NETWORK_MASK_NAMES["trafo_pst_controllable"], np.isin(trafo_ids, GROUPED_PST_IDS))
+ group_labels = np.array([pst_group_labels[trafo_id] for trafo_id in trafo_ids])
+ np.save(mask_path / NETWORK_MASK_NAMES["pst_group_masks"], group_labels)
+
+ return PowsyblBackend(DirFileSystem(str(tmp_dir)))
+
def test_get_branches(powsybl_data_folder: Path) -> None:
filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
@@ -429,3 +457,61 @@ def test_psts(tmp_path_factory: pytest.TempPathFactory) -> None:
tap_found = True
break
assert tap_found
+
+
+@pytest.mark.parametrize(
+ (
+ "linear_pst",
+ "split_station",
+ "pst_group_labels",
+ "expected_linearity",
+ "expected_group_mask",
+ "expected_group_ids",
+ ),
+ [
+ (
+ [True, True, True, True],
+ False,
+ {pst_id: 0 for pst_id in GROUPED_PST_IDS},
+ [True, True, True, True],
+ [[True, True, True, True]],
+ ["PST_1_group_1"],
+ ),
+ (
+ [True, True, True, True],
+ True,
+ {"PST_1_group_1": 0, "PST_2_group_1": 1, "PST_3_group_2": 0, "PST_4_group_2": 1},
+ [True, True, True, True],
+ [[True, False, True, False], [False, True, False, True]],
+ ["PST_1_group_1", "PST_2_group_1"],
+ ),
+ (
+ [True, False, True, False],
+ False,
+ {"PST_1_group_1": 0, "PST_2_group_1": 1, "PST_3_group_2": 0, "PST_4_group_2": 1},
+ [True, False, True, False],
+ [[True, False, True, False], [False, True, False, True]],
+ ["PST_1_group_1", "PST_2_group_1"],
+ ),
+ ],
+)
+def test_grouped_pst_grid_backend_reads_parallel_group_masks(
+ tmp_path: Path,
+ linear_pst: list[bool],
+ split_station: bool,
+ pst_group_labels: dict[str, int],
+ expected_linearity: list[bool],
+ expected_group_mask: list[list[bool]],
+ expected_group_ids: list[str],
+) -> None:
+ backend = _write_grouped_pst_grid(
+ tmp_dir=tmp_path,
+ linear_pst=linear_pst,
+ pst_group_labels=pst_group_labels,
+ split_station=split_station,
+ )
+
+ assert backend.get_controllable_phase_shift_ids() == GROUPED_PST_IDS
+ assert np.array_equal(backend.get_phase_shift_linearity(), np.array(expected_linearity))
+ assert np.array_equal(backend.get_parallel_pst_group_mask(), np.array(expected_group_mask))
+ assert backend.get_parallel_pst_group_ids() == expected_group_ids
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
index aea6ae073..9f851fe17 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_helpers.py
@@ -15,12 +15,14 @@
import pytest
from toop_engine_dc_solver.preprocess.powsybl.powsybl_helpers import (
add_missing_branch_model_columns,
+ get_linear_pst,
get_lines,
get_network_as_pu,
get_p_max,
get_tie_lines,
get_trafos,
)
+from toop_engine_grid_helpers.powsybl.example_grids import grouped_pst_grid_example
from toop_engine_interfaces.folder_structure import PREPROCESSING_PATHS
@@ -291,3 +293,25 @@ def test_get_cgmes_ids_hybrid(ucte_file: Path) -> None:
cgmes_ids = get_cgmes_ids(net)
assert np.array_equal(cgmes_ids, cgmes_net.get_identifiables().index.values)
+
+
+@pytest.mark.parametrize(
+ ("linear_pst", "expected_linearity"),
+ [
+ (None, [True, True, True, True]),
+ ([True, True, True, True], [True, True, True, True]),
+ ([False, False, False, False], [False, False, False, False]),
+ ([True, False, True, False], [True, False, True, False]),
+ ],
+)
+def test_grouped_pst_grid_importer_detects_linear_and_nonlinear_psts(
+ linear_pst: list[bool] | None, expected_linearity: list[bool]
+) -> None:
+ net = grouped_pst_grid_example(linear_pst=linear_pst)
+ pst_ids = ["PST_1_group_1", "PST_2_group_1", "PST_3_group_2", "PST_4_group_2"]
+
+ dc_linearity = get_linear_pst(net, mode="dc").loc[pst_ids]
+ ac_linearity = get_linear_pst(net, mode="ac").loc[pst_ids]
+
+ assert np.array_equal(dc_linearity.values, np.array(expected_linearity))
+ assert np.array_equal(ac_linearity.values, np.array(expected_linearity))
diff --git a/packages/dc_solver_pkg/tests/test_example_grids.py b/packages/dc_solver_pkg/tests/test_example_grids.py
index 0d9e1dc30..5661bb59a 100644
--- a/packages/dc_solver_pkg/tests/test_example_grids.py
+++ b/packages/dc_solver_pkg/tests/test_example_grids.py
@@ -14,6 +14,7 @@
import pypowsybl
import pytest
from fsspec.implementations.dirfs import DirFileSystem
+from tests.network_data_pickle import load_network_data
from tests.numpy_reference import calc_bsdf as calc_bsdf_numpy
from toop_engine_dc_solver.example_grids import (
PandapowerCounters,
@@ -33,7 +34,7 @@
random_topology_info_backend,
)
from toop_engine_dc_solver.jax.bsdf import _apply_bus_split, calc_bsdf, init_bsdf_results
-from toop_engine_dc_solver.jax.inputs import validate_static_information
+from toop_engine_dc_solver.jax.inputs import load_static_information_fs, validate_static_information
from toop_engine_dc_solver.jax.lodf import calc_lodf
from toop_engine_dc_solver.preprocess.convert_to_jax import convert_to_jax, load_grid
from toop_engine_dc_solver.preprocess.pandapower.pandapower_backend import PandaPowerBackend
@@ -469,6 +470,53 @@ def test_case30_with_psts_powsybl() -> None:
assert powsybl_backend.get_controllable_phase_shift_mask().sum() == 2
+def test_parallel_pst_example_data_folder(parallel_pst_data_folder: Path) -> None:
+ network_data = load_network_data(parallel_pst_data_folder / "network_data.pkl")
+ assert network_data.controllable_phase_shift_mask.sum() == 3
+ assert np.array_equal(network_data.phase_shift_linearity, np.array([True, True, True]))
+ assert np.array_equal(
+ network_data.parallel_pst_group_mask,
+ np.array([[True, True, False], [False, False, True]], dtype=bool),
+ )
+ assert network_data.parallel_pst_group_ids == ["PST1", "PST3"]
+ assert len(network_data.branch_action_set) > 0
+
+
+@pytest.mark.parametrize(
+ ("fixture_name", "expected_group_mask", "expected_group_ids"),
+ [
+ ("grouped_pst_grid_example_data_folder", np.ones((1, 4), dtype=bool), ["PST_1_group_1"]),
+ (
+ "grouped_pst_grid_example_split_data_folder",
+ np.array([[True, False, True, False], [False, True, False, True]], dtype=bool),
+ ["PST_1_group_1", "PST_2_group_1"],
+ ),
+ ],
+)
+def test_grouped_pst_grid_example_data_folder(
+ request: pytest.FixtureRequest,
+ fixture_name: str,
+ expected_group_mask: np.ndarray,
+ expected_group_ids: list[str],
+) -> None:
+ data_folder = request.getfixturevalue(fixture_name)
+ network_data = load_network_data(data_folder / "network_data.pkl")
+
+ assert network_data.controllable_phase_shift_mask.sum() == 4
+ assert np.array_equal(network_data.phase_shift_linearity, np.ones(4, dtype=bool))
+ assert np.array_equal(network_data.parallel_pst_group_mask, expected_group_mask)
+ assert network_data.parallel_pst_group_ids == expected_group_ids
+ assert len(network_data.branch_action_set) > 0
+
+ static_information = load_static_information_fs(
+ filesystem=DirFileSystem(str(data_folder)),
+ filename=PREPROCESSING_PATHS["static_information_file_path"],
+ )
+ nodal_injection_information = static_information.dynamic_information.nodal_injection_information
+ assert nodal_injection_information is not None
+ assert np.array_equal(nodal_injection_information.parallel_pst_group_mask, expected_group_mask)
+
+
def test_case14_with_matching_asset_topo() -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir)
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index b065c32af..489bbaa74 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -16,7 +16,7 @@
import pytest
from fsspec.implementations.local import LocalFileSystem
from pypowsybl.network import Network
-from toop_engine_grid_helpers.powsybl.example_grids import parallel_pst_example
+from toop_engine_grid_helpers.powsybl.example_grids import grouped_pst_grid_example, parallel_pst_example
from toop_engine_importer.pypowsybl_import import network_analysis, powsybl_masks, preprocessing
from toop_engine_importer.pypowsybl_import.data_classes import PreProcessingStatistics
from toop_engine_importer.pypowsybl_import.ucte.powsybl_masks_ucte import get_switchable_buses_ucte
@@ -882,6 +882,62 @@ def test_build_pst_group_labels_marks_non_controllable_as_ungrouped():
assert label_by_id["PST3"] == -1
+@pytest.mark.parametrize(
+ ("linear_pst", "split_pst_station", "expected_group_count", "expected_grouped_pairs"),
+ [
+ (
+ [True, True, True, True],
+ False,
+ 1,
+ [("PST_1_group_1", "PST_2_group_1"), ("PST_3_group_2", "PST_4_group_2")],
+ ),
+ (
+ [True, True, True, True],
+ True,
+ 2,
+ [("PST_1_group_1", "PST_3_group_2"), ("PST_2_group_1", "PST_4_group_2")],
+ ),
+ (
+ [True, False, True, False],
+ False,
+ 2,
+ [("PST_1_group_1", "PST_3_group_2"), ("PST_2_group_1", "PST_4_group_2")],
+ ),
+ ],
+)
+def test_grouped_pst_grid_importer_masks_include_pst_groups(
+ linear_pst: list[bool],
+ split_pst_station: bool,
+ expected_group_count: int,
+ expected_grouped_pairs: list[tuple[str, str]],
+ cgmes_importer_parameters: CgmesImporterParameters,
+) -> None:
+ net = grouped_pst_grid_example(linear_pst=linear_pst)
+ if split_pst_station:
+ net.open_switch("VL2_BREAKER#0")
+ trafos = net.get_2_windings_transformers(attributes=[])
+ importer_parameters = deepcopy(cgmes_importer_parameters)
+ importer_parameters.area_settings.control_area = ["BE"]
+ importer_parameters.area_settings.view_area = ["BE"]
+ importer_parameters.area_settings.nminus1_area = ["BE"]
+ importer_parameters.area_settings.cutoff_voltage = 220
+
+ network_masks = powsybl_masks.make_masks(
+ network=net,
+ slack_id="VL1_0",
+ importer_parameters=importer_parameters,
+ blacklisted_ids=[],
+ )
+ label_by_id = dict(zip(trafos.index, network_masks.pst_group_masks, strict=True))
+
+ assert powsybl_masks.validate_network_masks(network_masks, powsybl_masks.create_default_network_masks(net))
+ assert np.array_equal(network_masks.trafo_pst_controllable, np.ones(len(trafos), dtype=bool))
+ assert set(label_by_id) == {"PST_1_group_1", "PST_2_group_1", "PST_3_group_2", "PST_4_group_2"}
+ assert len(set(network_masks.pst_group_masks)) == expected_group_count
+ for first_pst_id, second_pst_id in expected_grouped_pairs:
+ assert label_by_id[first_pst_id] == label_by_id[second_pst_id]
+
+
def test_update_reward_masks_to_include_border_branches(
ucte_file_with_border, ucte_importer_parameters: UcteImporterParameters
):
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py
index f95455731..cd2f52ba8 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py
@@ -16,10 +16,11 @@
from qdax.core.emitters.standard_emitters import EmitterState, ExtraScores
from toop_engine_dc_solver.jax.aggregate_results import aggregate_to_metric_batched, get_worst_k_contingencies
from toop_engine_dc_solver.jax.compute_batch import compute_symmetric_batch
-from toop_engine_dc_solver.jax.nodal_inj_optim import make_start_options
+from toop_engine_dc_solver.jax.nodal_inj_optim import canonicalize_parallel_pst_taps, make_start_options
from toop_engine_dc_solver.jax.types import (
ActionIndexComputations,
DynamicInformation,
+ NodalInjectionInformation,
NodalInjOptimResults,
NodalInjStartOptions,
SolverConfig,
@@ -324,6 +325,7 @@ def convert_to_topologies(
repertoire: DiscreteMapElitesRepertoire,
contingency_ids: list[str],
grid_model_low_tap: Int[Array, " n_controllable_psts"] | None = None,
+ nodal_injection_information: NodalInjectionInformation | None = None,
) -> list[Topology]:
"""Take a repertoire and convert it to a list of kafka-sendable topologies.
@@ -337,6 +339,8 @@ def convert_to_topologies(
The lowest tap value in the grid model, used to convert the relative tap values in the genotype to absolute tap
values that can be sent to the kafka topics. This will only be read if nodal_injection results are present
in the genotype.
+ nodal_injection_information : NodalInjectionInformation | None
+ Optional nodal injection information used to emit grouped PST setpoints consistently with the solver.
Returns
-------
@@ -362,7 +366,13 @@ def convert_to_topologies(
assert nodal_inj.pst_tap_idx.shape[0] == 1, "Only one timestep is supported, but found shape " + str(
nodal_inj.pst_tap_idx.shape
)
- tap_array = nodal_inj.pst_tap_idx[0].astype(int) + grid_model_low_tap
+ tap_array = nodal_inj.pst_tap_idx.astype(int)
+ if nodal_injection_information is not None:
+ tap_array = canonicalize_parallel_pst_taps(
+ pst_tap_indices=tap_array[None, :, :],
+ nodal_inj_info=nodal_injection_information,
+ )[0]
+ tap_array = tap_array[0] + grid_model_low_tap
pst_setpoints = tap_array.tolist()
case_indices = iter_repertoire.extra_scores.pop("case_indices", [])
@@ -389,6 +399,7 @@ def summarize_repo(
initial_fitness: float,
contingency_ids: list[str],
grid_model_low_tap: Int[Array, " n_controllable_psts"] | None = None,
+ nodal_injection_information: NodalInjectionInformation | None = None,
) -> list[Topology]:
"""Summarize the repertoire into a list of topologies.
@@ -404,6 +415,8 @@ def summarize_repo(
TODO: Fix me to have per topology contingency ids if needed
grid_model_low_tap : Int[Array, " n_controllable_psts"] | None
The lowest tap value in the grid model, from nodal_injection_information.grid_model_low_tap.
+ nodal_injection_information : NodalInjectionInformation | None
+ Optional nodal injection information used to emit grouped PST setpoints consistently with the solver.
Returns
-------
@@ -416,7 +429,12 @@ def summarize_repo(
initial_fitness=initial_fitness,
)
- topologies = convert_to_topologies(best_repo, contingency_ids, grid_model_low_tap=grid_model_low_tap)
+ topologies = convert_to_topologies(
+ best_repo,
+ contingency_ids,
+ grid_model_low_tap=grid_model_low_tap,
+ nodal_injection_information=nodal_injection_information,
+ )
return topologies
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py
index f1be10ee6..590b089a9 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py
@@ -372,12 +372,14 @@ def extract_topologies(optimizer_data: OptimizerData) -> list[Topology]:
# Get grid_model_low_tap if nodal injection information is available
nodal_inj_info = optimizer_data.jax_data.dynamic_informations[0].nodal_injection_information
grid_model_low_tap = nodal_inj_info.grid_model_low_tap if nodal_inj_info is not None else None
+ grouped_pst_nodal_inj_info = nodal_inj_info if optimizer_data.solver_configs[0].enable_parallel_pst_group_optim else None
topologies = summarize_repo(
optimizer_data.jax_data.repertoire,
initial_fitness=optimizer_data.initial_fitness,
contingency_ids=contingency_ids,
grid_model_low_tap=grid_model_low_tap,
+ nodal_injection_information=grouped_pst_nodal_inj_info,
)
# Filter out topologies that have already been sent
diff --git a/packages/topology_optimizer_pkg/tests/conftest.py b/packages/topology_optimizer_pkg/tests/conftest.py
index f91cab3f5..2198ba739 100644
--- a/packages/topology_optimizer_pkg/tests/conftest.py
+++ b/packages/topology_optimizer_pkg/tests/conftest.py
@@ -34,6 +34,9 @@
complex_grid_battery_hvdc_svc_3w_trafo_data_folder,
oberrhein_data,
)
+from toop_engine_dc_solver.example_grids import (
+ grouped_pst_grid_example_data_folder as create_grouped_pst_grid_example_data_folder,
+)
from toop_engine_dc_solver.jax.types import ActionSet
from toop_engine_dc_solver.preprocess import load_grid
from toop_engine_grid_helpers.powsybl.loadflow_parameters import DISTRIBUTED_SLACK
@@ -311,6 +314,36 @@ def case57_non_converging_path(_case57_non_converging_path: Path, tmp_path: Path
return tmp_path
+@pytest.fixture(scope="session")
+def _grouped_pst_grid_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ """Preprocessed grouped PST grid prepared once per test session."""
+ tmp_path = tmp_path_factory.mktemp("grouped_pst_grid")
+ create_grouped_pst_grid_example_data_folder(tmp_path)
+ return tmp_path
+
+
+@pytest.fixture(scope="function")
+def grouped_pst_grid_path(_grouped_pst_grid_path: Path, tmp_path: Path) -> Path:
+ """Path to a per-test copy of the grouped PST grid."""
+ shutil.copytree(_grouped_pst_grid_path, tmp_path, dirs_exist_ok=True)
+ return tmp_path
+
+
+@pytest.fixture(scope="session")
+def _grouped_pst_grid_split_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ """Preprocessed grouped PST grid with the PST station switch split prepared once per test session."""
+ tmp_path = tmp_path_factory.mktemp("grouped_pst_grid_split")
+ create_grouped_pst_grid_example_data_folder(tmp_path, split_group_station=True)
+ return tmp_path
+
+
+@pytest.fixture(scope="function")
+def grouped_pst_grid_split_path(_grouped_pst_grid_split_path: Path, tmp_path: Path) -> Path:
+ """Path to a per-test copy of the grouped PST grid with the PST station switch split."""
+ shutil.copytree(_grouped_pst_grid_split_path, tmp_path, dirs_exist_ok=True)
+ return tmp_path
+
+
@pytest.fixture(scope="session")
def static_information_file(_grid_folder: Path) -> Path:
return _grid_folder / "oberrhein" / PREPROCESSING_PATHS["static_information_file_path"]
diff --git a/packages/topology_optimizer_pkg/tests/test_ac_dc_integration.py b/packages/topology_optimizer_pkg/tests/test_ac_dc_integration.py
index 5a994c347..5145044dd 100644
--- a/packages/topology_optimizer_pkg/tests/test_ac_dc_integration.py
+++ b/packages/topology_optimizer_pkg/tests/test_ac_dc_integration.py
@@ -41,6 +41,7 @@
from toop_engine_topology_optimizer.ac.scoring_functions import compute_metrics_single_timestep
from toop_engine_topology_optimizer.ac.worker import Args as ACArgs
from toop_engine_topology_optimizer.ac.worker import main as ac_main
+from toop_engine_topology_optimizer.dc.worker.optimizer import extract_topologies, initialize_optimization, run_epoch
from toop_engine_topology_optimizer.dc.worker.worker import Args as DCArgs
from toop_engine_topology_optimizer.dc.worker.worker import main as dc_main
from toop_engine_topology_optimizer.interfaces.messages.ac_params import ACGAParameters, ACOptimizerParameters
@@ -64,6 +65,40 @@
# Ensure that tests using Kafka are not run in parallel with each other
pytestmark = pytest.mark.xdist_group("kafka")
+GROUPED_PST_IDS = ("PST_1_group_1", "PST_2_group_1", "PST_3_group_2", "PST_4_group_2")
+
+
+def _dc_n0_overload_for_pst_setpoints(
+ grid_folder: Path,
+ pst_setpoints: list[int] | None,
+) -> float:
+ network_data = load_grid(
+ data_folder_dirfs=DirFileSystem(str(grid_folder)),
+ parameters=PreprocessParameters(),
+ lf_params=SINGLE_SLACK,
+ )[2]
+ runner = PowsyblRunner()
+ runner.load_base_grid(grid_folder / PREPROCESSING_PATHS["grid_file_path_powsybl"])
+ runner.store_action_set(extract_action_set(network_data))
+ nminus1_definition = extract_nminus1_definition(network_data)
+ runner.store_nminus1_definition(nminus1_definition)
+ base_case_id = nminus1_definition.base_case.id if nminus1_definition.base_case is not None else None
+
+ loadflow = runner.run_loadflow_single_timestep(
+ actions=[],
+ disconnections=[],
+ pst_setpoints=pst_setpoints,
+ method="dc",
+ )
+ metrics = compute_metrics_single_timestep(
+ actions=[],
+ disconnections=[],
+ loadflow=loadflow,
+ additional_info=None,
+ base_case_id=base_case_id,
+ )
+ return float(metrics.extra_scores["overload_energy_n_0"])
+
def dc_main_wrapper(args: DCArgs, processed_gridfile_fs: AbstractFileSystem) -> None:
instance_id = str(uuid4())
@@ -781,6 +816,77 @@ def test_dc_optimizer_fitness_ac_validation_fitness_parallel_pst(tmp_path_factor
)
+@pytest.mark.parametrize("grid_fixture_name", ["grouped_pst_grid_path", "grouped_pst_grid_split_path"])
+def test_grouped_pst_optimizer_improves_n0_loadflow(request: pytest.FixtureRequest, grid_fixture_name: str) -> None:
+ """Run the DC optimizer on grouped PST grids and validate the emitted PST setpoints on DC N-0 loadflow."""
+ grid_folder = request.getfixturevalue(grid_fixture_name)
+ _, static_information, network_data = load_grid(
+ data_folder_dirfs=DirFileSystem(str(grid_folder)),
+ parameters=PreprocessParameters(),
+ lf_params=SINGLE_SLACK,
+ )
+ action_set = extract_action_set(network_data)
+ nodal_injection_information = static_information.dynamic_information.nodal_injection_information
+ assert nodal_injection_information is not None
+ parallel_pst_group_mask = nodal_injection_information.parallel_pst_group_mask
+ assert parallel_pst_group_mask is not None
+ assert [pst.id for pst in action_set.pst_ranges] == list(GROUPED_PST_IDS)
+ assert len(action_set.pst_ranges) == parallel_pst_group_mask.shape[1]
+
+ initial_n0_overload = _dc_n0_overload_for_pst_setpoints(grid_folder, pst_setpoints=None)
+ assert initial_n0_overload > 0.0
+
+ optimizer_params = DCOptimizerParameters(
+ ga_config=BatchedMEParameters(
+ iterations_per_epoch=40,
+ runtime_seconds=5,
+ random_seed=42,
+ enable_nodal_inj_optim=True,
+ enable_parallel_pst_group_optim=True,
+ n_worst_contingencies=2,
+ pst_mutation_sigma=3.0,
+ pst_mutation_probability=1.0,
+ target_metrics=(("overload_energy_n_0", 1.0),),
+ observed_metrics=("overload_energy_n_0", "split_subs"),
+ me_descriptors=(DescriptorDef(metric="split_subs", num_cells=2),),
+ random_topo_prob=0.0,
+ add_split_prob=0.0,
+ change_split_prob=0.0,
+ remove_split_prob=0.0,
+ ),
+ loadflow_solver_config=LoadflowSolverParameters(batch_size=16, max_num_splits=1, max_num_disconnections=0),
+ )
+ optimizer_data, _stats, initial_strategy = initialize_optimization(
+ params=optimizer_params,
+ optimization_id="test_grouped_pst",
+ static_information_files=(PREPROCESSING_PATHS["static_information_file_path"],),
+ processed_gridfile_fs=DirFileSystem(str(grid_folder)),
+ )
+ assert initial_strategy.timesteps[0].metrics.extra_scores["overload_energy_n_0"] > 0.0
+
+ optimizer_data = run_epoch(optimizer_data)
+ topologies = extract_topologies(optimizer_data)
+ topology = max(topologies, key=lambda candidate: candidate.metrics.fitness)
+ assert topology.pst_setpoints is not None
+ pst_setpoints = np.asarray(topology.pst_setpoints, dtype=int)
+ assert pst_setpoints.shape == (parallel_pst_group_mask.shape[1],)
+ for group_mask in np.asarray(parallel_pst_group_mask, dtype=bool):
+ group_setpoints = pst_setpoints[group_mask]
+ assert np.all(group_setpoints == group_setpoints[0])
+ assert (
+ topology.metrics.extra_scores["overload_energy_n_0"]
+ < initial_strategy.timesteps[0].metrics.extra_scores["overload_energy_n_0"]
+ )
+
+ optimized_n0_overload = _dc_n0_overload_for_pst_setpoints(
+ grid_folder,
+ pst_setpoints=topology.pst_setpoints,
+ )
+ assert optimized_n0_overload < initial_n0_overload
+ if grid_fixture_name == "grouped_pst_grid_split_path":
+ assert optimized_n0_overload == pytest.approx(0.0, abs=1e-6)
+
+
def test_dc_optimizer_fitness_ac_validation_fitness_complex(tmp_path_factory: pytest.TempPathFactory) -> None:
"""Compare DC solver fitness to AC validation fitness (using DC loadflow mode) for several PST taps on the complex grid.
From 3b6ffa810841d0a031e02e722b7bec3afedfcc4e Mon Sep 17 00:00:00 2001
From: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:01:48 +0200
Subject: [PATCH 41/44] chore: revert not needed changes
Signed-off-by: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
---
.../dc/genetic_functions/scoring_functions.py | 24 +++----------------
.../dc/worker/optimizer.py | 2 --
2 files changed, 3 insertions(+), 23 deletions(-)
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py
index cd2f52ba8..f95455731 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/scoring_functions.py
@@ -16,11 +16,10 @@
from qdax.core.emitters.standard_emitters import EmitterState, ExtraScores
from toop_engine_dc_solver.jax.aggregate_results import aggregate_to_metric_batched, get_worst_k_contingencies
from toop_engine_dc_solver.jax.compute_batch import compute_symmetric_batch
-from toop_engine_dc_solver.jax.nodal_inj_optim import canonicalize_parallel_pst_taps, make_start_options
+from toop_engine_dc_solver.jax.nodal_inj_optim import make_start_options
from toop_engine_dc_solver.jax.types import (
ActionIndexComputations,
DynamicInformation,
- NodalInjectionInformation,
NodalInjOptimResults,
NodalInjStartOptions,
SolverConfig,
@@ -325,7 +324,6 @@ def convert_to_topologies(
repertoire: DiscreteMapElitesRepertoire,
contingency_ids: list[str],
grid_model_low_tap: Int[Array, " n_controllable_psts"] | None = None,
- nodal_injection_information: NodalInjectionInformation | None = None,
) -> list[Topology]:
"""Take a repertoire and convert it to a list of kafka-sendable topologies.
@@ -339,8 +337,6 @@ def convert_to_topologies(
The lowest tap value in the grid model, used to convert the relative tap values in the genotype to absolute tap
values that can be sent to the kafka topics. This will only be read if nodal_injection results are present
in the genotype.
- nodal_injection_information : NodalInjectionInformation | None
- Optional nodal injection information used to emit grouped PST setpoints consistently with the solver.
Returns
-------
@@ -366,13 +362,7 @@ def convert_to_topologies(
assert nodal_inj.pst_tap_idx.shape[0] == 1, "Only one timestep is supported, but found shape " + str(
nodal_inj.pst_tap_idx.shape
)
- tap_array = nodal_inj.pst_tap_idx.astype(int)
- if nodal_injection_information is not None:
- tap_array = canonicalize_parallel_pst_taps(
- pst_tap_indices=tap_array[None, :, :],
- nodal_inj_info=nodal_injection_information,
- )[0]
- tap_array = tap_array[0] + grid_model_low_tap
+ tap_array = nodal_inj.pst_tap_idx[0].astype(int) + grid_model_low_tap
pst_setpoints = tap_array.tolist()
case_indices = iter_repertoire.extra_scores.pop("case_indices", [])
@@ -399,7 +389,6 @@ def summarize_repo(
initial_fitness: float,
contingency_ids: list[str],
grid_model_low_tap: Int[Array, " n_controllable_psts"] | None = None,
- nodal_injection_information: NodalInjectionInformation | None = None,
) -> list[Topology]:
"""Summarize the repertoire into a list of topologies.
@@ -415,8 +404,6 @@ def summarize_repo(
TODO: Fix me to have per topology contingency ids if needed
grid_model_low_tap : Int[Array, " n_controllable_psts"] | None
The lowest tap value in the grid model, from nodal_injection_information.grid_model_low_tap.
- nodal_injection_information : NodalInjectionInformation | None
- Optional nodal injection information used to emit grouped PST setpoints consistently with the solver.
Returns
-------
@@ -429,12 +416,7 @@ def summarize_repo(
initial_fitness=initial_fitness,
)
- topologies = convert_to_topologies(
- best_repo,
- contingency_ids,
- grid_model_low_tap=grid_model_low_tap,
- nodal_injection_information=nodal_injection_information,
- )
+ topologies = convert_to_topologies(best_repo, contingency_ids, grid_model_low_tap=grid_model_low_tap)
return topologies
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py
index 590b089a9..f1be10ee6 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/worker/optimizer.py
@@ -372,14 +372,12 @@ def extract_topologies(optimizer_data: OptimizerData) -> list[Topology]:
# Get grid_model_low_tap if nodal injection information is available
nodal_inj_info = optimizer_data.jax_data.dynamic_informations[0].nodal_injection_information
grid_model_low_tap = nodal_inj_info.grid_model_low_tap if nodal_inj_info is not None else None
- grouped_pst_nodal_inj_info = nodal_inj_info if optimizer_data.solver_configs[0].enable_parallel_pst_group_optim else None
topologies = summarize_repo(
optimizer_data.jax_data.repertoire,
initial_fitness=optimizer_data.initial_fitness,
contingency_ids=contingency_ids,
grid_model_low_tap=grid_model_low_tap,
- nodal_injection_information=grouped_pst_nodal_inj_info,
)
# Filter out topologies that have already been sent
From 289140b64f8383b60ac8797cdc276997257eefcd Mon Sep 17 00:00:00 2001
From: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:11:48 +0200
Subject: [PATCH 42/44] test: fix importer pst group
Signed-off-by: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
---
.../tests/pypowsybl_import/test_powsybl_masks.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index 6c711d8a7..a6e2aaab8 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -936,12 +936,14 @@ def test_grouped_pst_grid_importer_masks_include_pst_groups(
importer_parameters=importer_parameters,
blacklisted_ids=[],
)
- label_by_id = dict(zip(trafos.index, network_masks.pst_group_masks, strict=True))
+ label_by_id = dict(zip(trafos.index, network_masks.pst_group_labels, strict=True))
assert powsybl_masks.validate_network_masks(network_masks, powsybl_masks.create_default_network_masks(net))
- assert np.array_equal(network_masks.trafo_pst_controllable, np.ones(len(trafos), dtype=bool))
- assert set(label_by_id) == {"PST_1_group_1", "PST_2_group_1", "PST_3_group_2", "PST_4_group_2"}
- assert len(set(network_masks.pst_group_masks)) == expected_group_count
+ if expected_group_count == 2:
+ assert set(label_by_id.values()) == {0, 1}
+ else:
+ assert set(label_by_id.values()) == {0}
+ assert len(set(network_masks.pst_group_labels)) == expected_group_count
for first_pst_id, second_pst_id in expected_grouped_pairs:
assert label_by_id[first_pst_id] == label_by_id[second_pst_id]
From c16723ed23becd5cc3ae91b1ba3f94fcda9abfe2 Mon Sep 17 00:00:00 2001
From: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
Date: Mon, 29 Jun 2026 14:37:09 +0200
Subject: [PATCH 43/44] test:
grouped_pst_grid_backend_reads_parallel_group_masks
Signed-off-by: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
---
.../preprocessing/test_powsybl_backend.py | 85 +++++--------------
1 file changed, 19 insertions(+), 66 deletions(-)
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
index ad252d4e5..77fbdc208 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
@@ -35,41 +35,13 @@
)
from toop_engine_dc_solver.preprocess.powsybl.powsybl_backend import PowsyblBackend
from toop_engine_dc_solver.preprocess.preprocess import preprocess
-from toop_engine_grid_helpers.powsybl.example_grids import grouped_pst_grid_example
from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK
from toop_engine_interfaces.folder_structure import (
- NETWORK_MASK_NAMES,
PREPROCESSING_PATHS,
)
from toop_engine_interfaces.loadflow_result_helpers_polars import extract_solver_matrices_polars
from toop_engine_interfaces.nminus1_definition import load_nminus1_definition
-GROUPED_PST_IDS = ["PST_1_group_1", "PST_2_group_1", "PST_3_group_2", "PST_4_group_2"]
-
-
-def _write_grouped_pst_grid(
- tmp_dir: Path,
- linear_pst: list[bool],
- pst_group_labels: dict[str, int],
- split_station: bool,
-) -> PowsyblBackend:
- net = grouped_pst_grid_example(linear_pst=linear_pst)
- if split_station:
- net.open_switch("VL2_BREAKER#0")
-
- grid_path = tmp_dir / PREPROCESSING_PATHS["grid_file_path_powsybl"]
- grid_path.parent.mkdir(parents=True, exist_ok=True)
- net.save(grid_path)
-
- mask_path = tmp_dir / PREPROCESSING_PATHS["masks_path"]
- mask_path.mkdir(parents=True, exist_ok=True)
- trafo_ids = net.get_2_windings_transformers(attributes=[]).index.to_list()
- np.save(mask_path / NETWORK_MASK_NAMES["trafo_pst_controllable"], np.isin(trafo_ids, GROUPED_PST_IDS))
- group_labels = np.array([pst_group_labels[trafo_id] for trafo_id in trafo_ids])
- np.save(mask_path / NETWORK_MASK_NAMES["pst_group_masks"], group_labels)
-
- return PowsyblBackend(DirFileSystem(str(tmp_dir)))
-
def test_get_branches(powsybl_case57_folder_xiidm: Path) -> None:
filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
@@ -461,57 +433,38 @@ def test_psts(tmp_path_factory: pytest.TempPathFactory) -> None:
@pytest.mark.parametrize(
(
- "linear_pst",
- "split_station",
- "pst_group_labels",
- "expected_linearity",
+ "fixture_name",
"expected_group_mask",
"expected_group_ids",
),
[
(
- [True, True, True, True],
- False,
- {pst_id: 0 for pst_id in GROUPED_PST_IDS},
- [True, True, True, True],
- [[True, True, True, True]],
+ "grouped_pst_grid_example_data_folder",
+ np.ones((1, 4), dtype=bool),
["PST_1_group_1"],
),
(
- [True, True, True, True],
- True,
- {"PST_1_group_1": 0, "PST_2_group_1": 1, "PST_3_group_2": 0, "PST_4_group_2": 1},
- [True, True, True, True],
- [[True, False, True, False], [False, True, False, True]],
- ["PST_1_group_1", "PST_2_group_1"],
- ),
- (
- [True, False, True, False],
- False,
- {"PST_1_group_1": 0, "PST_2_group_1": 1, "PST_3_group_2": 0, "PST_4_group_2": 1},
- [True, False, True, False],
- [[True, False, True, False], [False, True, False, True]],
+ "grouped_pst_grid_example_split_data_folder",
+ np.array([[True, False, True, False], [False, True, False, True]], dtype=bool),
["PST_1_group_1", "PST_2_group_1"],
),
],
)
def test_grouped_pst_grid_backend_reads_parallel_group_masks(
- tmp_path: Path,
- linear_pst: list[bool],
- split_station: bool,
- pst_group_labels: dict[str, int],
- expected_linearity: list[bool],
- expected_group_mask: list[list[bool]],
+ request: pytest.FixtureRequest,
+ fixture_name: str,
+ expected_group_mask: np.ndarray,
expected_group_ids: list[str],
) -> None:
- backend = _write_grouped_pst_grid(
- tmp_dir=tmp_path,
- linear_pst=linear_pst,
- pst_group_labels=pst_group_labels,
- split_station=split_station,
- )
-
- assert backend.get_controllable_phase_shift_ids() == GROUPED_PST_IDS
- assert np.array_equal(backend.get_phase_shift_linearity(), np.array(expected_linearity))
- assert np.array_equal(backend.get_parallel_pst_group_mask(), np.array(expected_group_mask))
+ data_folder = request.getfixturevalue(fixture_name)
+ backend = PowsyblBackend(DirFileSystem(str(data_folder)))
+
+ assert backend.get_controllable_phase_shift_ids() == [
+ "PST_1_group_1",
+ "PST_2_group_1",
+ "PST_3_group_2",
+ "PST_4_group_2",
+ ]
+ assert np.array_equal(backend.get_phase_shift_linearity(), np.ones(4, dtype=bool))
+ assert np.array_equal(backend.get_parallel_pst_group_mask(), expected_group_mask)
assert backend.get_parallel_pst_group_ids() == expected_group_ids
From 5a3ba1ab6f370c3b40b716e6b38a340534215376 Mon Sep 17 00:00:00 2001
From: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:43:59 +0200
Subject: [PATCH 44/44] Merge branch 'main' into feat/pst_group_example_grid
Signed-off-by: Benjamin Petrick <170433522+BenjPetr@users.noreply.github.com>
---
.../sensitive-files-whitelist.txt | 4 +
.../tests/powsybl/test_ca_powsybl_helpers.py | 53 +
.../test_contingency_analysis_powsybl.py | 268 ++++-
.../jax/compute_batch.py | 8 +-
.../preprocess/convert_to_jax.py | 10 +-
.../preprocess/network_data.py | 104 +-
.../preprocess/powsybl/powsybl_backend.py | 39 +-
.../preprocess/preprocess.py | 10 +
packages/dc_solver_pkg/tests/conftest.py | 18 +-
.../test_grid_node_breaker/action_set.json | 806 ++++++++++++++-
.../action_set_diffs.hdf5 | Bin 0 -> 7120 bytes
.../files/test_grid_node_breaker/grid.xiidm | 605 +++++------
.../importer_auxiliary_data.json | 475 ++++-----
.../initial_topology/asset_topology.json | 953 ++++++++++--------
.../original_gridfile/grid.xiidm | 246 +++++
.../test_ucte_powsybl_example.xiidm | 359 -------
.../loadflow_parameters.json | 1 +
.../masks/busbar_for_nminus1.npy | Bin 0 -> 139 bytes
.../masks/dangling_line_for_nminus1.npy | Bin 0 -> 128 bytes
.../masks/line_blacklisted.npy | Bin 138 -> 137 bytes
.../masks/line_disconnectable.npy | Bin 138 -> 137 bytes
.../masks/line_for_nminus1.npy | Bin 138 -> 137 bytes
.../masks/line_for_reward.npy | Bin 138 -> 137 bytes
.../masks/line_overload_weight.npy | Bin 208 -> 200 bytes
.../masks/line_tso_border.npy | Bin 138 -> 137 bytes
.../masks/load_for_nminus1.npy | Bin 131 -> 130 bytes
.../masks/pst_group_labels.npy | Bin 0 -> 128 bytes
.../masks/relevant_subs.npy | Bin 134 -> 133 bytes
.../masks/switch_for_nminus1.npy | Bin 285 -> 234 bytes
.../masks/switch_for_reward.npy | Bin 285 -> 234 bytes
.../masks/trafo_has_pst_tap.npy | Bin 0 -> 128 bytes
.../masks/trafo_pst_linear.npy | Bin 0 -> 128 bytes
.../test_grid_node_breaker/network_data.pkl | Bin 17463 -> 0 bytes
.../nminus1_definition.json | 357 ++++---
.../static_information.hdf5 | Bin 20427 -> 20955 bytes
.../static_information.zip | Bin 2196 -> 0 bytes
.../static_information_stats.json | 27 +
.../tests/jax/test_busbar_outage.py | 92 +-
.../tests/jax/test_compute_batch.py | 44 +
.../preprocessing/test_convert_to_jax.py | 23 +
.../preprocessing/test_powsybl_backend.py | 79 ++
.../preprocessing/test_write_aux_data.py | 18 +-
.../powsybl/example_grids.py | 2 +
.../network_graph/graph_to_asset_topo.py | 6 +-
.../network_graph/powsybl_station_to_graph.py | 42 +-
.../cgmes/powsybl_masks_cgmes.py | 4 +
.../pypowsybl_import/powsybl_masks.py | 82 +-
.../pypowsybl_import/preprocessing.py | 9 +
.../test_powsybl_station_to_graph.py | 71 +-
.../pypowsybl_import/test_powsybl_masks.py | 83 ++
.../pypowsybl_import/test_preprocessing.py | 12 +
.../ac/optimizer.py | 1 +
.../ac/scoring_functions.py | 81 +-
.../dc/genetic_functions/initialization.py | 24 +-
.../interfaces/messages/ac_params.py | 6 +
.../tests/ac/test_ac_scoring_functions.py | 124 +++
.../tests/benchmark/test_benchmark_utils.py | 94 ++
.../genetic_functions/test_initialization.py | 49 +-
.../interfaces/messages/test_ac_ga_params.py | 2 +
59 files changed, 3641 insertions(+), 1650 deletions(-)
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/action_set_diffs.hdf5
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm
delete mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/test_ucte_powsybl_example.xiidm
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/busbar_for_nminus1.npy
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/dangling_line_for_nminus1.npy
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/pst_group_labels.npy
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/trafo_has_pst_tap.npy
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/trafo_pst_linear.npy
delete mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/network_data.pkl
delete mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.zip
create mode 100644 packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information_stats.json
diff --git a/.pre-commit-hooks/sensitive-files-whitelist.txt b/.pre-commit-hooks/sensitive-files-whitelist.txt
index 00d6e449f..bfe4d1f5e 100644
--- a/.pre-commit-hooks/sensitive-files-whitelist.txt
+++ b/.pre-commit-hooks/sensitive-files-whitelist.txt
@@ -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
diff --git a/packages/contingency_analysis_pkg/tests/powsybl/test_ca_powsybl_helpers.py b/packages/contingency_analysis_pkg/tests/powsybl/test_ca_powsybl_helpers.py
index 89720a88c..0e829813d 100644
--- a/packages/contingency_analysis_pkg/tests/powsybl/test_ca_powsybl_helpers.py
+++ b/packages/contingency_analysis_pkg/tests/powsybl/test_ca_powsybl_helpers.py
@@ -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()
@@ -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:
diff --git a/packages/contingency_analysis_pkg/tests/powsybl/test_contingency_analysis_powsybl.py b/packages/contingency_analysis_pkg/tests/powsybl/test_contingency_analysis_powsybl.py
index c700395aa..80aa1e9cc 100644
--- a/packages/contingency_analysis_pkg/tests/powsybl/test_contingency_analysis_powsybl.py
+++ b/packages/contingency_analysis_pkg/tests/powsybl/test_contingency_analysis_powsybl.py
@@ -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
@@ -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,
@@ -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)
@@ -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)
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py
index 23dacdce4..01e73bc85 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/jax/compute_batch.py
@@ -208,12 +208,12 @@ def compute_bsdf_lodf_static_flows(
topo_res.success[:, None],
(topo_res.success.shape[0], dynamic_information.n_inj_failures),
)
+ n_bb_outage_failures = (
+ dynamic_information.n_bb_outages if solver_config.enable_bb_outages and solver_config.bb_outage_as_nminus1 else 0
+ )
bb_outage_success = jnp.broadcast_to(
topo_res.success[:, None],
- (
- topo_res.success.shape[0],
- dynamic_information.n_bb_outages if dynamic_information.bb_outage_baseline_analysis is None else 0,
- ),
+ (topo_res.success.shape[0], n_bb_outage_failures),
)
contingency_success = jnp.concatenate(
[
diff --git a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
index 94b38d31f..4d46f272c 100644
--- a/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
+++ b/packages/dc_solver_pkg/src/toop_engine_dc_solver/preprocess/convert_to_jax.py
@@ -458,7 +458,15 @@ def convert_rel_bb_outage_data( # noqa: C901
# Determine dimensions for padding of branch_outage_set
n_actions = sum(actions_per_sub) # Total number of combinations
- n_max_bb_to_outage_per_sub = max(len(combi) for sub in rel_bb_outage_br_indices for combi in sub)
+ n_max_bb_slots_from_outages = max(len(combi) for sub in rel_bb_outage_br_indices for combi in sub)
+ n_max_bb_slots_from_articulation = max(
+ (
+ max((max(articulation_bbs, default=-1) + 1) for articulation_bbs in sub_articulation_nodes)
+ for sub_articulation_nodes in network_data.rel_bb_articulation_nodes
+ ),
+ default=0,
+ )
+ n_max_bb_to_outage_per_sub = max(n_max_bb_slots_from_outages, n_max_bb_slots_from_articulation)
max_branches_per_sub = max(len(branches) for branches in network_data.branches_at_nodes)
# Initialize the padded array with a sentinel value (int_max)
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 5b1580a7d..89506dbd5 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
@@ -10,6 +10,7 @@
from dataclasses import dataclass
from pathlib import Path
+import networkx as nx
import numpy as np
import pypowsybl
from beartype.typing import NamedTuple, Optional, Sequence, Union
@@ -638,6 +639,84 @@ def find_station(stations: list[Station], grid_model_id: str, fallback: Optional
return [find_station(network_data.simplified_asset_topology.stations, node_id) for node_id in relevant_node_ids]
+def _get_station_articulation_busbar_ids(station: Station) -> set[str]:
+ """Return busbar ids that would split the station if they were outaged."""
+ busbar_intid_index_mapper = {busbar.int_id: index for index, busbar in enumerate(station.busbars)}
+ edges = [
+ (
+ busbar_intid_index_mapper[coupler.busbar_from_id],
+ busbar_intid_index_mapper[coupler.busbar_to_id],
+ )
+ for coupler in station.couplers
+ if (not coupler.open) and coupler.in_service
+ ]
+ if len(edges) <= 1:
+ return set()
+
+ graph = nx.Graph()
+ graph.add_nodes_from(range(len(station.busbars)))
+ graph.add_edges_from(edges)
+ return {station.busbars[node_index].grid_model_id for node_index in nx.articulation_points(graph)}
+
+
+def extract_busbar_outage_ids(network_data: NetworkData) -> list[str]:
+ """Extract busbar outage ids that remain after preprocessing-side filtering."""
+ if network_data.busbar_outage_map is None or network_data.asset_topology is None:
+ return []
+
+ relevant_station_ids = {
+ node_id
+ for node_id, is_relevant in zip(network_data.node_ids, network_data.relevant_node_mask, strict=True)
+ if is_relevant
+ }
+
+ busbar_outage_ids: list[str] = []
+ relevant_stations = get_relevant_stations(network_data)
+ for station_index, station in enumerate(relevant_stations):
+ configured_busbars = set(network_data.busbar_outage_map.get(station.grid_model_id, []))
+ always_articulation_ids: set[str] = set()
+ if network_data.rel_bb_articulation_nodes is not None and station_index < len(
+ network_data.rel_bb_articulation_nodes
+ ):
+ articulation_by_action = network_data.rel_bb_articulation_nodes[station_index]
+ if articulation_by_action:
+ always_articulation_indices = set(articulation_by_action[0])
+ for articulation_indices in articulation_by_action[1:]:
+ always_articulation_indices &= set(articulation_indices)
+ always_articulation_ids = {
+ station.busbars[busbar_index].grid_model_id
+ for busbar_index in always_articulation_indices
+ if busbar_index < len(station.busbars)
+ }
+
+ busbar_outage_ids.extend(
+ busbar.grid_model_id
+ for busbar in station.busbars
+ if busbar.grid_model_id in configured_busbars and busbar.grid_model_id not in always_articulation_ids
+ )
+
+ articulation_ids_by_station = {
+ station.grid_model_id: _get_station_articulation_busbar_ids(station)
+ for station in (
+ network_data.simplified_asset_topology.stations
+ if network_data.simplified_asset_topology is not None
+ else network_data.asset_topology.stations
+ )
+ }
+ for station in network_data.asset_topology.stations:
+ if station.grid_model_id in relevant_station_ids:
+ continue
+ configured_busbars = set(network_data.busbar_outage_map.get(station.grid_model_id, []))
+ articulation_ids = articulation_ids_by_station.get(station.grid_model_id, set())
+ busbar_outage_ids.extend(
+ busbar.grid_model_id
+ for busbar in station.busbars
+ if busbar.grid_model_id in configured_busbars and busbar.grid_model_id not in articulation_ids
+ )
+
+ return busbar_outage_ids
+
+
def map_branch_injection_ids(
network_data: NetworkData,
) -> tuple[list[list[str]], list[list[str]]]:
@@ -871,11 +950,34 @@ def extract_nminus1_definition(network_data: NetworkData) -> Nminus1Definition:
for index in network_data.rel_io_global_inj_index
]
+ busbar_contingencies: list[Contingency] = []
+ if network_data.asset_topology is not None:
+ busbar_lookup = {
+ busbar.grid_model_id: busbar for station in network_data.asset_topology.stations for busbar in station.busbars
+ }
+ busbar_contingencies = [
+ Contingency(
+ elements=[
+ GridElement(
+ id=busbar_id,
+ type=busbar_lookup[busbar_id].type,
+ name=busbar_lookup[busbar_id].name or "",
+ kind="bus",
+ )
+ ],
+ id=busbar_id,
+ name=busbar_lookup[busbar_id].name or "",
+ )
+ for busbar_id in extract_busbar_outage_ids(network_data)
+ if busbar_id in busbar_lookup
+ ]
+
return Nminus1Definition(
monitored_elements=monitored_branches + monitored_nodes + monitored_switches,
contingencies=basecase_contingency
+ branch_contingencies
+ multi_contingencies
+ nonrel_inj_contingencies
- + rel_inj_contingencies,
+ + rel_inj_contingencies
+ + busbar_contingencies,
)
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 321b964b7..cb51f37e3 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
@@ -114,7 +114,7 @@ def __init__(
self.ac_p_values = net.get_branches(attributes=["p1"])["p1"]
dc_results = pp.loadflow.run_dc(net, lf_params)
- self.slack_id = dc_results[0].reference_bus_id
+ self.slack_id = net.get_extension("slackTerminal").iloc[0].bus_id
self.net = net
self.net_pu = get_network_as_pu(net)
@@ -642,6 +642,43 @@ def get_asset_topology(self) -> Optional[Topology]:
)
return None
+ def get_busbar_outage_map(self) -> Optional[dict[str, Sequence[str]]]:
+ """Get busbar outages grouped by asset-topology station id."""
+ mask_path = self._get_masks_path() / NETWORK_MASK_NAMES["busbar_for_nminus1"]
+ if not self.data_folder_dirfs.exists(str(mask_path)):
+ return None
+
+ busbar_sections = self.net.get_busbar_sections(attributes=["bus_id"])
+ busbar_for_nminus1 = load_numpy_filesystem(filesystem=self.data_folder_dirfs, file_path=str(mask_path))
+ selected_busbars = busbar_sections[busbar_for_nminus1]
+ selected_busbars = selected_busbars[selected_busbars["bus_id"].isin(self.get_node_ids())]
+
+ asset_topology = self.get_asset_topology()
+ busbar_to_station_id = {}
+ bus_id_to_station_id = {}
+ if asset_topology is not None:
+ busbar_to_station_id = {
+ busbar.grid_model_id: station.grid_model_id
+ for station in asset_topology.stations
+ for busbar in station.busbars
+ }
+ bus_id_to_station_id = {
+ busbar.bus_branch_bus_id: station.grid_model_id
+ for station in asset_topology.stations
+ for busbar in station.busbars
+ }
+
+ outage_map: dict[str, list[str]] = {}
+ for busbar_id, busbar in selected_busbars.iterrows():
+ station_id = busbar_to_station_id.get(str(busbar_id))
+ if station_id is None:
+ station_id = bus_id_to_station_id.get(str(busbar["bus_id"]))
+ if station_id is None:
+ continue
+ outage_map.setdefault(station_id, []).append(str(busbar_id))
+
+ return outage_map
+
def get_metadata(self) -> dict:
"""Get the path to the data_folder, masks_folder and the start datetime of the case"""
return {
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 a1e6df3c3..16bee0db0 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
@@ -83,6 +83,15 @@
logger = structlog.get_logger(__name__)
+def disable_busbar_outage_contingencies(network_data: NetworkData) -> NetworkData:
+ """Clear busbar-outage configuration from network data.
+
+ The importer may provide a busbar outage map unconditionally, but when preprocessing-side
+ busbar outages are disabled we must not reconstruct bus contingencies from it later on.
+ """
+ return replace(network_data, busbar_outage_map=None)
+
+
def compute_ptdf_if_not_given(network_data: NetworkData) -> NetworkData:
"""Compute the PTDF if not given.
@@ -1476,6 +1485,7 @@ def preprocess( # noqa: PLR0915
network_data = preprocess_bb_outages(network_data)
else:
logging_fn("preprocess_bb_outage", "BB-Outages disabled, skipping preprocessing step")
+ network_data = disable_busbar_outage_contingencies(network_data)
logging_fn("preprocess_done", None)
return network_data
diff --git a/packages/dc_solver_pkg/tests/conftest.py b/packages/dc_solver_pkg/tests/conftest.py
index f2772b5be..20323763a 100644
--- a/packages/dc_solver_pkg/tests/conftest.py
+++ b/packages/dc_solver_pkg/tests/conftest.py
@@ -735,6 +735,9 @@ def _test_grid_folder_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a temporary folder with test grid node breaker data."""
tmp_path = tmp_path_factory.mktemp("test_grid_node_breaker")
node_breaker_folder_powsybl(tmp_path)
+ save_lf_params_to_fs(
+ SINGLE_SLACK, DirFileSystem(str(tmp_path)), Path(PREPROCESSING_PATHS["loadflow_parameters_file_path"])
+ )
return tmp_path
@@ -745,25 +748,14 @@ def test_grid_folder_path(_test_grid_folder_path: Path, tmp_path: Path) -> Path:
@pytest.fixture(scope="session")
-def network_data_test_grid(_test_grid_folder_path: Path, outage_map_test_grid: dict) -> NetworkData:
- class TestBackend(PowsyblBackend):
- def get_busbar_outage_map(self):
- return outage_map_test_grid
+def network_data_test_grid(_test_grid_folder_path: Path) -> NetworkData:
fs_dir = DirFileSystem(str(_test_grid_folder_path))
- backend = TestBackend(fs_dir, lf_params=SINGLE_SLACK)
+ backend = PowsyblBackend(fs_dir, lf_params=SINGLE_SLACK)
network_data = preprocess(backend, parameters=PreprocessParameters(preprocess_bb_outages=True))
return network_data
-@pytest.fixture(scope="session")
-def outage_map_test_grid():
- return {
- "VL2_0": ["BBS2_1", "BBS2_2", "BBS2_3"],
- "VL3_0": ["BBS3_1", "BBS3_2"],
- }
-
-
@pytest.fixture(scope="session")
def jax_inputs_test_grid(
network_data_test_grid: NetworkData,
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..9b21c9e80 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,805 @@
-{"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,
+ "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": "VL2_0"
+ },
+ {
+ "grid_model_id": "BBS2_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL2_0"
+ },
+ {
+ "grid_model_id": "BBS2_3",
+ "type": "busbar",
+ "name": "bus3",
+ "int_id": 2,
+ "in_service": true,
+ "bus_branch_bus_id": "VL2_0"
+ }
+ ],
+ "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_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": "to",
+ "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": "from",
+ "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": "from",
+ "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": "from",
+ "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,
+ "bus_branch_bus_id": "VL3_0"
+ },
+ {
+ "grid_model_id": "BBS3_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL3_0"
+ }
+ ],
+ "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
+ }
+ ],
+ "assets": [
+ {
+ "grid_model_id": "L3",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "to",
+ "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": "to",
+ "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": "to",
+ "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": "from",
+ "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
+ ]
+ ],
+ "asset_connectivity": [
+ [
+ true,
+ true,
+ true,
+ true,
+ true
+ ],
+ [
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ ],
+ "model_log": []
+ },
+ {
+ "grid_model_id": "VL4_0",
+ "name": "VLevel4",
+ "type": null,
+ "region": "BE",
+ "voltage_level": 225.0,
+ "busbars": [
+ {
+ "grid_model_id": "BBS4_1",
+ "type": "busbar",
+ "name": "bus1",
+ "int_id": 0,
+ "in_service": true,
+ "bus_branch_bus_id": "VL4_0"
+ },
+ {
+ "grid_model_id": "BBS4_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL4_0"
+ }
+ ],
+ "couplers": [
+ {
+ "grid_model_id": "VL4_BREAKER",
+ "type": "BREAKER",
+ "name": "VL4_BREAKER",
+ "busbar_from_id": 0,
+ "busbar_to_id": 1,
+ "open": false,
+ "in_service": true,
+ "asset_bay": null
+ }
+ ],
+ "assets": [
+ {
+ "grid_model_id": "L4",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "to",
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "L42_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS4_1": "L42_DISCONNECTOR_3_0",
+ "BBS4_2": "L42_DISCONNECTOR_3_1"
+ }
+ }
+ },
+ {
+ "grid_model_id": "L5",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "to",
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "L52_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS4_1": "L52_DISCONNECTOR_5_0",
+ "BBS4_2": "L52_DISCONNECTOR_5_1"
+ }
+ }
+ },
+ {
+ "grid_model_id": "L8",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "to",
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "L82_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS4_1": "L82_DISCONNECTOR_7_0",
+ "BBS4_2": "L82_DISCONNECTOR_7_1"
+ }
+ }
+ }
+ ],
+ "asset_switching_table": [
+ [
+ true,
+ false,
+ true
+ ],
+ [
+ false,
+ true,
+ false
+ ]
+ ],
+ "asset_connectivity": [
+ [
+ true,
+ true,
+ true
+ ],
+ [
+ true,
+ true,
+ true
+ ]
+ ],
+ "model_log": []
+ },
+ {
+ "grid_model_id": "VL5_0",
+ "name": "VLevel5",
+ "type": null,
+ "region": "BE",
+ "voltage_level": 225.0,
+ "busbars": [
+ {
+ "grid_model_id": "BBS5_1",
+ "type": "busbar",
+ "name": "bus1",
+ "int_id": 0,
+ "in_service": true,
+ "bus_branch_bus_id": "VL5_0"
+ }
+ ],
+ "couplers": [],
+ "assets": [
+ {
+ "grid_model_id": "L9",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "to",
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "L92_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS5_1": "L92_DISCONNECTOR_2_0"
+ }
+ }
+ },
+ {
+ "grid_model_id": "generator3",
+ "type": "GENERATOR",
+ "name": "",
+ "in_service": true,
+ "branch_end": null,
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "generator3_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS5_1": "generator3_DISCONNECTOR_4_0"
+ }
+ }
+ }
+ ],
+ "asset_switching_table": [
+ [
+ true,
+ true
+ ]
+ ],
+ "asset_connectivity": [
+ [
+ true,
+ true
+ ]
+ ],
+ "model_log": []
+ }
+ ],
+ "asset_setpoints": null,
+ "timestamp": "2026-07-02T10:35:24.879394",
+ "metrics": null
+ },
+ "simplified_starting_topology": {
+ "topology_id": "grid.xiidm",
+ "grid_model_file": "grid.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,
+ "bus_branch_bus_id": "VL2_0"
+ },
+ {
+ "grid_model_id": "BBS2_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL2_0"
+ },
+ {
+ "grid_model_id": "BBS2_3",
+ "type": "busbar",
+ "name": "bus3",
+ "int_id": 2,
+ "in_service": true,
+ "bus_branch_bus_id": "VL2_0"
+ }
+ ],
+ "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": "L6",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "from",
+ "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": "from",
+ "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": "from",
+ "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": "L1",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": "to",
+ "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": "to",
+ "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": "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,
+ true,
+ false,
+ true
+ ],
+ [
+ false,
+ true,
+ false,
+ false,
+ true,
+ 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_setpoints": null,
+ "timestamp": "2026-07-02T10:35:24.879394",
+ "metrics": null
+ },
+ "connectable_branches": [],
+ "disconnectable_branches": [
+ {
+ "id": "L1",
+ "name": "VL1_3 ## VL2_3 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L2",
+ "name": "VL1_5 ## VL2_5 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L3",
+ "name": "VL1_7 ## VL3_2 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L4",
+ "name": "VL1_9 ## VL4_2 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L5",
+ "name": "VL1_11 ## VL4_4 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L6",
+ "name": "VL2_7 ## VL3_4 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L7",
+ "name": "VL2_9 ## VL3_6 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ },
+ {
+ "id": "L8",
+ "name": "VL2_11 ## VL4_6 ## ",
+ "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 0000000000000000000000000000000000000000..e47fda4b2473c99090e4650927078b891bad80b3
GIT binary patch
literal 7120
zcmeHMy-or_5Z=8Lb0Mf0e?bdNOJikA1f(Eh01X`?5+N~Q0HcqfZ(wO@Y2gD{T3TBA
z7Ws-aN^cjpjKlSq;)y@nd8$``U598VTFt6OQ{An0saiO(b}$red1oQ
za<##s@&4Eb40cEF1Ymo+nY5jwgZ9S3dOjzNp}ozam0lPS2801&Ko}4PgaKhd7!U@8
z0bxKG5C+DF0c{il_?w?~Gu9!V7w`=4OK5-7%^oQM=NTUcWT&HGz^|Kaa#GS$*)2CN
zFU~8?V&kGx2ds5fYnQ9F`dP7EIytX&^eWKShH1I|OdKj0RWGA$QQfkR<*(yma!z85
zRsrOmh|>yhP&){qaIX~0pnl&q@%B4n0>FmNh2Tn0C1dHF5#3cjdkDr(1k8=WyyM^l
zJo*B;`XgdvjM~}Xs{QUidstUar>^c-20xI$d!gvyxXGKvUzq;hUu!#-7VToiaBY!H
zt)_{zpYYMu-a7sF>B)CZ1)o1!*KmXM&>Lf35K!+e8QgIM!hn9WiZR@tP0{V_5=aWk
UAprswfrZ`7-H1+L&kk|v7u(op?EnA(
literal 0
HcmV?d00001
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..b5565c5f5 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,246 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..3d9d2fde4 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,215 @@
-{
- "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"
+ ],
+ "trafo_for_nminus1": [],
+ "tie_line_for_nminus1": [],
+ "boundary_line_for_nminus1": [],
+ "generator_for_nminus1": [
+ "generator1",
+ "generator2",
+ "generator3"
+ ],
+ "load_for_nminus1": [
+ "load1",
+ "load2"
+ ],
+ "switch_for_nminus1": [
+ "L11_BREAKER",
+ "L11_DISCONNECTOR_4_0",
+ "L11_DISCONNECTOR_4_1",
+ "L11_DISCONNECTOR_4_2",
+ "L21_BREAKER",
+ "L21_DISCONNECTOR_6_0",
+ "L21_DISCONNECTOR_6_1",
+ "L21_DISCONNECTOR_6_2",
+ "L31_BREAKER",
+ "L31_DISCONNECTOR_8_0",
+ "L31_DISCONNECTOR_8_1",
+ "L31_DISCONNECTOR_8_2",
+ "L41_BREAKER",
+ "L41_DISCONNECTOR_10_0",
+ "L41_DISCONNECTOR_10_1",
+ "L41_DISCONNECTOR_10_2",
+ "L51_BREAKER",
+ "L51_DISCONNECTOR_12_0",
+ "L51_DISCONNECTOR_12_1",
+ "L51_DISCONNECTOR_12_2",
+ "VL1_BREAKER",
+ "VL1_DISCONNECTOR_13_0",
+ "VL1_DISCONNECTOR_13_1",
+ "VL1_DISCONNECTOR_14_1",
+ "VL1_DISCONNECTOR_14_2",
+ "VL1_BREAKER#0",
+ "VL1_DISCONNECTOR_15_0",
+ "VL1_DISCONNECTOR_15_1",
+ "VL1_DISCONNECTOR_16_1",
+ "VL1_DISCONNECTOR_16_2",
+ "generator1_BREAKER",
+ "generator1_DISCONNECTOR_18_0",
+ "generator1_DISCONNECTOR_18_1",
+ "generator1_DISCONNECTOR_18_2",
+ "generator2_BREAKER",
+ "generator2_DISCONNECTOR_20_0",
+ "generator2_DISCONNECTOR_20_1",
+ "generator2_DISCONNECTOR_20_2",
+ "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_3_0",
+ "L32_DISCONNECTOR_3_1",
+ "L62_BREAKER",
+ "L62_DISCONNECTOR_5_0",
+ "L62_DISCONNECTOR_5_1",
+ "L72_BREAKER",
+ "L72_DISCONNECTOR_7_0",
+ "L72_DISCONNECTOR_7_1",
+ "L91_BREAKER",
+ "L91_DISCONNECTOR_9_0",
+ "L91_DISCONNECTOR_9_1",
+ "VL3_BREAKER",
+ "VL3_DISCONNECTOR_10_0",
+ "VL3_DISCONNECTOR_11_1",
+ "load2_BREAKER",
+ "load2_DISCONNECTOR_13_0",
+ "load2_DISCONNECTOR_13_1",
+ "L42_BREAKER",
+ "L42_DISCONNECTOR_3_0",
+ "L42_DISCONNECTOR_3_1",
+ "L52_BREAKER",
+ "L52_DISCONNECTOR_5_0",
+ "L52_DISCONNECTOR_5_1",
+ "L82_BREAKER",
+ "L82_DISCONNECTOR_7_0",
+ "L82_DISCONNECTOR_7_1",
+ "VL4_BREAKER",
+ "VL4_DISCONNECTOR_8_0",
+ "VL4_DISCONNECTOR_9_1",
+ "L92_BREAKER",
+ "L92_DISCONNECTOR_2_0",
+ "generator3_BREAKER",
+ "generator3_DISCONNECTOR_4_0"
+ ],
+ "line_disconnectable": [
+ "L1",
+ "L2",
+ "L3",
+ "L4",
+ "L5",
+ "L6",
+ "L7",
+ "L8",
+ "L9"
+ ],
+ "trafo_disconnectable": []
+ },
+ "import_result": {
+ "data_folder": "../data/grid_node_breaker/grid",
+ "n_relevant_subs": 2,
+ "n_low_impedance_lines": 0,
+ "n_branch_across_switch": 0,
+ "n_line_for_nminus1": 9,
+ "n_line_for_reward": 3,
+ "n_line_disconnectable": 9,
+ "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": 2,
+ "n_switch_for_nminus1": 106,
+ "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/grid_node_breaker/grid",
+ "grid_model_file": "../data/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
+ },
+ "loadflow_parameters_file": null
+ }
+}
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..d0566e0fd 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,533 @@
-{
- "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,
+ "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": "VL2_0"
+ },
+ {
+ "grid_model_id": "BBS2_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL2_0"
+ },
+ {
+ "grid_model_id": "BBS2_3",
+ "type": "busbar",
+ "name": "bus3",
+ "int_id": 2,
+ "in_service": true,
+ "bus_branch_bus_id": "VL2_0"
+ }
+ ],
+ "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,
+ "bus_branch_bus_id": "VL3_0"
+ },
+ {
+ "grid_model_id": "BBS3_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL3_0"
+ }
+ ],
+ "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
+ }
+ ],
+ "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
+ ]
+ ],
+ "asset_connectivity": [
+ [
+ true,
+ true,
+ true,
+ true,
+ true
+ ],
+ [
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ ],
+ "model_log": []
+ },
+ {
+ "grid_model_id": "VL4_0",
+ "name": "VLevel4",
+ "type": null,
+ "region": "BE",
+ "voltage_level": 225.0,
+ "busbars": [
+ {
+ "grid_model_id": "BBS4_1",
+ "type": "busbar",
+ "name": "bus1",
+ "int_id": 0,
+ "in_service": true,
+ "bus_branch_bus_id": "VL4_0"
+ },
+ {
+ "grid_model_id": "BBS4_2",
+ "type": "busbar",
+ "name": "bus2",
+ "int_id": 1,
+ "in_service": true,
+ "bus_branch_bus_id": "VL4_0"
+ }
+ ],
+ "couplers": [
+ {
+ "grid_model_id": "VL4_BREAKER",
+ "type": "BREAKER",
+ "name": "VL4_BREAKER",
+ "busbar_from_id": 0,
+ "busbar_to_id": 1,
+ "open": false,
+ "in_service": true,
+ "asset_bay": null
+ }
+ ],
+ "assets": [
+ {
+ "grid_model_id": "L4",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": null,
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "L42_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS4_1": "L42_DISCONNECTOR_3_0",
+ "BBS4_2": "L42_DISCONNECTOR_3_1"
+ }
+ }
+ },
+ {
+ "grid_model_id": "L5",
+ "type": "LINE",
+ "name": "",
+ "in_service": true,
+ "branch_end": null,
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "L52_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS4_1": "L52_DISCONNECTOR_5_0",
+ "BBS4_2": "L52_DISCONNECTOR_5_1"
+ }
+ }
+ },
+ {
+ "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": "L82_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS4_1": "L82_DISCONNECTOR_7_0",
+ "BBS4_2": "L82_DISCONNECTOR_7_1"
+ }
+ }
+ }
+ ],
+ "asset_switching_table": [
+ [
+ true,
+ false,
+ true
+ ],
+ [
+ false,
+ true,
+ false
+ ]
+ ],
+ "asset_connectivity": [
+ [
+ true,
+ true,
+ true
+ ],
+ [
+ true,
+ true,
+ true
+ ]
+ ],
+ "model_log": []
+ },
+ {
+ "grid_model_id": "VL5_0",
+ "name": "VLevel5",
+ "type": null,
+ "region": "BE",
+ "voltage_level": 225.0,
+ "busbars": [
+ {
+ "grid_model_id": "BBS5_1",
+ "type": "busbar",
+ "name": "bus1",
+ "int_id": 0,
+ "in_service": true,
+ "bus_branch_bus_id": "VL5_0"
+ }
+ ],
+ "couplers": [],
+ "assets": [
+ {
+ "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": "L92_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS5_1": "L92_DISCONNECTOR_2_0"
+ }
+ }
+ },
+ {
+ "grid_model_id": "generator3",
+ "type": "GENERATOR",
+ "name": "",
+ "in_service": true,
+ "branch_end": null,
+ "asset_bay": {
+ "sl_switch_grid_model_id": null,
+ "dv_switch_grid_model_id": "generator3_BREAKER",
+ "sr_switch_grid_model_id": {
+ "BBS5_1": "generator3_DISCONNECTOR_4_0"
+ }
+ }
+ }
+ ],
+ "asset_switching_table": [
+ [
+ true,
+ true
+ ]
+ ],
+ "asset_connectivity": [
+ [
+ true,
+ true
+ ]
+ ],
+ "model_log": []
+ }
+ ],
+ "asset_setpoints": null,
+ "timestamp": "2026-07-02T10:35:24.879394",
+ "metrics": null
+}
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm
new file mode 100644
index 000000000..0b6d2fadb
--- /dev/null
+++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/original_gridfile/grid.xiidm
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/test_ucte_powsybl_example.xiidm
deleted file mode 100644
index 6e7c7ea43..000000000
--- a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/initial_topology/test_ucte_powsybl_example.xiidm
+++ /dev/null
@@ -1,359 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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..4da43f330
--- /dev/null
+++ b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/loadflow_parameters.json
@@ -0,0 +1 @@
+{"version": "1.10", "voltageInitMode": "PREVIOUS_VALUES", "transformerVoltageControlOn": true, "phaseShifterRegulationOn": false, "useReactiveLimits": true, "twtSplitShuntAdmittance": true, "shuntCompensatorVoltageControlOn": true, "readSlackBus": true, "writeSlackBus": true, "dc": false, "distributedSlack": true, "balanceType": "PROPORTIONAL_TO_GENERATION_P_MAX", "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": "FAIL", "voltageRemoteControl": true, "voltageRemoteControlRobustMode": true, "lowImpedanceBranchMode": "REPLACE_BY_ZERO_IMPEDANCE_LINE", "loadPowerFactorConstant": false, "plausibleActivePowerLimit": 300.0, "newtonRaphsonStoppingCriteriaType": "PER_EQUATION_TYPE_CRITERIA", "maxActivePowerMismatch": 0.0001, "maxReactivePowerMismatch": 1e-05, "maxVoltageMismatch": 0.0001, "maxAngleMismatch": 1e-05, "maxRatioMismatch": 1e-05, "maxSusceptanceMismatch": 0.0001, "slackBusPMaxMismatch": 1.0, "voltagePerReactivePowerControl": false, "generatorReactivePowerRemoteControl": true, "transformerReactivePowerControl": false, "maxNewtonRaphsonIterations": 25, "maxOuterLoopIterations": 20, "newtonRaphsonConvEpsPerEq": 0.0001, "voltageInitModeOverride": "VOLTAGE_MAGNITUDE", "transformerVoltageControlMode": "INCREMENTAL_VOLTAGE_CONTROL", "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", "minPlausibleTargetVoltage": 0.893, "maxPlausibleTargetVoltage": 1.105, "minNominalVoltageTargetVoltageCheck": 20.0, "minRealisticVoltage": 0.5, "maxRealisticVoltage": 2.0, "minNominalVoltageRealisticVoltageCheck": 0.0, "lowImpedanceThreshold": 1e-08, "reactiveRangeCheckMode": "MAX", "networkCacheEnabled": false, "svcVoltageMonitoring": false, "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": false, "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": "GENERATOR_REFERENCE_PRIORITY", "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": {"voltageInitModeOverride": "VOLTAGE_MAGNITUDE", "reactiveLimitsMaxPpvSwitch": "0", "svcVoltageMonitoring": "false", "maxActivePowerMismatch": "0.0001", "maxReactivePowerMismatch": "0.00001", "maxNewtonRaphsonIterations": "25", "newtonRaphsonStoppingCriteriaType": "PER_EQUATION_TYPE_CRITERIA", "generatorReactivePowerRemoteControl": "true", "plausibleActivePowerLimit": "300", "useActiveLimits": "false", "minPlausibleTargetVoltage": "0.893", "maxPlausibleTargetVoltage": "1.105", "referenceBusSelectionMode": "GENERATOR_REFERENCE_PRIORITY"}}
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 0000000000000000000000000000000000000000..47949b0e2ee74c25be14c6c8441b3b10ebb6e4e8
GIT binary patch
literal 139
zcmbR27wQ`j$;eQ~P_3SlTAW;@Zl$1JlVqr_qoAIaUsO_*m=~X4l#&V(cT3DEP6dh=
cXCxM+0{I$-hB}%$3bhL411<&z21Y0V0G>P>ssI20
literal 0
HcmV?d00001
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/dangling_line_for_nminus1.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/dangling_line_for_nminus1.npy
new file mode 100644
index 0000000000000000000000000000000000000000..29df92a583993bf07c7775466db1004e423ff51e
GIT binary patch
literal 128
zcmbR27wQ`j$;eQ~P_3SlTAW;@Zl$1JlVqr_qoAIaUsO_*m=~X4l#&V(cT3DEP6dh=
VXCxM+0{I#SI+{8PwF(pfE&yrl8;}41
literal 0
HcmV?d00001
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_blacklisted.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_blacklisted.npy
index 4800741fec989b78a7f2eefbd3ed22b5c0cbac1c..9dcf3d787eff9d64c81dd2f8a1ed9c826b4e94c2 100644
GIT binary patch
delta 14
VcmeBT>|~r^%V;^#-eF=LHvk{i1V;b>
delta 17
YcmeBV>|&f?%VcOU(avFFJr^Sb04wDLZ~y=R
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_disconnectable.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_disconnectable.npy
index 10462100830c0318b7506603c994e427c5d29ba9..19d80a1c4c8f73d77f4cc83aebc66f60bfc2df8c 100644
GIT binary patch
delta 14
VcmeBT>|~r^%V;^#-eF=LHvk{i1V;b>
delta 17
YcmeBV>|&f?%VcOU(avFFJr^S*04wGMaR2}S
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_for_nminus1.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_for_nminus1.npy
index 10462100830c0318b7506603c994e427c5d29ba9..19d80a1c4c8f73d77f4cc83aebc66f60bfc2df8c 100644
GIT binary patch
delta 14
VcmeBT>|~r^%V;^#-eF=LHvk{i1V;b>
delta 17
YcmeBV>|&f?%VcOU(avFFJr^S*04wGMaR2}S
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_for_reward.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_for_reward.npy
index 9c42e17cf0fbf6df6f9d329db79cf518613b31e1..c4f97422baf31a8c38625d9c2c811bbfcd875b98 100644
GIT binary patch
delta 14
VcmeBT>|~r^%V;^#-eF=LHvk{i1V;b>
delta 17
YcmeBV>|&f?%VcOU(avFFJr^Sb04wDLZ~y=R
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_overload_weight.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_overload_weight.npy
index 817393592de607803f679433b801b8a33b1e2e07..b00550a511a41ce9d248b4fd8be3820929fe0223 100644
GIT binary patch
delta 14
Vcmcb>c!F_)Eu-Z`dxwd2UH~TC1q%QG
delta 18
acmX@Xc!6<(Et8?aL_3Fx^_~+aZ~y>2_Xb=5
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_tso_border.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/line_tso_border.npy
index 4800741fec989b78a7f2eefbd3ed22b5c0cbac1c..9dcf3d787eff9d64c81dd2f8a1ed9c826b4e94c2 100644
GIT binary patch
delta 14
VcmeBT>|~r^%V;^#-eF=LHvk{i1V;b>
delta 17
YcmeBV>|&f?%VcOU(avFFJr^Sb04wDLZ~y=R
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/load_for_nminus1.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/load_for_nminus1.npy
index 88abb5d6d5314a954c992c65d8b83f855ee878c7..2c3097de45724bcded0aa104020ee506629dcebd 100644
GIT binary patch
delta 11
ScmZo>Y+{^X%V;#w&IJGx?gJYD
delta 13
UcmZo-Y-XHb%V<2&&V`W?02wO-9smFU
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/pst_group_labels.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/pst_group_labels.npy
new file mode 100644
index 0000000000000000000000000000000000000000..d62c53132cce0ff76d62b7b8fb94a031560fea8c
GIT binary patch
literal 128
zcmbR27wQ`j$;eQ~P_3SlTAW;@Zl$1ZlWC!@qoAIaUsO_*m=~X4l#&V(cT3DEP6dh=
VXCxM+0{I#SI+{8PwF(pfE&x>V8(sha
literal 0
HcmV?d00001
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/relevant_subs.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/relevant_subs.npy
index b6117875627661c52cc2b3936d5986856c63ea69..b67688e7c4424c4e5f4c2675ddaf71db383189cc 100644
GIT binary patch
delta 11
ScmZo;Y-OBa%V;{$&K&>~IRhsE
delta 13
UcmZo=Y-5~Y%V;*y&Yh6~02z$~DF6Tf
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/switch_for_nminus1.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/switch_for_nminus1.npy
index 6cc46a518b1ea6f63dfdf0fe5cd4d09a9c229b31..0a993a88185f9b01e8903333c1141ae72752aad2 100644
GIT binary patch
delta 12
TcmbQs^ontU9g~6CM2A%X8;%4#
delta 15
XcmaFGIG1UH9h0f~M2A%qCl~_&EDr^i
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/switch_for_reward.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/switch_for_reward.npy
index 97ab097a1ef990d3ba02dca8bda66a6b0bd2e9d9..271af5963fd52a965d9f79e43132c93781b75900 100644
GIT binary patch
delta 12
TcmbQs^ontU9g~6CM2A%X8;%4#
delta 15
XcmaFGIG1UH9h0f~M2A%qCl~_&EDr^i
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/trafo_has_pst_tap.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/trafo_has_pst_tap.npy
new file mode 100644
index 0000000000000000000000000000000000000000..29df92a583993bf07c7775466db1004e423ff51e
GIT binary patch
literal 128
zcmbR27wQ`j$;eQ~P_3SlTAW;@Zl$1JlVqr_qoAIaUsO_*m=~X4l#&V(cT3DEP6dh=
VXCxM+0{I#SI+{8PwF(pfE&yrl8;}41
literal 0
HcmV?d00001
diff --git a/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/trafo_pst_linear.npy b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/masks/trafo_pst_linear.npy
new file mode 100644
index 0000000000000000000000000000000000000000..29df92a583993bf07c7775466db1004e423ff51e
GIT binary patch
literal 128
zcmbR27wQ`j$;eQ~P_3SlTAW;@Zl$1JlVqr_qoAIaUsO_*m=~X4l#&V(cT3DEP6dh=
VXCxM+0{I#SI+{8PwF(pfE&yrl8;}41
literal 0
HcmV?d00001
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 ffc2654084a81bdf0af4f96ec151aebcc8f49260..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 17463
zcmeGkS!^Unb;jrT+AH2G0qsWN!vWSiV|%^c5NUUPXj$ycu6C11`>`tZ%(Ok-+cQ1t
zZhLJGDF^~K6eJ=|I07gi5DxJX_(%u|vBMEgi3mdAgX;?=A4vI#lzc>j_g+G6UXxVFeF~=NzR?B*}
zGYH++ZRZ-xX`bfup0ub=V(k(C&X4;a^9Oia$;-|9e1CDiP+A(y+KxV0n72Gba~y5S
z=e>X}YZqP5na_Fv#oKA1&pV4bG2{_{I`*JH5zqLa_AmJZ)A8xVbmCKfzKgf#yrq(k
zKrORl{>rlGZ{_j3{d7A0q5r^-is_i=XI92zEn;R^Ua&v>$H)Hu@@mhOUyUF5;Y+Kl
zuiXPJ&jxkXt{~L->be@YS@>R}yS6&^6C%8--;S7`2)eEtLtj~X5q!WpFAz#Uy*blt*XBg&{yLXeO2EoyqN!QCA}9<3w$q0
zK3+U6=KtbpMNf_QK@&hFT~Ntz>+&06+#Hv%zeyx{B1C;!>`
z%cYgGHBUtLy7AVT^`1m-PdPbOWT|(6qz(4Nvb7w$t{ZcCFKU(v5Ff~%<^Z#iOSkza
z&GA4n$%-jhsYRAEW@ni&SQ{WKHTLyQKZ$IA*0EieW$pQrr8~?r3P$8y<6^D}Trd0y
zHE`$MtX}f8Vpd<5(~IZN5M=N?RaLL<59>s%d)BcFq|Wt7fhrqiRyBVh!ltU#B32p}
zQs0%{2Tx)q;n@vYVW1Y(2$V5zm1NaZwX3IUmmDT^y)@Y~%qd;dX~iB2d&mLoPwNkG9FhS4yswx4m2`
z`oKj5D5J2&r`v>|I7(n^nKc1^By#<|4fP{
zAtt+jW=KAV<#R+nPsryeK6@`sP2M*#HObN!Ca3)TvpVmV3ZgK!%%mV%n;F7l7>f}s
zPGB*L#Yrs2e4Y0NGi3_4drmLvjt065knQMA02e(09X&?@a8Jc*30U4u(<*8OSOuI|
z*Znj4f^N~|Y%K!(xa?T;SeJIDFqj%hK
z6a_hqFFvQn5!HC2tZ`H{4m0YNBgaJJh-$nAai)^0m0)P-5Id>h%NtLrMndFsfFEM1
zl!6znIxkn+KwL3D|0@2|`MwJ2su>1T#&}#vxNYWqddlaUeSpT$QiX_G$M3uB=idze
zt?_nfVdEg^GG4>+UFW9KQrr&0cb$x08tv02;7
z=?w&NMaIlxi0Jw0RF*I<0h;2>~EWO-sJIW&??r9i2>ORjfJUqld}ANF+D
zV~`br1Aw@2DPu8xQ7b@T0lOC_2T`MY%~i~rZD2bF%yuZv4lFvcNMO;0MK=~bSoC7i
z2L*2@3=zAH60m`aW&lgalblXoRy
zyYB^O;5kNS-UDkcg5~9T-k*W#&%u<}F|ru%)i}Q0u=<)6nz@Fdsjyk9thREpy~YZv
zh*CyfdoVv`h6}=lgg2+>{AIpZ>|otxnh5z(r)%?tc455-l$Gv~BsL@(Hr0A6i|F
z?~qXF%qQF03wYkWM@JFTtorDYTR+a8`o&61PeOUl?72S4k+o$l4z$H>it^C#Dco
zGJX&;^KfN0#6dX%z84M+j0L?6MdZwpILur`3mU)8Em?-gD(sG09)RbEBKlD-xMX}!
z(Jn&n#tfUmZiEmhN?Oyc!fPI)MOw?8p=a#MntGD_c7>?wh`m*a{g86zkxu;95Y3!r
zXN0bUNOJmO1aAtZ;3O8DU{nOP-ExNRd?o^gT1$mOD1!vC?Cd%mM1mib;2W5Mg0Iko
zU7G8{$&zQ6Y|Ea5Q=s?OoHZFmPj?_|)7`=9!HIhnTyV&gV36izJ;vzBsYfA((81?N
z>e|u4MZ?Gye7<`Q4R(PH_N)Q+mCqdyt^4tHZToRFdu0Z)qU_1`xq-N<={xg$fD(SG+4VdKUmf}Yv@+a1sGh`
zhx`E|V}1u{5ef6#_=b!;%=^p0N{+DrP7Kk_XUxZ;zfySRyblT9&2K5B9QlS;#62A+
zV$1X9ccGRxzXxSQHq7tixOIsRM-qirBHFS=qB>8=Q-`wk>l5oYA&w*JEMpG_g5#H_
z&Q4EVl55!+R8gbMH7NKlvA&GX4aAc6$|lI(u_4ae?2=yeSHu)}m)HjxIOa`93h@_+
zoEG$F_(rsTif=#1f|fRr>2uH&wUB*f+~1LuqJ1Gi_ZxgeoUocmWV(frOBPM!;D&kA
zc?Zq3Oz^ueUpKsL9#iDAXzJ>S9M#zFxhBOGZ#r#t_l=*w8
zMX3y?pTd~Dj6jG($(%5luV6DN=U-}3AbV-z4@5Tj`TA
z40KfpDwPZ*71^mo71_CivKreiS&h|~6?9d~3J-Y12HZar(HY7rqI1V&C6BqtT;<=c
zwN>ej*r`4`E332p4vNl7S%o{WPPdX)o$i}0FP-lf!G&;ic-TPa(GmGi>vRXCcaTu-
zi^HV@WYhRh{wNpPCosuVZDYakmKvj>8W
z;se-5UhEmHj$oBs+3%nlI6(6YP>XV95cuO+C1K#5BJh{-Wm*t;4ACOdAkHW1SHYt{
zUI!`0^}{Nz4`XBV2}DIfs)*XQIWit*$}I=sxcP&CnA1Bx43}X0z
zvxpczQ_b5=h~YQJ8(6(6xy=Q<$^4>q`{kyaXbf;^>qoRok8e1l9gc`-&%q=i^oOnr
zFM+oq=1jf5)DDIB=9{>IX_;Dd}o*N3LdS*^V1f8@+_G4FtAX0e4DE3HX=XQu9JQ3SO4lJ)tw;`^q
zrxg?#R_R45RE#>^+aZufhXT5ZQCRI&PTO?5h!XoLLX4G1iQ@i+Vzb78DkfCnr3Azn
zZ`(scxc!t7uh^Gs$?%*J~Q1W>qyvhq-AMq!;J6~(xv-@y&Z{gqmgLseF
z2{)jQkp=kbsFkB7C26jr8F>h+pTXisSbZ9bAVTGbT?4Kr4LG#TF~OKRg!jz{0j4S<
zAvB*wTtwt^_=c3yMCGbqFXA=7q(+)w#y2!=jU!$28#wM6EPjlI@}+NJr4=W#7mJs$
z`Y$YwK((Hl$Yf#}lL@IK!3$;qb(EwGOzBAQBrr+??gqluOF*W3>xqL#tCSWC_B_rL
zSMr7yTHcK^yv&2oZ}4z4>Ta}KZKxsI4I=4+y@fxRI|@(kG3rt+Bux~F#{0ty%XMkR
z#pdk>T=IVc$9v>mYluLeZeqrWx7YZsT{QpVGVdZW3kjE-7fI
zhYV3$?Zw5iNDt}Mr)C=y3Ocvm*rkd{EX-e{U5Ivz;o%ACG44+Ik?o>yR>gtE9}vOK
zGGYJ2+Oe&RQ7;4CjCO4AwHCR`4QdBkUC<6x5TPXG*628^sT~m>ce7jd|6DtiYLKr7
z9cgMu({`w1dErom*%12C)Q+Zh1ll12dU7jG&mvCco7xd-2iOgAE=|vZpE%)%Q%&s%
zv;&nxDu#$anx3Vp9ZEYWUqP3G|54G@j;3}f?I5orj0U|nbC#xO2~WewY^aa}eat(R
z(drufaT0x$&onrEEtTLP8^1e-^UkaObc+T>3<~^z$!O8T=SCX={b6|y41Wz~WalmV
VqXhZK+3A3`{3}{u0Kc*y{4ZR7-k<;g
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..9b21eda76 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,201 @@
-{
- "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_3 ## VL2_3 ## ",
+ "type": "LINE",
+ "kind": "branch",
+ "monitoring_scope": null
+ },
+ {
+ "id": "L2",
+ "name": "VL1_5 ## VL2_5 ## ",
+ "type": "LINE",
+ "kind": "branch",
+ "monitoring_scope": null
+ },
+ {
+ "id": "L3",
+ "name": "VL1_7 ## VL3_2 ## ",
+ "type": "LINE",
+ "kind": "branch",
+ "monitoring_scope": null
+ },
+ {
+ "id": "BBS2_1",
+ "name": "bus1",
+ "type": "busbar",
+ "kind": "bus",
+ "monitoring_scope": null
+ },
+ {
+ "id": "BBS2_2",
+ "name": "bus2",
+ "type": "busbar",
+ "kind": "bus",
+ "monitoring_scope": null
+ },
+ {
+ "id": "BBS2_3",
+ "name": "bus3",
+ "type": "busbar",
+ "kind": "bus",
+ "monitoring_scope": null
+ },
+ {
+ "id": "VL2_BREAKER",
+ "name": "VL2_BREAKER",
+ "type": "BREAKER",
+ "kind": "switch",
+ "monitoring_scope": null
+ },
+ {
+ "id": "VL2_BREAKER#0",
+ "name": "VL2_BREAKER#0",
+ "type": "BREAKER",
+ "kind": "switch",
+ "monitoring_scope": null
+ }
+ ],
+ "contingencies": [
+ {
+ "elements": [],
+ "id": "BASECASE",
+ "name": ""
+ },
+ {
+ "elements": [
+ {
+ "id": "L1",
+ "name": "VL1_3 ## VL2_3 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L1",
+ "name": "VL1_3 ## VL2_3 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L2",
+ "name": "VL1_5 ## VL2_5 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L2",
+ "name": "VL1_5 ## VL2_5 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L3",
+ "name": "VL1_7 ## VL3_2 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L3",
+ "name": "VL1_7 ## VL3_2 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L4",
+ "name": "VL1_9 ## VL4_2 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L4",
+ "name": "VL1_9 ## VL4_2 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L5",
+ "name": "VL1_11 ## VL4_4 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L5",
+ "name": "VL1_11 ## VL4_4 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L6",
+ "name": "VL2_7 ## VL3_4 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L6",
+ "name": "VL2_7 ## VL3_4 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L7",
+ "name": "VL2_9 ## VL3_6 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L7",
+ "name": "VL2_9 ## VL3_6 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "L8",
+ "name": "VL2_11 ## VL4_6 ## ",
+ "type": "LINE",
+ "kind": "branch"
+ }
+ ],
+ "id": "L8",
+ "name": "VL2_11 ## VL4_6 ## "
+ },
+ {
+ "elements": [
+ {
+ "id": "generator3",
+ "name": "",
+ "type": "GENERATOR",
+ "kind": "injection"
+ }
+ ],
+ "id": "generator3",
+ "name": ""
+ },
+ {
+ "elements": [
+ {
+ "id": "load2",
+ "name": "",
+ "type": "LOAD",
+ "kind": "injection"
+ }
+ ],
+ "id": "load2",
+ "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/static_information.hdf5 b/packages/dc_solver_pkg/tests/files/test_grid_node_breaker/static_information.hdf5
index df692622909982b3f7caa16c2868f465e5a85b9b..b8e8b3f75f64fa053a768583278277ec229ec527 100644
GIT binary patch
literal 20955
zcmeHPPi!1l8GjqcWNDpZw{@G8KixKMaVaqlall;gnkHdWQtJLevJcUCc6ZjEvOBZd
znN4g^B_B9A2c#f`RFDusRi$1k;82Miklh2KxwS$FBn0Gw)C(vVq#P*az4v{;nc3OQ
zu6N_Cccq;u^S$qT-@Ny|-}~PGx4xV^efID@kK7~FlTM3+Vq6FD`k0@;zDN@Ah+pAA
zis=^9w+>JsB_@T6JHqmDPJiVv3D#Nu%1dX?2ysXteNc`TGY2WO!|A^E1o2a>aQ!R=
z*4U8BQ^YTE{D=2b;1=_fpP@j8^+%uP2u`25A?XtYa%UD6l_{H}WKep9=o{Z6dbi#w
zF{=HSUP2P`xYPl1oKJGbhnU9s5$X9#b0RY@(po1gX73fVAG7_VQmWz)9H0c#+)_qT
z>s-=X?4g{2K#Kw2Tl(_1UpTEW`HZ*Jbm{8=kv??aV_{@JC^=@obT?t>zv=av3%c+rGGu(k?-y4&A?8?
zH}@w;bW|$U*q@*u8Ij|qiW!%k2l@~7L2|#bNopde(+yu&!r%ag&~&au0CHjSUhmp&ztv
zhw!hEb~`^BzmM>tb{@Em*b0Mv5W;%L`YaifWuV5yk`|=I{o*Ovvs;dtFIz@FZ@6{e
zT(vy5{fKOs>J!!f)2C^1cH#WXYWnlaOW&bckc{AD`K#{94{jX#!^Y)>Y@D|1U&H#O
zyt|+G&V7nf=Ny%?=#3J`bNqVny#5%XeB)wZ4@voyv8iORq
zx+bLU{1@XBX!qm8(j*~;mH^zg5a=t~4wvR}v(xon^&u*7d>rWSb`3wkx}zDVDP1h=
zvq3+ce^aT!citbq{xGFL`A}HBH^bmQhET3gunpK>gh{~T&T*U4bI0AzGn4ln-e(7d
z<*;-Ofu2ykkJ1cET4xT>I(L?Ta<@(CgeAEe1_vOt_qcrY2o-xUaY5s1#)W(tn{x0Q
z(9Zqm0x$pKIQ3^IO_QaxnBxLZKGxc~H(BpLJjG1XzRG$RzL2ER=uH`nj!EjA-sq>3
z6i9x9^?vfj)_Oav_u4b9^)jsY(W9;PSWRx>((G{bOQb)=qh7cPFsU~Ig4MradWKW6
zow|2IeX-ll8To;R=|8N~FO=s7<8`gWz`av+~}?Gu~jpxsX{-{Bg~A#0{vF}@Gj#dBG0#U0RQT<3I>sS)xZ
z_|a}@_qZQ^j1Z0Yk833E>3ccbjLQ_^!iT=V1Cz2i-#rdbK2AlYdI*cX9tQV4w45aq
zKw;4G4ux*cL(+_6R*oAc@R
z(C&Gj`NIQ%^!|=-u3CY$po8m3@e6CGzj-uzrsRD&SH+_VEU3pD~yhEX(Co>%(C97ij0>U4>U_aeunfXPA&V=pRwucKD#(fp(714|&BK
zcfkI{2bFgBae0$l(4gaT`+ghiVE7IA?$;&1o2I(Sp^BL9Z@*1Oe0>M>fBM$=N#>sj
z=zq2RSQeD@XR}0?vzR{}Um!`9hzRFTlQNtZPQ8-1YDUGC`x<7cWEFg?s8r*Z^ME^s
zhcUj4ml8)4(=i-lhO_M;y-Y)T=DNB)V%Sd1#3cJLPa6|+I%_p6ROQT@eqqh$+uwQO`AS+c6qGkv#qlH{m2P?e|33G?l!{L4~~X?Bp$ETx0q
zFOZ)iIr@u0{uPqLotXRi^U6G&_*T))ahrAZDiLre~(BvRR!tHC?nW&O9@F
z>T5H_*;8yM&gy~Jt7c=rQ-uO$JA4lj7$Pu4V2HpFfgu7z1cnF<5f~ycL|}-(5P>^F
z0Q*G0f0B|};nc$YhB18)rf7R|lObQ{?V-DN?*;2_Q0(s|?Q4XWH|f|f{IU7l-=L!W
zno9(-g+O0WwU2=P7`3+$wd``A-bD&>5S1p6uKJN3l*5&6dk)E?7r?|+5$8~SaLv);XpBMTiurvdR}zrL3FB_TA{Y3=2_SB+afKVKl4^nQyYa{M#0Bt87$nXQQ=jlqzIEvU7B
zK@YTZ|9biyAwqlgAv*o(U!i?_6KdCffh*|V^<%qdQJj2AHMe3oZqeeZ@m=EbHPb1qS)RPtEzivAd5#vr=}|1EB999z
zmyA-`y`)tmyiv9*HfFC;xjfMd;+z@7Ibo>SqFs`f734+lvdmh4>j@FqQ*j+z9$wK|
zsSv*F8=i0auuqpq35>k!mWA-jX5qUs(qXNdyh*O3D`r)F}^zT{Ina
z?#XA3GIRMzh|F?aDCL1&61b9S%N*&Ev-X1PIP$oXeu~d<%5)rMLXbNDLf~hn8zdnVL^NKO
zA&iWC=Rg|uok+YiRMmhwKP|Ymn&nkprzj236%f&bsVu6Po~+17e9baF&t7#ZmaHzd
zN(w4bmNTMChCWf~u4(ZzqCAU^ly=Bs>9_>P{P_Y_S2jc$+l$BUl
delta 2225
zcmbtVZ%k8H6uo^#JV@5lG)m3QgkQg)?rvOJX6l1cCx5ewP5qppsSjLA0&n=eSw7n=mpL&Z8(
zB-R$GU(6Q7UCC!U1>u(Xb)|w|NL&tmNZ};2s*W@vdbJ)3={|6$xmPIC`jHiyvx4eK
zx>Pe*Q~4VB3>g+9AxsGC0^G|=Xfr76#h^y2)l&0b4Qi%WD{OQNl4lGiT8o$oiD}=A
zte$O4E@wmIjj8vEIWWb$SMmt1_n>vFj*qT`qHI_`hkj}r?9P9>{n
zF>*3esJS}vfY;@EKpn61m*j@K(C&P4l&5pP>;^7yiyCm*NFKPRQdqAVg$-#xDY1
zlY!q{!e4u^0UeJU;PD^N0k;g`RX>=x>J+4$>F|qb15}$kSg9GlUd7SoUKX$*sTnm|_EQBe
zTBg;#1tQo8NepGWDf2CZnPumc+ik(_tVw@>yY8heiHplhC@WnKGnNlv(1^k44M=&W
z`8Jxg-iO=SDsyLPeQTPK+vhhar-Th!QK(O19w_B++Pu(e?lOCmKtG=Im&hw@chog
z6Hb+FbmI-0koUc)L}Q`fsj#DENFH_i*u8uNElSXLUesh3C~USIHTt+Ha-oJ7&P=TQ
zr#-ixgjm6Vtb8`?&Z=fNR`TlWIi`&z_@$Md~Cwb$#_nxBOsuyDSgEbI$kbV-a*Hq-)qILzv@l=>a|neu8ZDJ
zwL+-5@_zX!_w01Ne(F{3h5k*#pB`5*mHw@k)d#RU+j4TZNZKIz%55yS2W%k8yJXm
z4o71H@o-f&%SJ2FXpm>{tzW>kg{i
zSiay^JJ=_+I1_C3z09^5#_IW`Fes}0JQ9bBC()#N;?JQ%Was6nO?i+#DU>9z99jLF
zJYnPPZ`hM>Z{2M=utmUB>;`QH9o`mU<`9p0F7eU!yA(cem~Z8&4-duq`r14CBH_XK
za5y^9743|~We5`QiFOZ%yCTmHMIzz;=)k|r+edV{+&Jk;*yu6JFVzB^Dl;--fM=Rr
eCWC}A*yqjT<#6M*di3^;Ht)3LRtd1yz<&cy<5oTZ
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 00359203c58ccdbc361c23f29e40ef87d8fea5a1..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2196
zcmZ{mc{~%0AIC>?MHc1?iCjfD;&&k(G$;e!$
zmuy%GAjfu!o+`5wMhyH>+Y$clxR7?MHSU#R16q6Jh=Dq7q}6aPxs-#q{?6RAxx%Mg
z<0e<1iqQbt16agxA*(2B6w6Ap++?JCR7Vjpo-RNAZck&~3+a11K2fHh$I{EI8>~$&u
zldMYl0g_}=mxB&hjyemJLqfOlKnX3cW
zFjPWVRunnR9!c>xJy?dfGOo_|T9TICk+eG~=#4<+nS(lblyxTYgO$6>5Okz5+cMV?
zcC&pt7LHpLe)$1MahG(@^zzGd{ZUBn_?g3pXiRUQ!-^d~^dl_`((dwyxYCSKxY!5X+z0t_z!w(=iw<
z)QF7p0KSP)Z5l5M+T~-7QQlNh5JT&3VwBA@r-i?mpn271v3ol9mQLHm4!-`yfeZ{|
zM$H=XE3^FXvrVsUowpM&>T&LLxXk12mE}r3xw4Y+QIyCneDQZ>_Pkghe$R|eO0?q_
zN&PQ04)ic^w`jj~oCzCNrfAkL(p84Q4*ke;xbW;_TdN#M&FR@ddey{bLX0g}
zSikJmgbh@R$NfskHuq7PaBr^AH{_*#3rUFQuX7U`hGHuX01{mY?Fqh(ZZ`yh99~51
zlh3hXpoyy}LLS~Bw2g$?XEz-_lD0JvXLa+@-f1pn9xe3zDuiy2wsVt&7T~=3BHu!3
z1T&)%@-Kc4eW1?7@@Ng$nzwy~s%~NQ*u!!iI~MGj_%waAU&dyzx=ku+s0^%K77r^RgXkc+9=}=pvCu1S
zFN-)CEe5!H<)tdyyo%p!k8AJB3Dvxvst2vrRY(RPRlg=xvUxz|xl|3(l-xQeAVf+=
z4YoELXCb&_fryjU
z5<3OtJbS??7Qn*#|1iZ-FEIb`ae1W?u~s|Wg`maX7!q(#352^QcpT8H3u>IL)~>H=
zgY-?sKrSLo)JhAaVAWmRTPZcqK25uZOO?u{(_rW-Mw-^*uQtxY`k%M@mV0aa3A)_Q
zzhX=W)%cv@v%98_Zka_a(Z=;d6DA~8);O#6Lx)*|F_rHw?@On!X)i}w-)jW
zgE8K}i=*Mp_brZe{7;@u!j?tL_kp(P1L3S*o&BwR2_`nmWs}}pQ{v(kh;GAN^>>s3
zWR>Ury)_nfud7!sKY4G~k5US{YBs!MxzvaYhu?Um{wA`-kUHU0luc2X1hz}-XXK@-
zu{XbQfkI164pV#1o9)Kv8XuMohPU00wRyabNL5^@?HLN+5S(mV062G66`!vd`dE!f
zFL1!b19>BMNzV#!gY1)j(6fwJ@?IIH9+?i7PHMT={8^~ir1uzhJT~~-O2oDBz+jV<
zxvoEQkKC^3l$X7dvoLXTdOfY@9 None:
+ with TemporaryDirectory() as temp_dir_str:
+ temp_dir = Path(temp_dir_str)
+ net = basic_node_breaker_network_powsybl()
+
+ grid_file = temp_dir / "node_breaker_network.xiidm"
+ net.save(grid_file)
+ importer_parameters = CgmesImporterParameters(
+ grid_model_file=grid_file,
+ data_folder=temp_dir,
+ white_list_file=None,
+ black_list_file=None,
+ area_settings=AreaSettings(cutoff_voltage=1, control_area=[""], view_area=[""], nminus1_area=[""]),
+ )
+ preprocessing.convert_file(importer_parameters=importer_parameters)
+
+ imported_nminus1_definition = load_nminus1_definition(temp_dir / PREPROCESSING_PATHS["nminus1_definition_file_path"])
+ all_busbar_ids = sorted(net.get_busbar_sections().index.to_list())
+ expected_busbar_ids = sorted(
+ {
+ element.id
+ for contingency in imported_nminus1_definition.contingencies
+ for element in contingency.elements
+ if element.kind == "bus" and element.type == "BUSBAR_SECTION"
+ }
+ )
+
+ _info, _static_information, network_data = load_grid(
+ data_folder_dirfs=DirFileSystem(str(temp_dir)),
+ pandapower=False,
+ parameters=PreprocessParameters(preprocess_bb_outages=True),
+ lf_params=CGMES_DISTRIBUTED_SLACK,
+ )
+
+ configured_busbar_ids = sorted(
+ busbar_id for busbar_ids in (network_data.busbar_outage_map or {}).values() for busbar_id in busbar_ids
+ )
+ preprocessed_busbar_ids = sorted(extract_busbar_outage_ids(network_data))
+ _, non_rel_busbar_outage_map = get_rel_non_rel_sub_bb_maps(network_data, network_data.busbar_outage_map or {})
+ expected_non_rel_busbar_count = sum(len(busbar_ids) for busbar_ids in non_rel_busbar_outage_map.values())
+
+ assert expected_busbar_ids
+ assert set(expected_busbar_ids).issubset(set(all_busbar_ids))
+ assert configured_busbar_ids == expected_busbar_ids
+ assert preprocessed_busbar_ids == expected_busbar_ids
+ assert len(network_data.non_rel_bb_outage_br_indices) == expected_non_rel_busbar_count
+ assert network_data.non_rel_bb_outage_deltap.shape[0] == expected_non_rel_busbar_count
+ assert network_data.non_rel_bb_outage_nodal_indices.shape[0] == expected_non_rel_busbar_count
+
+
def test_perform_rel_bb_outage_single_topo_with_no_inj_reassignments(
jax_inputs_oberrhein: tuple[ActionIndexComputations, StaticInformation],
network_data_preprocessed: NetworkData,
@@ -473,15 +538,14 @@ def correct_power_flow_directions(lfs, network_data_preprocessed: NetworkData):
def test_compare_loadflows_non_rel_bb_outage_powsybl(
test_grid_folder_path: Path,
network_data_test_grid: NetworkData,
- outage_map_test_grid: dict[str, list[str]],
):
net = pp.network.load(test_grid_folder_path / "grid.xiidm")
# outage_map = outage_map_test_grid
outage_map = network_data_test_grid.busbar_outage_map
+ assert set(outage_map.keys()) == {"VL2_0", "VL3_0", "VL4_0", "VL5_0"}
rel_bb_outage_map, non_rel_bb_outage_map = get_rel_non_rel_sub_bb_maps(network_data_test_grid, outage_map)
- network_data = replace(network_data_test_grid, busbar_outage_map=outage_map)
- network_data = preprocess_bb_outages(network_data)
+ network_data = preprocess_bb_outages(network_data_test_grid)
static_information = convert_to_jax(
network_data,
preprocess_bb_outages=True,
@@ -513,10 +577,7 @@ def test_compare_loadflows_non_rel_bb_outage_powsybl(
network_data.branch_ids.index(asset.grid_model_id) for asset in connected_assets if asset.is_branch()
]
copy_net.remove_elements(connected_asset_ids)
- config = pp.loadflow.Parameters(
- distributed_slack=False,
- )
- pp.loadflow.run_dc(copy_net, parameters=config)
+ pp.loadflow.run_dc(copy_net, parameters=SINGLE_SLACK)
# Get the stub branches connected to this busbar
stub_branch_indices = []
@@ -544,7 +605,6 @@ def test_compare_loadflows_non_rel_bb_outage_powsybl(
def test_compare_loadflows_rel_bb_outage(
test_grid_folder_path: Path,
network_data_test_grid: NetworkData,
- outage_map_test_grid: dict[str, list[str]],
jax_inputs_test_grid: tuple[ActionIndexComputations, StaticInformation],
):
net = pp.network.load(test_grid_folder_path / "grid.xiidm")
@@ -556,7 +616,10 @@ def test_compare_loadflows_rel_bb_outage(
# These indices are neccessarily sorted in the order of rel_subs
updated_topo_indices = jax.jit(pad_action_with_unsplit_action_indices)(di.action_set, topo_indices.action)
- rel_bb_outage_map, _ = get_rel_non_rel_sub_bb_maps(network_data_test_grid, outage_map_test_grid)
+
+ assert set(network_data_test_grid.busbar_outage_map.keys()) == {"VL2_0", "VL3_0", "VL4_0", "VL5_0"}
+
+ rel_bb_outage_map, _ = get_rel_non_rel_sub_bb_maps(network_data_test_grid, network_data_test_grid.busbar_outage_map)
branch_actions = di.action_set.branch_actions[updated_topo_indices]
affected_sub_ids = di.action_set.substation_correspondence[updated_topo_indices]
@@ -605,10 +668,7 @@ def test_compare_loadflows_rel_bb_outage(
if asset.is_branch()
]
copy_net.remove_elements(connected_asset_ids)
- config = pp.loadflow.Parameters(
- distributed_slack=False,
- )
- pp.loadflow.run_dc(copy_net, parameters=config)
+ pp.loadflow.run_dc(copy_net, parameters=SINGLE_SLACK)
# Get the stub branches connected to this busbar
stub_branch_indices = []
diff --git a/packages/dc_solver_pkg/tests/jax/test_compute_batch.py b/packages/dc_solver_pkg/tests/jax/test_compute_batch.py
index 43098d3ba..6f317c642 100644
--- a/packages/dc_solver_pkg/tests/jax/test_compute_batch.py
+++ b/packages/dc_solver_pkg/tests/jax/test_compute_batch.py
@@ -227,6 +227,50 @@ def test_compute_batch_symmetric_with_bb_outage(
assert lf_res.bb_outage_penalty.shape == (solver_config.batch_size_bsdf,)
+def test_compute_batch_symmetric_with_bb_outage_nminus1_with_baseline(
+ jax_inputs_oberrhein: tuple[ActionIndexComputations, StaticInformation],
+) -> None:
+ """Regression test: keep baseline present with bb_outage_as_nminus1 enabled.
+
+ In this setup, N-1 contains busbar outages and contingency_success must have
+ matching failure-axis length.
+ """
+ topo_indices, static_information = jax_inputs_oberrhein
+ di = static_information.dynamic_information
+ baseline_analysis = di.bb_outage_baseline_analysis
+ if baseline_analysis is None:
+ baseline_analysis = get_bb_outage_baseline_analysis(
+ di=di,
+ more_splits_penalty=1000.0,
+ )
+
+ static_information = replace(
+ static_information,
+ solver_config=replace(
+ static_information.solver_config,
+ enable_bb_outages=True,
+ bb_outage_as_nminus1=True,
+ ),
+ dynamic_information=replace(
+ di,
+ bb_outage_baseline_analysis=baseline_analysis,
+ ),
+ )
+
+ lf_res, success = compute_symmetric_batch(
+ topology_batch=topo_indices,
+ disconnection_batch=None,
+ injections=None,
+ nodal_inj_start_options=None,
+ dynamic_information=static_information.dynamic_information,
+ solver_config=static_information.solver_config,
+ )
+
+ assert jnp.any(success)
+ assert lf_res.contingency_success is not None
+ assert lf_res.contingency_success.shape[1] == lf_res.n_1_matrix.shape[2]
+
+
def test_compute_symmetric_batch_with_disconnection(
jax_inputs: tuple[TopoVectBranchComputations, InjectionComputations, StaticInformation],
) -> None:
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py b/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
index a7ec1f0a4..2982d0f74 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_convert_to_jax.py
@@ -232,6 +232,29 @@ def test_convert_rel_bb_outage_data(network_data_preprocessed: NetworkData, ober
assert len(network_data_preprocessed.non_rel_bb_outage_nodal_indices) == 0
+def test_convert_rel_bb_outage_data_uses_physical_busbar_width_for_articulation_mask(
+ network_data_preprocessed: NetworkData,
+) -> None:
+ n_timesteps = network_data_preprocessed.nodal_injection.shape[0]
+ rel_bb_outage_data = convert_rel_bb_outage_data(
+ replace(
+ network_data_preprocessed,
+ branch_action_set=[np.zeros((1, 1), dtype=bool)],
+ rel_bb_outage_br_indices=[[[[], [], [], []]]],
+ rel_bb_outage_deltap=[
+ [[np.zeros(n_timesteps), np.zeros(n_timesteps), np.zeros(n_timesteps), np.zeros(n_timesteps)]]
+ ],
+ rel_bb_outage_nodal_indices=[[[0, 1, 2, 3]]],
+ rel_bb_articulation_nodes=[[[4]]],
+ )
+ )
+
+ assert rel_bb_outage_data.branch_outage_set.shape[1] == 5
+ assert rel_bb_outage_data.nodal_indices.shape[1] == 5
+ assert rel_bb_outage_data.articulation_node_mask.shape[1] == 5
+ assert rel_bb_outage_data.articulation_node_mask[0, 4]
+
+
def test_get_bb_outage_baseline_analysis(jax_inputs_oberrhein):
static_information = jax_inputs_oberrhein[1]
result = get_bb_outage_baseline_analysis(
diff --git a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
index 77fbdc208..bdff4fb2f 100644
--- a/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
+++ b/packages/dc_solver_pkg/tests/preprocessing/test_powsybl_backend.py
@@ -37,6 +37,7 @@
from toop_engine_dc_solver.preprocess.preprocess import preprocess
from toop_engine_grid_helpers.powsybl.loadflow_parameters import CGMES_DISTRIBUTED_SLACK
from toop_engine_interfaces.folder_structure import (
+ NETWORK_MASK_NAMES,
PREPROCESSING_PATHS,
)
from toop_engine_interfaces.loadflow_result_helpers_polars import extract_solver_matrices_polars
@@ -96,6 +97,84 @@ def test_get_nodes(powsybl_case57_folder_xiidm: Path) -> None:
assert backend.get_cross_coupler_limits().shape == (n_connected_nodes,)
+def test_get_busbar_outage_map(powsybl_data_folder: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_data_folder))
+ backend = PowsyblBackend(filesystem_dir_powsybl)
+ asset_topology = backend.get_asset_topology()
+
+ busbar_sections = backend.net.get_busbar_sections(attributes=["bus_id"])
+ connected_busbars = busbar_sections[busbar_sections["bus_id"].isin(backend.get_node_ids())]
+ selected_busbars = connected_busbars.iloc[: min(3, len(connected_busbars))]
+ mask = np.zeros(len(busbar_sections), dtype=bool)
+ mask[busbar_sections.index.get_indexer(selected_busbars.index)] = True
+ mask_path = powsybl_data_folder / PREPROCESSING_PATHS["masks_path"] / NETWORK_MASK_NAMES["busbar_for_nminus1"]
+ mask_path.parent.mkdir(parents=True, exist_ok=True)
+ np.save(mask_path, mask)
+
+ selected_busbars = busbar_sections[mask]
+ selected_busbars = selected_busbars[selected_busbars["bus_id"].isin(backend.get_node_ids())]
+ busbar_to_station_id = {}
+ bus_id_to_station_id = {}
+ if asset_topology is not None:
+ busbar_to_station_id = {
+ busbar.grid_model_id: station.grid_model_id for station in asset_topology.stations for busbar in station.busbars
+ }
+ bus_id_to_station_id = {
+ busbar.bus_branch_bus_id: station.grid_model_id
+ for station in asset_topology.stations
+ for busbar in station.busbars
+ }
+ expected_outage_map: dict[str, list[str]] = {}
+ for busbar_id, busbar in selected_busbars.iterrows():
+ station_id = busbar_to_station_id.get(str(busbar_id))
+ if station_id is None:
+ station_id = bus_id_to_station_id.get(str(busbar["bus_id"]))
+ if station_id is None:
+ continue
+ expected_outage_map.setdefault(station_id, []).append(str(busbar_id))
+
+ assert backend.get_busbar_outage_map() == expected_outage_map
+
+
+def test_get_busbar_outage_map_case57(powsybl_case57_folder_xiidm: Path) -> None:
+ filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
+ backend = PowsyblBackend(filesystem_dir_powsybl)
+ asset_topology = backend.get_asset_topology()
+
+ busbar_sections = backend.net.get_busbar_sections(attributes=["bus_id"])
+ connected_busbars = busbar_sections[busbar_sections["bus_id"].isin(backend.get_node_ids())]
+ selected_busbars = connected_busbars.iloc[: min(3, len(connected_busbars))]
+ mask = np.zeros(len(busbar_sections), dtype=bool)
+ mask[busbar_sections.index.get_indexer(selected_busbars.index)] = True
+ mask_path = powsybl_case57_folder_xiidm / PREPROCESSING_PATHS["masks_path"] / NETWORK_MASK_NAMES["busbar_for_nminus1"]
+ mask_path.parent.mkdir(parents=True, exist_ok=True)
+ np.save(mask_path, mask)
+
+ selected_busbars = busbar_sections[mask]
+ selected_busbars = selected_busbars[selected_busbars["bus_id"].isin(backend.get_node_ids())]
+ busbar_to_station_id = {}
+ bus_id_to_station_id = {}
+ if asset_topology is not None:
+ busbar_to_station_id = {
+ busbar.grid_model_id: station.grid_model_id for station in asset_topology.stations for busbar in station.busbars
+ }
+ bus_id_to_station_id = {
+ busbar.bus_branch_bus_id: station.grid_model_id
+ for station in asset_topology.stations
+ for busbar in station.busbars
+ }
+ expected_outage_map: dict[str, list[str]] = {}
+ for busbar_id, busbar in selected_busbars.iterrows():
+ station_id = busbar_to_station_id.get(str(busbar_id))
+ if station_id is None:
+ station_id = bus_id_to_station_id.get(str(busbar["bus_id"]))
+ if station_id is None:
+ continue
+ expected_outage_map.setdefault(station_id, []).append(str(busbar_id))
+
+ assert backend.get_busbar_outage_map() == expected_outage_map
+
+
def test_get_injections(powsybl_case57_folder_xiidm: Path) -> None:
filesystem_dir_powsybl = DirFileSystem(str(powsybl_case57_folder_xiidm))
backend = PowsyblBackend(filesystem_dir_powsybl)
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 a1c1b298f..bb1e82570 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
@@ -13,7 +13,12 @@
from toop_engine_dc_solver.example_grids import case30_with_psts_pandapower
from toop_engine_dc_solver.postprocess.write_aux_data import write_aux_data
from toop_engine_dc_solver.preprocess.convert_to_jax import convert_to_jax
-from toop_engine_dc_solver.preprocess.network_data import NetworkData, extract_action_set, extract_nminus1_definition
+from toop_engine_dc_solver.preprocess.network_data import (
+ NetworkData,
+ extract_action_set,
+ extract_busbar_outage_ids,
+ 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_interfaces.folder_structure import PREPROCESSING_PATHS
@@ -24,13 +29,20 @@
def test_extract_data_compare_to_jax(network_data_preprocessed: NetworkData) -> None:
nminus1_definition = extract_nminus1_definition(network_data_preprocessed)
action_set = extract_action_set(network_data_preprocessed)
+ busbar_outage_ids = extract_busbar_outage_ids(network_data_preprocessed)
+ busbar_contingencies = [
+ contingency
+ for contingency in nminus1_definition.contingencies
+ if contingency.elements and contingency.elements[0].kind == "bus"
+ ]
# Compare with the processed jax data
static_information = convert_to_jax(network_data_preprocessed)
assert len(action_set.local_actions) == len(static_information.dynamic_information.action_set)
mon_branches = [el for el in nminus1_definition.monitored_elements if el.kind == "branch"]
assert len(mon_branches) == static_information.n_branches_monitored
- assert len(nminus1_definition.contingencies) == static_information.n_nminus1_cases + 1
+ assert [contingency.id for contingency in busbar_contingencies] == busbar_outage_ids
+ assert len(nminus1_definition.contingencies) == static_information.n_nminus1_cases + len(busbar_outage_ids) + 1
assert nminus1_definition.contingencies[0].id == "BASECASE"
assert len(nminus1_definition.contingencies[0].elements) == 0
assert len(action_set.disconnectable_branches) == static_information.dynamic_information.disconnectable_branches.shape[0]
@@ -45,12 +57,14 @@ def test_extract_data_compare_to_network_data(network_data_preprocessed: Network
n_multi_outages = len(network_data_preprocessed.multi_outage_ids)
n_branch_outages = network_data_preprocessed.outaged_branch_mask.sum()
n_inj_outages = network_data_preprocessed.outaged_injection_mask.sum()
+ n_busbar_outages = len(extract_busbar_outage_ids(network_data_preprocessed))
n_expected_contingencies = (
1 # Base case
+ n_branch_outages # Single branch outages
+ n_multi_outages # Multi outages
+ n_inj_outages # Injection outages
+ + n_busbar_outages # Exported busbar outages
)
assert n_contingencies == n_expected_contingencies, "Number of contingencies does not match expected count"
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 87219cc10..1d5421bfc 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
@@ -1644,6 +1644,8 @@ def _create_busbars(voltage_list: list, kwargs: dict) -> None:
if connect_line_out_of_service:
n.connect("LINE_out_of_service")
+ pypowsybl.loadflow.run_ac(n)
+
return n
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..4b804c19d 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,7 @@
import pandera.typing as pat
import structlog
from beartype.typing import Literal, Optional, Union
-from jaxtyping import ArrayLike, Bool
+from jaxtyping import Bool
from toop_engine_importer.network_graph.data_classes import (
BranchSchema,
BusbarConnectionInfo,
@@ -732,9 +732,9 @@ def get_station_connection_tables(
def remove_double_connections(
- switching_table: Bool[ArrayLike, " n_bus n_asset"],
+ switching_table: Bool[np.ndarray, " n_bus n_asset"],
substation_id: Optional[str] = None,
-) -> Bool[ArrayLike, " n_bus n_asset"]:
+) -> Bool[np.ndarray, " n_bus n_asset"]:
"""Remove double connections from the switching table.
An Asset can be connected to multiple busbars.
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..7e74bb493 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
@@ -75,6 +75,25 @@ def node_breaker_topology_to_graph_data(net: Network, substation_info: Substatio
NetworkGraphData.
"""
all_names_df = get_all_element_names(net, line_trafo_name_col="name")
+ branches_df = net.get_branches(attributes=["connected1", "connected2", "bus1_id", "bus2_id"])
+ injections_df = net.get_injections(attributes=["connected", "bus_id"])
+ buses_df = net.get_buses(attributes=["connected_component"])
+ in_main_connected_component = buses_df["connected_component"].fillna(0).eq(0)
+
+ asset_in_service = pd.concat(
+ [
+ (
+ branches_df["connected1"].fillna(False)
+ & branches_df["connected2"].fillna(False)
+ & branches_df["bus1_id"].map(in_main_connected_component).fillna(False)
+ & branches_df["bus2_id"].map(in_main_connected_component).fillna(False)
+ ).rename("in_service"),
+ (
+ injections_df["connected"].fillna(False)
+ & injections_df["bus_id"].map(in_main_connected_component).fillna(False)
+ ).rename("in_service"),
+ ]
+ )
nbt = net.get_node_breaker_topology(substation_info.voltage_level_id)
switches_df = get_switches(switches_df=nbt.switches)
@@ -86,7 +105,11 @@ def node_breaker_topology_to_graph_data(net: Network, substation_info: Substatio
substation_info=substation_info,
)
helper_branches = get_helper_branches(internal_connections_df=nbt.internal_connections)
- node_assets_df = get_node_assets(nodes_df=nodes_df, all_names_df=all_names_df)
+ node_assets_df = get_node_assets(
+ nodes_df=nodes_df,
+ all_names_df=all_names_df,
+ asset_in_service=asset_in_service,
+ )
graph_data = NetworkGraphData(
nodes=nodes_df,
@@ -232,7 +255,11 @@ def get_helper_branches(internal_connections_df: pd.DataFrame) -> pat.DataFrame[
return helper_branches
-def get_node_assets(nodes_df: pd.DataFrame, all_names_df: pd.Series) -> pat.DataFrame[NodeAssetSchema]:
+def get_node_assets(
+ nodes_df: pd.DataFrame,
+ all_names_df: pd.Series,
+ asset_in_service: pd.Series,
+) -> pat.DataFrame[NodeAssetSchema]:
"""Get node assets from a node breaker topology.
Get the node assets from a node breaker topology, rename and retype for the NetworkGraph.
@@ -243,6 +270,8 @@ def get_node_assets(nodes_df: pd.DataFrame, all_names_df: pd.Series) -> pat.Data
The nodes DataFrame from the node NodeBreakerTopology.
all_names_df : pd.Series
The names of all elements in the network.
+ asset_in_service : pd.Series
+ Boolean service state by connectable id derived from the Powsybl network model.
Returns
-------
@@ -260,8 +289,7 @@ def get_node_assets(nodes_df: pd.DataFrame, all_names_df: pd.Series) -> pat.Data
node_assets_df.rename(columns={"connectable_type": "asset_type", "name": "foreign_id"}, inplace=True)
node_assets_df.fillna({"foreign_id": ""}, inplace=True)
node_assets_df = node_assets_df[["grid_model_id", "foreign_id", "node", "asset_type"]]
- # TODO: might need to be changed once there is more information about the in_service state
- node_assets_df["in_service"] = True
+ node_assets_df["in_service"] = node_assets_df["grid_model_id"].map(asset_in_service).fillna(True).astype(bool)
return node_assets_df
@@ -402,6 +430,12 @@ def get_relevant_voltage_levels(network: Network, network_masks: NetworkMasks) -
voltage_levels = get_voltage_level_with_region(network, attributes=attributes)
busses = network.get_buses()
relevant_voltage_levels = busses[network_masks.relevant_subs]["voltage_level_id"]
+ busbar_sections = network.get_busbar_sections(attributes=["bus_id"])
+ busbar_outage_bus_ids = pd.Index(busbar_sections[network_masks.busbar_for_nminus1]["bus_id"].unique())
+ relevant_busbar_buses = busses.loc[busses.index.intersection(busbar_outage_bus_ids)]
+ relevant_voltage_levels = pd.concat(
+ [relevant_voltage_levels, relevant_busbar_buses["voltage_level_id"]]
+ ).drop_duplicates()
relevant_voltage_level_with_region = voltage_levels[voltage_levels.index.isin(relevant_voltage_levels)]
relevant_voltage_level_with_region_and_bus_id = relevant_voltage_level_with_region.merge(
relevant_voltage_levels, left_index=True, right_on="voltage_level_id", how="left"
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..5e5b0bcfa 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
@@ -109,6 +109,10 @@ def get_most_connected_bus_at_voltage_level(
node_breaker_topology = net.get_node_breaker_topology(voltage_level_id)
switches = bus_breaker_topology.switches
elements = bus_breaker_topology.elements
+ elements.rename(columns={"bus_id": "bus_breaker_id"}, inplace=True)
+ elements = elements.merge(
+ net.get_bus_breaker_view_buses()[["bus_id"]], left_on="bus_breaker_id", right_index=True, how="left"
+ )
if switches[switches["kind"] == "BREAKER"].empty:
return None
diff --git a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
index c85aa8717..9e5ee689c 100644
--- a/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
+++ b/packages/importer_pkg/src/toop_engine_importer/pypowsybl_import/powsybl_masks.py
@@ -778,6 +778,7 @@ def update_bus_masks(
The updated network mask object including the bus masks.
"""
buses = network.get_buses()
+ busbar_sections = network.get_busbar_sections(attributes=["bus_id", "voltage_level_id"])
if importer_parameters.data_type == "ucte":
relevant_subs = buses.index.isin(
get_switchable_buses_ucte(
@@ -788,6 +789,11 @@ def update_bus_masks(
)
)
substation_ids = buses["voltage_level_id"].values
+ busbar_area_mask = get_mask_for_area_codes(
+ busbar_sections,
+ importer_parameters.area_settings.nminus1_area,
+ "voltage_level_id",
+ )
elif importer_parameters.data_type == "cgmes":
relevant_subs = buses.index.isin(
get_switchable_buses_cgmes(
@@ -799,15 +805,37 @@ def update_bus_masks(
)
)
substation_ids = buses["name"].str[:-2].values
+ busbar_sections = get_region_for_df(df=busbar_sections, network=network)
+ busbar_area_mask = get_mask_for_area_codes(
+ busbar_sections,
+ importer_parameters.area_settings.nminus1_area,
+ "region",
+ )
else:
raise ValueError(f"Data type {importer_parameters.data_type} is not supported.")
+ if importer_parameters.select_by_voltage_level_id_list is None:
+ busbar_hv_mask = (
+ get_voltage_from_voltage_level_id(network, busbar_sections["voltage_level_id"])
+ >= importer_parameters.area_settings.cutoff_voltage
+ )
+ busbar_for_nminus1 = busbar_hv_mask & busbar_area_mask
+ else:
+ busbar_for_nminus1 = (
+ busbar_sections["voltage_level_id"]
+ .isin(importer_parameters.select_by_voltage_level_id_list)
+ .to_numpy(dtype=bool)
+ )
+
blacklisted_substations = np.isin(substation_ids, blacklisted_ids)
relevant_subs = relevant_subs & ~blacklisted_substations
+ busbar_substation_ids = pd.Series(substation_ids, index=buses.index).reindex(busbar_sections["bus_id"]).to_numpy()
+ busbar_for_nminus1 = np.logical_and(busbar_for_nminus1, ~np.isin(busbar_substation_ids, blacklisted_ids))
return replace(
network_masks,
relevant_subs=relevant_subs,
+ busbar_for_nminus1=busbar_for_nminus1,
)
@@ -1088,6 +1116,8 @@ def make_masks(
if not validate_network_masks(network_masks, default_masks):
raise RuntimeError("Network masks are not created correctly.")
+ network_masks = remove_slack_busbar_sections(network_masks, network, slack_id=slack_id)
+
return network_masks
@@ -1171,9 +1201,30 @@ def remove_slack_from_relevant_subs(network_masks: NetworkMasks, network: Networ
network_masks: NetworkMasks
The updated network masks without the slack bus in the relevant_subs mask.
"""
- return replace(
- network_masks, relevant_subs=network_masks.relevant_subs & ~network.get_buses(attributes=[]).index.isin([slack_id])
- )
+ relevant_subs = network_masks.relevant_subs & ~network.get_buses(attributes=[]).index.isin([slack_id])
+ return replace(network_masks, relevant_subs=relevant_subs)
+
+
+def remove_slack_busbar_sections(network_masks: NetworkMasks, network: Network, slack_id: str) -> NetworkMasks:
+ """Remove busbar sections that belong to the slack bus from the N-1 mask.
+
+ Parameters
+ ----------
+ network_masks: NetworkMasks
+ The network masks to update.
+ network: Network
+ The network to get the busbar-section information from.
+ slack_id: str
+ The id of the slack bus.
+
+ Returns
+ -------
+ NetworkMasks
+ The updated network masks without slack-connected busbar sections in the N-1 mask.
+ """
+ busbar_sections = network.get_busbar_sections(attributes=["bus_id"])
+ busbar_for_nminus1 = network_masks.busbar_for_nminus1 & ~busbar_sections["bus_id"].isin([slack_id]).to_numpy()
+ return replace(network_masks, busbar_for_nminus1=busbar_for_nminus1)
def update_masks_from_power_factory_contingency_list_file(
@@ -1256,11 +1307,30 @@ def update_masks_from_power_factory_contingency_list_file(
)
load_nminus1_mask = load_hv_mask & network.get_loads().index.isin(grid_model_ids)
- busbar_df = network.get_busbar_sections(attributes=["voltage_level_id"])
- busbar_for_nminus1 = (
+ busbar_df = network.get_busbar_sections(attributes=["name", "bus_id", "voltage_level_id"])
+ busbar_hv_mask = (
get_voltage_from_voltage_level_id(network, busbar_df.voltage_level_id)
>= importer_parameters.area_settings.cutoff_voltage
- ) & busbar_df.index.isin(grid_model_ids)
+ )
+ busbar_rows = processed_n1_definition["element_type"].isin(["BUS", "BUSBAR_SECTION"])
+ if "power_factory_element_type" in processed_n1_definition:
+ busbar_rows |= processed_n1_definition["power_factory_element_type"].isin(["BUS", "BUSBAR_SECTION"])
+ busbar_candidate_ids = pd.unique(
+ pd.concat(
+ [
+ processed_n1_definition.loc[busbar_rows, "grid_model_id"],
+ processed_n1_definition.loc[busbar_rows, "power_factory_grid_model_rdf_id"],
+ processed_n1_definition.loc[busbar_rows, "power_factory_grid_model_fid"],
+ processed_n1_definition.loc[busbar_rows, "power_factory_grid_model_name"],
+ ],
+ ignore_index=True,
+ ).dropna()
+ )
+ busbar_for_nminus1 = busbar_hv_mask & (
+ busbar_df.index.isin(busbar_candidate_ids)
+ | busbar_df["bus_id"].isin(busbar_candidate_ids).to_numpy(dtype=bool)
+ | busbar_df["name"].isin(busbar_candidate_ids).to_numpy(dtype=bool)
+ )
if not process_multi_outages:
grid_model_ids = processed_n1_definition["grid_model_id"].unique()
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 9a42e08e2..b4458a940 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
@@ -228,6 +228,14 @@ def create_nminus1_definition_from_masks(network: Network, network_masks: Networ
MonitoredElement(id=idx, name=row["name"], type="BUSBAR_SECTION", kind="bus")
for idx, row in busbar_sections[busbar_sections.index.isin(relevant_bus_ids)].iterrows()
]
+ outaged_busbars = [
+ Contingency(
+ id=idx,
+ name=row["name"],
+ elements=[GridElement(id=idx, name=row["name"], type="BUSBAR_SECTION", kind="bus")],
+ )
+ for idx, row in busbar_sections[network_masks.busbar_for_nminus1].iterrows()
+ ]
busbreaker_buses = network.get_bus_breaker_view_buses(attributes=["name", "bus_id"])
monitored_busbreakers = [
MonitoredElement(id=idx, name=row["name"], type="BUS_BREAKER_BUS", kind="bus")
@@ -254,6 +262,7 @@ def create_nminus1_definition_from_masks(network: Network, network_masks: Networ
+ outaged_generators
+ outaged_loads
+ outaged_switches
+ + outaged_busbars
),
)
return nminus1_definition
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..606e4ad78 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
@@ -114,6 +114,16 @@ def test_get_helper_branches(basic_node_breaker_network_powsybl_grid):
def test_get_node_assets(basic_node_breaker_network_powsybl_grid):
net = basic_node_breaker_network_powsybl_grid
nbt = net.get_node_breaker_topology("VL1")
+ branches_df = net.get_branches(attributes=["connected1", "connected2"])
+ injections_df = net.get_injections(attributes=["connected"])
+ asset_in_service = pd.concat(
+ [
+ (branches_df["connected1"].fillna(False) & branches_df["connected2"].fillna(False)).rename("in_service"),
+ injections_df["connected"].fillna(False).rename("in_service"),
+ ]
+ )
+ asset_in_service.loc["L1"] = False
+ asset_in_service.loc["generator1"] = False
names_dict = {
"L1": "",
"L2": "",
@@ -143,8 +153,11 @@ def test_get_node_assets(basic_node_breaker_network_powsybl_grid):
switches_df=switches_df,
substation_info=substation_information,
)
- node_assets_df = get_node_assets(nodes_df=nodes_df, all_names_df=all_names_df)
- node_assets_df["in_service"] = True
+ node_assets_df = get_node_assets(nodes_df=nodes_df, all_names_df=all_names_df, asset_in_service=asset_in_service)
+
+ assert not node_assets_df.loc[node_assets_df["grid_model_id"] == "L1", "in_service"].item()
+ assert not node_assets_df.loc[node_assets_df["grid_model_id"] == "generator1", "in_service"].item()
+ assert node_assets_df.loc[node_assets_df["grid_model_id"] == "L2", "in_service"].item()
NodeAssetSchema.validate(node_assets_df)
@@ -617,20 +630,27 @@ def test_get_topo_integration(basic_node_breaker_network_powsybl_grid: Network):
blacklisted_ids=[],
)
relevant_voltage_level_with_region = get_relevant_voltage_levels(network=net, network_masks=network_masks)
- expected = ["VL1", "VL2", "VL3", "VL4", "VL5"]
- assert all(net.get_voltage_levels().index == expected)
- # VL4 has only 3 branches, VL5 has only 1 busbar
- assert all(relevant_voltage_level_with_region["voltage_level_id"] == expected[1:3])
- assert all(relevant_voltage_level_with_region.index == ["VL2_0", "VL3_0"])
+ # net.get_buses().index
+ # [
+ # VL1_0, slack -> not relevant
+ # VL2_0, relevant
+ # VL3_0, relevant
+ # VL4_0, RelevantStationRules() require per default 4 braches -> only 3 branches, but on busbaroutage list > relevant
+ # VL5_0, 1 branch, 1 injection, 1 busbar -> but on busbar outage list -> relevant
+ # ]
+ expected_relevant_voltage_level_with_region = ["VL2", "VL3", "VL4", "VL5"]
+ assert all(net.get_voltage_levels().index == ["VL1", "VL2", "VL3", "VL4", "VL5"])
+ assert all(relevant_voltage_level_with_region["voltage_level_id"] == expected_relevant_voltage_level_with_region)
+ assert all(relevant_voltage_level_with_region.index == ["VL2_0", "VL3_0", "VL4_0", "VL5_0"])
res = get_station_list(network=net, relevant_voltage_level_with_region=relevant_voltage_level_with_region)
- assert len(res) == 2
+ assert len(res) == 4
assert all([isinstance(station, Station) 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.stations) == 4
assert res.topology_id == "cgmes_file.zip"
assert res.grid_model_file == "cgmes_file.zip"
assert res.timestamp - timestamp < datetime.timedelta(seconds=3)
@@ -701,7 +721,7 @@ def test_create_complex_grid_battery_hvdc_svc_3w_trafo_asset_topo():
),
)
- lf_result, *_ = pypowsybl.loadflow.run_dc(net)
+ lf_result, *_ = pypowsybl.loadflow.run_ac(net)
network_masks = powsybl_masks.make_masks(
network=net,
slack_id=lf_result.reference_bus_id,
@@ -709,25 +729,50 @@ def test_create_complex_grid_battery_hvdc_svc_3w_trafo_asset_topo():
blacklisted_ids=[],
)
relevant_voltage_level_with_region = get_relevant_voltage_levels(network=net, network_masks=network_masks)
+ # net.get_buses().index =
+ # [
+ # 'VL_3W_HV_0', large station
+ # 'VL_3W_MV_0', large station
+ # 'VL_3W_LV_0', 2 branches, 1 injections, but busbar relevant
+ # 'VL_2W_MV_LV_MV_0', 4 branches -> relevant
+ # 'VL_2W_MV_LV_LV_0', 2 branches, 1 injections, but busbar relevant
+ # 'VL_LV_load_0', 2 branches, 2 injections, 1 busbar, but busbar relevant
+ # 'VL_MV_load_0', 3 branches + pst -> 4 branches, 1 injection -> relevant
+ # 'VL_MV_load_3', other side of pst -> not relevant
+ # 'VL_MV_svc_0', large station
+ # 'VL_MV_0', large station
+ # 'VL_2W_MV_HV_MV_0', large station + pst
+ # 'VL_2W_MV_HV_MV_2', other side of pst -> not relevant
+ # 'VL_2W_MV_HV_HV_0', large station
+ # 'VL_HV_gen_0', large station, slack bus -> not relevant
+ # 'VL_HV_vsc_0', 4 branches, 1 injection, 1 HVDC
+ # '3W-Star-VL_0' not relevant
+ # ]
expected = [
"VL_3W_HV",
"VL_3W_MV",
+ "VL_3W_LV",
"VL_2W_MV_LV_MV",
+ "VL_2W_MV_LV_LV",
+ "VL_LV_load",
+ "VL_MV_load",
"VL_MV_svc",
"VL_MV",
"VL_2W_MV_HV_MV",
"VL_2W_MV_HV_HV",
"VL_HV_vsc",
- "VL_MV_load",
]
- # 'VL_HV_gen' not included as it is the slack
+ # 'VL_HV_gen_0' not included as it is the slack
for vl in expected:
assert vl in relevant_voltage_level_with_region["voltage_level_id"].values, f"Expected voltage level {vl} not found"
res = get_station_list(network=net, relevant_voltage_level_with_region=relevant_voltage_level_with_region)
- assert len(res) >= len(expected)
+ assert len(res) == len(expected)
assert all([isinstance(station, Station) for station in res])
+ station_names = [station.name for station in res]
+ for bus_id in expected:
+ assert bus_id in station_names, f"Expected station {bus_id} not found in station list"
res = get_topology(network=net, network_masks=network_masks, importer_parameters=importer_parameters)
assert isinstance(res, Topology)
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
index a6e2aaab8..fdd8081ae 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_powsybl_masks.py
@@ -542,10 +542,23 @@ def test_update_bus_masks(ucte_file_with_border, ucte_importer_parameters: UcteI
)
expected_bus_mask[3] = False
assert np.array_equal(updated_masks.relevant_subs, expected_bus_mask)
+ expected_busbar_mask = (
+ powsybl_masks.get_voltage_from_voltage_level_id(
+ network,
+ network.get_busbar_sections(attributes=["voltage_level_id"])["voltage_level_id"],
+ )
+ >= importer_parameters.area_settings.cutoff_voltage
+ ) & powsybl_masks.get_mask_for_area_codes(
+ network.get_busbar_sections(attributes=["voltage_level_id"]),
+ importer_parameters.area_settings.nminus1_area,
+ "voltage_level_id",
+ )
+ assert np.array_equal(network_masks.busbar_for_nminus1, expected_busbar_mask)
def test_update_bus_masks_node_breaker_select_station(basic_node_breaker_network_powsybl_grid: Network):
network = basic_node_breaker_network_powsybl_grid
+ buses = network.get_buses(attributes=[])
importer_parameters = CgmesImporterParameters(
grid_model_file=Path("cgmes_file.zip"),
data_folder="data_folder",
@@ -556,6 +569,19 @@ def test_update_bus_masks_node_breaker_select_station(basic_node_breaker_network
expected_bus_mask = np.array([True, True, True, False, False])
assert np.array_equal(updated_masks.relevant_subs, expected_bus_mask)
+ busbar_sections = powsybl_masks.get_region_for_df(
+ network=network,
+ df=network.get_busbar_sections(attributes=["voltage_level_id"]),
+ )
+ expected_busbar_mask = (
+ powsybl_masks.get_voltage_from_voltage_level_id(network, busbar_sections["voltage_level_id"])
+ >= importer_parameters.area_settings.cutoff_voltage
+ ) & powsybl_masks.get_mask_for_area_codes(
+ busbar_sections,
+ importer_parameters.area_settings.nminus1_area,
+ "region",
+ )
+ assert np.array_equal(updated_masks.busbar_for_nminus1, expected_busbar_mask)
# make sure the slack is removed from relevant subs
lf_result, *_ = pypowsybl.loadflow.run_dc(network)
@@ -567,6 +593,11 @@ def test_update_bus_masks_node_breaker_select_station(basic_node_breaker_network
)
expected_bus_mask_no_slack = np.array([False, True, True, False, False])
assert np.array_equal(network_masks.relevant_subs, expected_bus_mask_no_slack)
+ expected_busbar_mask_no_slack = (
+ expected_busbar_mask
+ & ~network.get_busbar_sections(attributes=["bus_id"])["bus_id"].isin([lf_result.reference_bus_id]).to_numpy()
+ )
+ assert np.array_equal(network_masks.busbar_for_nminus1, expected_busbar_mask_no_slack)
importer_parameters.select_by_voltage_level_id_list = list(network.get_voltage_levels().index)
updated_masks = powsybl_masks.update_bus_masks(default_masks, network, importer_parameters, blacklisted_ids=[])
@@ -708,12 +739,32 @@ def test_make_masks_node_breaker(
basic_node_breaker_network_powsybl_not_disconnectable, cgmes_importer_parameters: CgmesImporterParameters
):
network = basic_node_breaker_network_powsybl_not_disconnectable
+ buses = network.get_buses(attributes=[])
default_masks = powsybl_masks.create_default_network_masks(network)
lf_result, *_ = pypowsybl.loadflow.run_dc(network)
masks = powsybl_masks.make_masks(
network=network, slack_id=lf_result.reference_bus_id, importer_parameters=cgmes_importer_parameters
)
assert powsybl_masks.validate_network_masks(masks, default_masks)
+ busbar_sections = powsybl_masks.get_region_for_df(
+ network=network,
+ df=network.get_busbar_sections(attributes=["bus_id", "voltage_level_id"]),
+ )
+ expected_busbar_mask = (
+ powsybl_masks.get_voltage_from_voltage_level_id(network, busbar_sections["voltage_level_id"])
+ >= cgmes_importer_parameters.area_settings.cutoff_voltage
+ ) & powsybl_masks.get_mask_for_area_codes(
+ busbar_sections,
+ cgmes_importer_parameters.area_settings.nminus1_area,
+ "region",
+ )
+ expected_busbar_mask &= ~busbar_sections["bus_id"].isin([lf_result.reference_bus_id]).to_numpy()
+ assert np.array_equal(masks.busbar_for_nminus1, expected_busbar_mask)
+ assert (
+ not network.get_busbar_sections(attributes=["bus_id"])["bus_id"][masks.busbar_for_nminus1]
+ .isin([lf_result.reference_bus_id])
+ .any()
+ )
def test_make_masks_node_breaker_with_ignore_file(
@@ -804,6 +855,38 @@ def test_update_masks_from_contingency_list_file(
assert np.array_equal(network_masks.boundary_line_for_nminus1, np.array([False, False, True, False, False]))
+def test_update_masks_from_power_factory_contingency_file_with_busbar_ids(
+ basic_node_breaker_network_powsybl_grid: Network,
+ cgmes_importer_parameters: CgmesImporterParameters,
+ tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+ network = basic_node_breaker_network_powsybl_grid
+ busbar_sections = network.get_busbar_sections(attributes=["bus_id", "voltage_level_id"])
+ selected_bus_id = str(busbar_sections.iloc[0]["bus_id"])
+
+ contingency_file = tmp_path_factory.mktemp("contingency_busbar_file") / "contingency.csv"
+ contingency_file.write_text(
+ """index;contingency_name;contingency_id;power_factory_grid_model_name;power_factory_grid_model_fid;power_factory_grid_model_rdf_id;power_factory_element_type
+1;busbar;1;{bus_id};{bus_id};{bus_id};BUS
+""".format(bus_id=selected_bus_id)
+ )
+ cgmes_importer_parameters.contingency_list_file = contingency_file
+
+ default_masks = powsybl_masks.create_default_network_masks(network)
+ network_masks = powsybl_masks.update_masks_from_power_factory_contingency_list_file(
+ network_masks=default_masks,
+ network=network,
+ importer_parameters=cgmes_importer_parameters,
+ filesystem=LocalFileSystem(),
+ )
+
+ expected_busbar_mask = (
+ powsybl_masks.get_voltage_from_voltage_level_id(network, busbar_sections["voltage_level_id"])
+ >= cgmes_importer_parameters.area_settings.cutoff_voltage
+ ) & busbar_sections["bus_id"].isin([selected_bus_id]).to_numpy()
+ assert np.array_equal(network_masks.busbar_for_nminus1, expected_busbar_mask)
+
+
def test_make_masks_with_contingency_file(
ucte_file_with_border, ucte_importer_parameters: UcteImporterParameters, tmp_path_factory: pytest.TempPathFactory
):
diff --git a/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py b/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py
index 92a1e2561..1acb440cc 100644
--- a/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py
+++ b/packages/importer_pkg/tests/pypowsybl_import/test_preprocessing.py
@@ -371,3 +371,15 @@ def test_create_nminus1_definition_from_masks_basic(ucte_file):
assert switches.index[0] in contingency_ids # switch_for_nminus1
# BASECASE contingency should exist
assert "BASECASE" in contingency_ids
+
+
+def test_create_nminus1_definition_from_masks_busbars(basic_node_breaker_network_powsybl_grid: Network) -> None:
+ network = basic_node_breaker_network_powsybl_grid
+ masks = powsybl_masks.create_default_network_masks(network=network)
+ masks.busbar_for_nminus1[0] = True
+
+ nminus1_def = create_nminus1_definition_from_masks(network, masks)
+ contingency_ids = [contingency.id for contingency in nminus1_def.contingencies]
+ busbar_sections = network.get_busbar_sections(attributes=["name"])
+
+ assert busbar_sections.index[0] in contingency_ids
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..694f958af 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
@@ -359,6 +359,7 @@ def loadflow_ref(loadflow: StoredLoadflowReference) -> LoadflowResultsPolars:
reject_critical_branch_threshold=ga_config.reject_critical_branch_threshold,
reject_voltage_jump_threshold=ga_config.reject_voltage_jump_threshold,
reject_critical_va_diff_threshold=ga_config.reject_critical_va_diff_threshold,
+ enable_critical_voltage_rejection=ga_config.enable_critical_voltage_rejection,
critical_voltage_jump_percent=ga_config.critical_voltage_jump_percent,
max_allowed_va_diff=ga_config.critical_va_diff_degree,
)
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..8b6e7270a 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
@@ -107,7 +107,7 @@ def compute_loadflow_and_metrics(
base_case_id: Optional[str],
cases_subset: Optional[Collection[str]] = None,
critical_voltage_jump_percent: float = 5.0,
- max_allowed_va_diff: float = 0.0,
+ max_allowed_va_diff: float = 20.0,
) -> tuple[LoadflowResultsPolars, Optional[AdditionalActionInfo], Metrics]:
"""Compute loadflow results and associated metrics for a given set of strategies.
@@ -192,7 +192,7 @@ def compute_metrics_single_timestep(
additional_info: Optional[AdditionalActionInfo],
base_case_id: Optional[str] = None,
critical_voltage_jump_percent: float = 5.0,
- max_allowed_va_diff: float = 0.0,
+ max_allowed_va_diff: float = 20.0,
) -> Metrics:
"""Compute the metrics for a single timestep
@@ -297,6 +297,7 @@ def evaluate_acceptance(
reject_critical_branch_threshold: float = 1.1,
reject_voltage_jump_threshold: float = 1.1,
reject_critical_va_diff_threshold: float = 1.1,
+ enable_critical_voltage_rejection: bool = False,
early_stopping: bool = False,
) -> Optional[TopologyRejectionReason]:
"""Evaluate if the split loadflow results are acceptable compared to the unsplit results.
@@ -337,6 +338,8 @@ def evaluate_acceptance(
reject_critical_va_diff_threshold : float, optional
The threshold for the critical voltage-angle-difference count increase, by default 1.1
(i.e. the split case must not have more than 110 % of the critical voltage-angle differences in the unsplit case).
+ enable_critical_voltage_rejection : bool, optional
+ Whether to reject based on the critical jump or voltage-angle-difference count increase.
early_stopping : bool, optional
Whether the acceptance is computed as part of an early stopping criterion, will set the early_stopping field in the
TopologyRejectionReason
@@ -395,33 +398,36 @@ def evaluate_acceptance(
early_stopping=early_stopping,
)
- unsplit_voltage_jumps = np.array([metrics_unsplit.extra_scores.get("voltage_jump_count_n_1", 999)], dtype=float)
- split_voltage_jumps = np.array([metrics_split.extra_scores.get("voltage_jump_count_n_1", 0)], dtype=float)
- voltage_jumps_acceptable = np.all(split_voltage_jumps <= unsplit_voltage_jumps * reject_voltage_jump_threshold)
- if not voltage_jumps_acceptable:
- return TopologyRejectionReason(
- criterion="voltage-magnitude",
- value_after=float(split_voltage_jumps.sum()),
- value_before=float(unsplit_voltage_jumps.sum()),
- threshold=reject_voltage_jump_threshold,
- early_stopping=early_stopping,
- description="Critical voltage jump count increased too much.",
- )
+ if enable_critical_voltage_rejection:
+ unsplit_voltage_jumps = np.array([metrics_unsplit.extra_scores.get("voltage_jump_count_n_1", 999)], dtype=float)
+ split_voltage_jumps = np.array([metrics_split.extra_scores.get("voltage_jump_count_n_1", 0)], dtype=float)
+ voltage_jumps_acceptable = np.all(split_voltage_jumps <= unsplit_voltage_jumps * reject_voltage_jump_threshold)
+ if not voltage_jumps_acceptable:
+ return TopologyRejectionReason(
+ criterion="voltage-magnitude",
+ value_after=float(split_voltage_jumps.sum()),
+ value_before=float(unsplit_voltage_jumps.sum()),
+ threshold=reject_voltage_jump_threshold,
+ early_stopping=early_stopping,
+ description="Critical voltage jump count increased too much.",
+ )
- unsplit_critical_va_diff = np.array([metrics_unsplit.extra_scores.get("critical_va_diff_count_n_1", 999)], dtype=float)
- split_critical_va_diff = np.array([metrics_split.extra_scores.get("critical_va_diff_count_n_1", 0)], dtype=float)
- critical_va_diff_acceptable = np.all(
- split_critical_va_diff <= unsplit_critical_va_diff * reject_critical_va_diff_threshold
- )
- if not critical_va_diff_acceptable:
- return TopologyRejectionReason(
- criterion="voltage-angle",
- value_after=float(split_critical_va_diff.sum()),
- value_before=float(unsplit_critical_va_diff.sum()),
- threshold=reject_critical_va_diff_threshold,
- early_stopping=early_stopping,
- description="Critical voltage-angle-difference count increased too much.",
+ unsplit_critical_va_diff = np.array(
+ [metrics_unsplit.extra_scores.get("critical_va_diff_count_n_1", 999)], dtype=float
+ )
+ split_critical_va_diff = np.array([metrics_split.extra_scores.get("critical_va_diff_count_n_1", 0)], dtype=float)
+ critical_va_diff_acceptable = np.all(
+ split_critical_va_diff <= unsplit_critical_va_diff * reject_critical_va_diff_threshold
)
+ if not critical_va_diff_acceptable:
+ return TopologyRejectionReason(
+ criterion="voltage-angle",
+ value_after=float(split_critical_va_diff.sum()),
+ value_before=float(unsplit_critical_va_diff.sum()),
+ threshold=reject_critical_va_diff_threshold,
+ early_stopping=early_stopping,
+ description="Critical voltage-angle-difference count increased too much.",
+ )
return None
@@ -432,6 +438,8 @@ def compute_remaining_loadflows(
base_case_id: Optional[str],
loadflows_subset: LoadflowResultsPolars,
cases_subset: list[str],
+ critical_voltage_jump_percent: float = 5.0,
+ max_allowed_va_diff: float = 20.0,
) -> tuple[LoadflowResultsPolars, Metrics]:
"""Compute the loadflows for the remaining contingencies that were not included in the early stopping subset.
@@ -452,6 +460,10 @@ def compute_remaining_loadflows(
cases_subset : list[str]
The contingency case ids that were included in the early stopping subset for each timestep. This could be extracted
from the loadflows_subset but as it is available it is faster to pass it in.
+ critical_voltage_jump_percent : float, optional
+ Voltage jumps larger than this percentage are counted as critical in the AC metrics.
+ max_allowed_va_diff : float, optional
+ Voltage angle differences larger than this value in degrees are counted as critical in the AC metrics.
Returns
-------
@@ -485,6 +497,8 @@ def compute_remaining_loadflows(
loadflow=lfs,
additional_info=additional_info_remaining,
base_case_id=base_case_id,
+ critical_voltage_jump_percent=critical_voltage_jump_percent,
+ max_allowed_va_diff=max_allowed_va_diff,
)
# Restore the original N-1 definitions in the runners
@@ -520,6 +534,9 @@ class ACScoringParameters:
reject_critical_va_diff_threshold: float
"""The rejection threshold for the critical voltage-angle-difference count increase."""
+ enable_critical_voltage_rejection: bool
+ """Whether to reject based on the critical voltage jump or voltage-angle-difference count increase."""
+
critical_voltage_jump_percent: float
"""Voltage jumps larger than this percentage are counted as critical in the AC metrics."""
@@ -638,6 +655,7 @@ def score_strategy_worst_k(
reject_critical_branch_threshold=scoring_params.reject_critical_branch_threshold,
reject_voltage_jump_threshold=scoring_params.reject_voltage_jump_threshold,
reject_critical_va_diff_threshold=scoring_params.reject_critical_va_diff_threshold,
+ enable_critical_voltage_rejection=scoring_params.enable_critical_voltage_rejection,
early_stopping=True,
)
return EarlyStoppingStageResult(
@@ -662,6 +680,7 @@ def score_strategy_worst_k(
reject_critical_branch_threshold=scoring_params.reject_critical_branch_threshold,
reject_voltage_jump_threshold=scoring_params.reject_voltage_jump_threshold,
reject_critical_va_diff_threshold=scoring_params.reject_critical_va_diff_threshold,
+ enable_critical_voltage_rejection=scoring_params.enable_critical_voltage_rejection,
early_stopping=False,
)
return EarlyStoppingStageResult(
@@ -781,6 +800,8 @@ def score_topology_remaining(
base_case_id=scoring_params.base_case_id,
loadflows_subset=early_stage_result.loadflow_results,
cases_subset=early_stage_result.cases_subset,
+ critical_voltage_jump_percent=scoring_params.critical_voltage_jump_percent,
+ max_allowed_va_diff=scoring_params.max_allowed_va_diff,
)
else:
lfs = early_stage_result.loadflow_results
@@ -794,6 +815,7 @@ def score_topology_remaining(
reject_critical_branch_threshold=scoring_params.reject_critical_branch_threshold,
reject_voltage_jump_threshold=scoring_params.reject_voltage_jump_threshold,
reject_critical_va_diff_threshold=scoring_params.reject_critical_va_diff_threshold,
+ enable_critical_voltage_rejection=scoring_params.enable_critical_voltage_rejection,
early_stopping=False,
)
return TopologyScoringResult(loadflow_results=lfs, metrics=metrics, rejection_reason=rejection_reason)
@@ -827,6 +849,8 @@ def score_strategy_full(
runner=runner,
topology=topology,
base_case_id=scoring_params.base_case_id,
+ critical_voltage_jump_percent=scoring_params.critical_voltage_jump_percent,
+ max_allowed_va_diff=scoring_params.max_allowed_va_diff,
)
rejection_reason = evaluate_acceptance(
metrics_split=metrics,
@@ -834,6 +858,9 @@ def score_strategy_full(
reject_convergence_threshold=scoring_params.reject_convergence_threshold,
reject_overload_threshold=scoring_params.reject_overload_threshold,
reject_critical_branch_threshold=scoring_params.reject_critical_branch_threshold,
+ reject_voltage_jump_threshold=scoring_params.reject_voltage_jump_threshold,
+ reject_critical_va_diff_threshold=scoring_params.reject_critical_va_diff_threshold,
+ enable_critical_voltage_rejection=scoring_params.enable_critical_voltage_rejection,
early_stopping=False,
)
return TopologyScoringResult(loadflow_results=lfs, metrics=metrics, rejection_reason=rejection_reason)
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
index 66ec124c7..121321ad9 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/dc/genetic_functions/initialization.py
@@ -461,14 +461,34 @@ def update_single_pair_bb_outage_information(
needs_penalty_baseline = (
should_enable_bb_outage and not bb_outage_as_nminus1 and has_rel_bb_outage_data and has_monitored_branches
)
- # Keep or create a baseline only when busbar outages stay enabled for this run.
- should_keep_or_create_baseline = should_enable_bb_outage and (has_stored_bb_outage_baseline or needs_penalty_baseline)
+ # Keep or create a baseline only in penalty mode. In N-1 mode, baseline must not influence case counting.
+ should_keep_or_create_baseline = (
+ should_enable_bb_outage and not bb_outage_as_nminus1 and (has_stored_bb_outage_baseline or needs_penalty_baseline)
+ )
+
+ base_nminus1_cases = (
+ dynamic_information.n_outages + dynamic_information.n_multi_outages + dynamic_information.n_inj_failures
+ )
+ expected_nminus1_cases = (
+ base_nminus1_cases + dynamic_information.n_bb_outages
+ if should_enable_bb_outage and bb_outage_as_nminus1
+ else base_nminus1_cases
+ )
+ contingency_ids = list(solver_config.contingency_ids)
+ if len(contingency_ids) < expected_nminus1_cases:
+ if len(contingency_ids) < base_nminus1_cases:
+ contingency_ids.extend(f"nminus1_case_{i}" for i in range(len(contingency_ids), base_nminus1_cases))
+ contingency_ids.extend(
+ f"bb_outage_case_{i}"
+ for i in range(len(contingency_ids) - base_nminus1_cases, expected_nminus1_cases - base_nminus1_cases)
+ )
updated_solver_config = replace(
solver_config,
enable_bb_outages=should_enable_bb_outage,
bb_outage_as_nminus1=bb_outage_as_nminus1,
clip_bb_outage_penalty=clip_bb_outage_penalty,
+ contingency_ids=contingency_ids,
)
updated_action_set = (
dynamic_information.action_set
diff --git a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/ac_params.py b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/ac_params.py
index dd3635f1b..78c9221b6 100644
--- a/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/ac_params.py
+++ b/packages/topology_optimizer_pkg/src/toop_engine_topology_optimizer/interfaces/messages/ac_params.py
@@ -76,6 +76,12 @@ class ACGAParameters(BaseModel):
"""The rejection threshold for the critical voltage-angle-difference count increase, i.e. the split case must have
less than 10% more critical voltage-angle differences than the unsplit case or it will be rejected."""
+ enable_critical_voltage_rejection: bool = False
+ """Whether to use critical voltage jumps and voltage-angle-difference counts as an acceptance/rejection criterion.
+
+ The associated metrics are still computed and reported when this flag is disabled.
+ """
+
critical_voltage_jump_percent: float = 5.0
"""Voltage jumps larger than this percentage are counted as critical in the AC metrics."""
diff --git a/packages/topology_optimizer_pkg/tests/ac/test_ac_scoring_functions.py b/packages/topology_optimizer_pkg/tests/ac/test_ac_scoring_functions.py
index ace1b2241..6aee3b7e4 100644
--- a/packages/topology_optimizer_pkg/tests/ac/test_ac_scoring_functions.py
+++ b/packages/topology_optimizer_pkg/tests/ac/test_ac_scoring_functions.py
@@ -26,6 +26,7 @@
evaluate_acceptance,
extract_switching_distance,
score_remaining_contingency_batch,
+ score_strategy_full,
score_strategy_worst_k_batch,
score_topology_batch,
)
@@ -83,6 +84,7 @@ def fake_worst_k(topology, runner, loadflow_results_unsplit, metrics_unsplit, sc
reject_critical_branch_threshold=1.1,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
critical_voltage_jump_percent=5.0,
max_allowed_va_diff=0.0,
base_case_id=None,
@@ -172,6 +174,7 @@ def fake_remaining(topology, runner, metrics_unsplit, scoring_params, early_stag
reject_critical_branch_threshold=1.1,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
critical_voltage_jump_percent=5.0,
max_allowed_va_diff=0.0,
base_case_id=None,
@@ -247,6 +250,7 @@ def fail_worst_k_batch(*args, **kwargs):
reject_critical_branch_threshold=1.1,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
critical_voltage_jump_percent=5.0,
max_allowed_va_diff=0.0,
base_case_id=None,
@@ -406,6 +410,7 @@ def test_evaluate_acceptance_identical_metrics():
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.0,
reject_critical_va_diff_threshold=1.0,
+ enable_critical_voltage_rejection=True,
)
assert reason is None, "Results rejected although they are the same as before and thresholds is exactly 1."
# Not accepted if any thresholds < 1.
@@ -417,6 +422,7 @@ def test_evaluate_acceptance_identical_metrics():
reject_critical_branch_threshold=0.9,
reject_voltage_jump_threshold=0.9,
reject_critical_va_diff_threshold=0.9,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results rejected although they are the same as before and thresholds is below 1."
assert reason.criterion == "convergence"
@@ -429,6 +435,7 @@ def test_evaluate_acceptance_identical_metrics():
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.0,
reject_critical_va_diff_threshold=1.0,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results rejected although they are just as good and convergence thresholds below 1."
assert reason.criterion == "convergence"
@@ -440,6 +447,7 @@ def test_evaluate_acceptance_identical_metrics():
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.0,
reject_critical_va_diff_threshold=1.0,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results rejected although they are just as good and overload thresholds below 1."
assert reason.criterion == "overload-energy"
@@ -452,6 +460,7 @@ def test_evaluate_acceptance_identical_metrics():
reject_critical_branch_threshold=0.9,
reject_voltage_jump_threshold=1.0,
reject_critical_va_diff_threshold=1.0,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results rejected although they are just as good and crit branch thresholds below 1."
assert reason.criterion == "critical-branch-count"
@@ -465,6 +474,7 @@ def test_evaluate_acceptance_identical_metrics():
reject_critical_branch_threshold=1.1,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
)
assert reason is None, "Results rejected although they are just as good and thresholds above 1."
@@ -500,6 +510,7 @@ def test_evaluate_acceptance_improved_metrics():
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.0,
reject_critical_va_diff_threshold=1.0,
+ enable_critical_voltage_rejection=True,
)
assert reason is None, "Results rejected although they are the same as before and thresholds is exactly 1."
# Accepted if all thresholds=0.9.
@@ -511,6 +522,7 @@ def test_evaluate_acceptance_improved_metrics():
reject_critical_branch_threshold=0.9,
reject_voltage_jump_threshold=0.9,
reject_critical_va_diff_threshold=0.9,
+ enable_critical_voltage_rejection=True,
)
assert reason is None, "Results not accepted although they improved by exactly 10 percent and thresholds is 0.9."
@@ -534,6 +546,7 @@ def test_evaluate_acceptance_improved_metrics():
reject_critical_branch_threshold=0.8,
reject_voltage_jump_threshold=0.8,
reject_critical_va_diff_threshold=0.8,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results accepted although they only improved by exactly 10 percent and thresholds is 0.8."
assert reason.criterion == "convergence"
@@ -547,6 +560,7 @@ def test_evaluate_acceptance_improved_metrics():
reject_critical_branch_threshold=1.1,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
)
assert reason is None, "Results rejected although they are just as good and thresholds above 1."
@@ -582,6 +596,7 @@ def test_evaluate_acceptance_worse_metrics():
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.0,
reject_critical_va_diff_threshold=1.0,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results accepted although they are worse as before and thresholds is exactly 1."
assert reason.criterion == "convergence"
@@ -595,6 +610,7 @@ def test_evaluate_acceptance_worse_metrics():
reject_critical_branch_threshold=0.9,
reject_voltage_jump_threshold=0.9,
reject_critical_va_diff_threshold=0.9,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None, "Results accepted although they got worse by exactly 10 percent and thresholds is 0.9."
assert reason.criterion == "convergence"
@@ -607,6 +623,7 @@ def test_evaluate_acceptance_worse_metrics():
reject_critical_branch_threshold=1.1,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
)
assert reason is None, "Results not accepted although they only got worse by exactly 10 percent and thresholds is 1.1."
@@ -641,6 +658,7 @@ def test_evaluate_acceptance_rejects_voltage_jump_increase() -> None:
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None
@@ -677,12 +695,118 @@ def test_evaluate_acceptance_rejects_critical_va_diff_increase() -> None:
reject_critical_branch_threshold=1.0,
reject_voltage_jump_threshold=1.1,
reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
)
assert reason is not None
assert reason.criterion == "voltage-angle"
+def test_evaluate_acceptance_zero_baseline_va_diff_respects_toggle() -> None:
+ metrics_unsplit = Metrics(
+ fitness=-1.0,
+ extra_scores={
+ "non_converging_loadflows": 1,
+ "overload_energy_n_1": 10.0,
+ "critical_branch_count_n_1": 1,
+ "voltage_jump_count_n_1": 1,
+ "critical_va_diff_count_n_1": 0,
+ },
+ )
+ metrics_split = Metrics(
+ fitness=-1.0,
+ extra_scores={
+ "non_converging_loadflows": 1,
+ "overload_energy_n_1": 10.0,
+ "critical_branch_count_n_1": 1,
+ "voltage_jump_count_n_1": 1,
+ "critical_va_diff_count_n_1": 1,
+ },
+ )
+
+ accepted_reason = evaluate_acceptance(
+ metrics_split=metrics_split,
+ metrics_unsplit=metrics_unsplit,
+ reject_convergence_threshold=1.0,
+ reject_overload_threshold=1.0,
+ reject_critical_branch_threshold=1.0,
+ reject_voltage_jump_threshold=1.0,
+ reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=False,
+ )
+
+ rejected_reason = evaluate_acceptance(
+ metrics_split=metrics_split,
+ metrics_unsplit=metrics_unsplit,
+ reject_convergence_threshold=1.0,
+ reject_overload_threshold=1.0,
+ reject_critical_branch_threshold=1.0,
+ reject_voltage_jump_threshold=1.0,
+ reject_critical_va_diff_threshold=1.1,
+ enable_critical_voltage_rejection=True,
+ )
+
+ assert accepted_reason is None
+ assert rejected_reason is not None
+ assert rejected_reason.criterion == "voltage-angle"
+
+
+def test_score_strategy_full_forwards_thresholds_and_toggle(monkeypatch: pytest.MonkeyPatch) -> None:
+ topology = ACOptimTopology(
+ actions=[1],
+ disconnections=[],
+ pst_setpoints=None,
+ unsplit=False,
+ timestep=0,
+ strategy_hash=b"full-path",
+ optimization_id="test",
+ optimizer_type=OptimizerType.AC,
+ fitness=0.0,
+ metrics={},
+ worst_k_contingency_cases=["c1"],
+ )
+ scoring_params = ACScoringParameters(
+ reject_convergence_threshold=1.0,
+ reject_overload_threshold=0.95,
+ reject_critical_branch_threshold=1.1,
+ reject_voltage_jump_threshold=0.7,
+ reject_critical_va_diff_threshold=0.85,
+ enable_critical_voltage_rejection=True,
+ critical_voltage_jump_percent=7.5,
+ max_allowed_va_diff=12.0,
+ base_case_id="BASECASE",
+ early_stop_validation=False,
+ )
+
+ split_metrics = Metrics(fitness=1.0, extra_scores={"overload_energy_n_1": 1.0})
+ unsplit_metrics = Metrics(fitness=1.0, extra_scores={"overload_energy_n_1": 1.0})
+
+ def fake_compute_loadflow_and_metrics(**kwargs):
+ assert kwargs["critical_voltage_jump_percent"] == 7.5
+ assert kwargs["max_allowed_va_diff"] == 12.0
+ return Mock(spec=LoadflowResultsPolars), None, split_metrics
+
+ def fake_evaluate_acceptance(**kwargs):
+ assert kwargs["reject_voltage_jump_threshold"] == 0.7
+ assert kwargs["reject_critical_va_diff_threshold"] == 0.85
+ assert kwargs["enable_critical_voltage_rejection"] is True
+
+ monkeypatch.setattr(
+ "toop_engine_topology_optimizer.ac.scoring_functions.compute_loadflow_and_metrics",
+ fake_compute_loadflow_and_metrics,
+ )
+ monkeypatch.setattr("toop_engine_topology_optimizer.ac.scoring_functions.evaluate_acceptance", fake_evaluate_acceptance)
+
+ result = score_strategy_full(
+ topology=topology,
+ runner=Mock(spec=AbstractLoadflowRunner),
+ metrics_unsplit=unsplit_metrics,
+ scoring_params=scoring_params,
+ )
+
+ assert result.metrics == split_metrics
+
+
def test_compute_remaining_loadflows(grid_folder: Path) -> None:
"""Test the compute_remaining_loadflows function.
diff --git a/packages/topology_optimizer_pkg/tests/benchmark/test_benchmark_utils.py b/packages/topology_optimizer_pkg/tests/benchmark/test_benchmark_utils.py
index 4c81cf2fe..1358d5b28 100644
--- a/packages/topology_optimizer_pkg/tests/benchmark/test_benchmark_utils.py
+++ b/packages/topology_optimizer_pkg/tests/benchmark/test_benchmark_utils.py
@@ -11,6 +11,7 @@
import pytest
from omegaconf import DictConfig
+from toop_engine_grid_helpers.powsybl.example_grids import basic_node_breaker_network_powsybl
from toop_engine_interfaces.messages.preprocess.preprocess_commands import (
CgmesImporterParameters,
PreprocessParameters,
@@ -21,9 +22,11 @@
get_paths,
prepare_importer_parameters,
run_pipeline,
+ run_preprocessing,
run_task_process,
set_environment_variables,
)
+from toop_engine_topology_optimizer.dc.genetic_functions.initialization import update_static_information
def test_run_task_process_no_conn(dc_config):
@@ -225,6 +228,97 @@ def _get_serialized_topology_fitness(topology: dict) -> float:
assert ac_metrics["dc_info"]["disconnections"] == (expected_topology.get("disconnections") or [])
+def test_run_task_process_with_imported_busbar_outages(tmp_path: Path) -> None:
+ input_grid = tmp_path / "grid.xiidm"
+ net = basic_node_breaker_network_powsybl()
+ # create a busbar outage related overload for L2 when the second busbar in VL2 is outaged
+ open_switches = ["load1_DISCONNECTOR_18_0", "L71_DISCONNECTOR_10_1"]
+ close_switches = ["load1_DISCONNECTOR_18_1", "L71_DISCONNECTOR_10_0"]
+ for switch in close_switches:
+ net.close_switch(switch)
+ for switch in open_switches:
+ net.open_switch(switch)
+
+ net.save(input_grid)
+
+ data_folder = tmp_path / input_grid.stem
+ importer_parameters = prepare_importer_parameters(input_grid, data_folder)
+ preprocessing_parameters = PreprocessParameters(action_set_clip=2**10, preprocess_bb_outages=True)
+
+ _, static_information = run_preprocessing(
+ importer_parameters=importer_parameters,
+ data_folder=data_folder,
+ preprocessing_parameters=preprocessing_parameters,
+ is_pandapower_net=False,
+ )
+
+ assert (
+ static_information.dynamic_information.action_set.rel_bb_outage_data is not None
+ or static_information.dynamic_information.non_rel_bb_outage_data is not None
+ or static_information.dynamic_information.bb_outage_baseline_analysis is not None
+ )
+ n_worst_contingencies = min(20, int(static_information.dynamic_information.n_nminus1_cases))
+ assert n_worst_contingencies > 0
+
+ dc_config = DictConfig(
+ {
+ "task_name": "test_busbar_outage_optimizer",
+ "fixed_files": [str(data_folder / "static_information.hdf5")],
+ "double_precision": None,
+ "tensorboard_dir": str(tmp_path / "results" / "{task_name}"),
+ "stats_dir": str(tmp_path / "results" / "{task_name}"),
+ "summary_frequency": None,
+ "checkpoint_frequency": None,
+ "stdout": None,
+ "double_limits": None,
+ "num_cuda_devices": 1,
+ "omp_num_threads": 1,
+ "xla_force_host_platform_device_count": None,
+ "output_json": str(tmp_path / "results" / "output.json"),
+ "lf_config": {"distributed": False},
+ "ga_config": {
+ "runtime_seconds": 5,
+ "enable_bb_outage": True,
+ "bb_outage_as_nminus1": True,
+ "n_worst_contingencies": n_worst_contingencies,
+ "target_metrics": [["overload_energy_n_1", 1.0]],
+ "me_descriptors": [{"metric": "split_subs", "num_cells": 5}],
+ "observed_metrics": ["overload_energy_n_1", "split_subs"],
+ },
+ }
+ )
+
+ updated_static_information = update_static_information(
+ static_informations=(static_information,),
+ batch_size=8,
+ enable_nodal_inj_optim=False,
+ enable_parallel_pst_group_optim=False,
+ enable_bb_outage=True,
+ bb_outage_as_nminus1=True,
+ clip_bb_outage_penalty=False,
+ bb_outage_more_islands_penalty=0.0,
+ )[0]
+ assert updated_static_information.solver_config.enable_bb_outages
+ # assert updated_static_information.solver_config.bb_outage_as_nminus1
+
+ set_environment_variables(dc_config)
+ result = run_task_process(dc_config)
+
+ assert result is not None
+ assert "overload_energy_n_1" in result["initial_metrics"]
+ assert result["initial_metrics"]["overload_energy_n_1"] > 0
+ assert result["max_fitness"] > result["initial_fitness"]
+ assert len(result["best_topos"]) > 0
+ best_topology = max(result["best_topos"], key=lambda topo: topo["metrics"]["fitness"])
+ assert best_topology["metrics"]["fitness"] > result["initial_fitness"]
+ assert (
+ best_topology["metrics"]["extra_scores"]["overload_energy_n_1"] <= result["initial_metrics"]["overload_energy_n_1"]
+ )
+ result_dir = Path(dc_config["output_json"]).parent
+ assert result_dir.exists()
+ assert len(list(result_dir.iterdir())) > 0
+
+
def test_run_pipeline_no_optimization_stage(pipeline_and_configs, preprocessing_parameters):
pipeline_cfg, dc_cfg, ac_cfg = pipeline_and_configs
pipeline_cfg = PipelineConfig(**pipeline_cfg)
diff --git a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py
index c312d639e..f6c751296 100644
--- a/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py
+++ b/packages/topology_optimizer_pkg/tests/dc/genetic_functions/test_initialization.py
@@ -10,7 +10,7 @@
import pytest
from jax_dataclasses import replace
from toop_engine_dc_solver.jax.inputs import load_static_information
-from toop_engine_dc_solver.jax.types import BBOutageBaselineAnalysis
+from toop_engine_dc_solver.jax.types import BBOutageBaselineAnalysis, NonRelBBOutageData
from toop_engine_topology_optimizer.dc.genetic_functions.genotype import Genotype
from toop_engine_topology_optimizer.dc.genetic_functions.initialization import (
get_repertoire_metrics,
@@ -142,6 +142,53 @@ def test_update_static_information_removes_busbar_data_when_disabled(static_info
assert updated.dynamic_information.action_set.rel_bb_outage_data is None
+def test_update_static_information_extends_contingency_ids_for_bb_outage_nminus1(static_information_file: str) -> None:
+ static_information = load_static_information(static_information_file)
+ n_timesteps = static_information.dynamic_information.n_timesteps
+ base_cases = (
+ static_information.dynamic_information.n_outages
+ + static_information.dynamic_information.n_multi_outages
+ + static_information.dynamic_information.n_inj_failures
+ )
+
+ static_information = replace(
+ static_information,
+ solver_config=replace(
+ static_information.solver_config,
+ contingency_ids=[f"cont_{i}" for i in range(base_cases)],
+ ),
+ dynamic_information=replace(
+ static_information.dynamic_information,
+ non_rel_bb_outage_data=NonRelBBOutageData(
+ branch_outages=jnp.zeros((2, 1), dtype=int),
+ nodal_indices=jnp.zeros((2,), dtype=int),
+ deltap=jnp.zeros((2, n_timesteps), dtype=float),
+ ),
+ bb_outage_baseline_analysis=BBOutageBaselineAnalysis(
+ overload=jnp.array(1.0),
+ success_count=jnp.array(2),
+ more_splits_penalty=jnp.array(50.0),
+ overload_weight=static_information.dynamic_information.branch_limits.overload_weight,
+ max_mw_flow=static_information.dynamic_information.branch_limits.max_mw_flow,
+ ),
+ ),
+ )
+
+ updated = update_static_information(
+ (static_information,),
+ batch_size=3,
+ enable_nodal_inj_optim=False,
+ enable_parallel_pst_group_optim=False,
+ enable_bb_outage=True,
+ bb_outage_as_nminus1=True,
+ clip_bb_outage_penalty=False,
+ bb_outage_more_islands_penalty=125.0,
+ )[0]
+
+ assert updated.dynamic_information.bb_outage_baseline_analysis is None
+ assert len(updated.solver_config.contingency_ids) == base_cases + updated.dynamic_information.n_bb_outages
+
+
def test_initialize_genetic_algorithm(
static_information_file: str,
) -> None:
diff --git a/packages/topology_optimizer_pkg/tests/interfaces/messages/test_ac_ga_params.py b/packages/topology_optimizer_pkg/tests/interfaces/messages/test_ac_ga_params.py
index b5a3e01c7..77d813978 100644
--- a/packages/topology_optimizer_pkg/tests/interfaces/messages/test_ac_ga_params.py
+++ b/packages/topology_optimizer_pkg/tests/interfaces/messages/test_ac_ga_params.py
@@ -24,6 +24,7 @@ def test_acga_parameters_default():
assert params.reject_critical_branch_threshold == 1.1
assert params.reject_voltage_jump_threshold == 1.1
assert params.reject_critical_va_diff_threshold == 1.1
+ assert params.enable_critical_voltage_rejection is False
assert params.critical_voltage_jump_percent == 5.0
assert params.critical_va_diff_degree == 20.0
# Probabilities sum to one
@@ -54,6 +55,7 @@ def test_acga_parameters_filter_strategy():
reject_critical_branch_threshold=0.8,
reject_voltage_jump_threshold=0.7,
reject_critical_va_diff_threshold=0.85,
+ enable_critical_voltage_rejection=True,
critical_voltage_jump_percent=7.5,
critical_va_diff_degree=12.0,
filter_strategy=filter_strat,